diff --git a/apps/mobile/.rnstorybook/.gitignore b/apps/mobile/.rnstorybook/.gitignore
new file mode 100644
index 00000000000..575262128f0
--- /dev/null
+++ b/apps/mobile/.rnstorybook/.gitignore
@@ -0,0 +1 @@
+storybook.requires.ts
diff --git a/apps/mobile/.rnstorybook/StorybookRouterProvider.tsx b/apps/mobile/.rnstorybook/StorybookRouterProvider.tsx
new file mode 100644
index 00000000000..29d1a8bd415
--- /dev/null
+++ b/apps/mobile/.rnstorybook/StorybookRouterProvider.tsx
@@ -0,0 +1,57 @@
+import {
+ PreviewRouteContext,
+ type PreviewRouteContextType,
+} from "expo-router/build/link/preview/PreviewRouteContext";
+import {
+ LinkingContext,
+ NavigationContainer,
+ NavigationContainerRefContext,
+ ThemeProvider,
+ UNSTABLE_UnhandledLinkingContext as UnhandledLinkingContext,
+} from "expo-router/react-navigation";
+import { type PropsWithChildren, useContext } from "react";
+import { NAV_THEME } from "@/lib/theme";
+
+import {
+ storybookLinkingContext,
+ storybookLinkingOptions,
+} from "./router/LinkingContext";
+import { storybookUnhandledLinkingContext } from "./router/UnhandledLinkingContext";
+
+const storybookRoute = {
+ pathname: "/storybook",
+ params: {},
+ segments: ["storybook"],
+} satisfies PreviewRouteContextType;
+
+export function StorybookRouterProvider({ children }: PropsWithChildren) {
+ const navigationRef = useContext(NavigationContainerRefContext);
+
+ const content = (
+
+
+
+
+ {children}
+
+
+
+
+ );
+
+ if (navigationRef) {
+ return content;
+ }
+
+ return (
+
+ {content}
+
+ );
+}
diff --git a/apps/mobile/.rnstorybook/index.tsx b/apps/mobile/.rnstorybook/index.tsx
new file mode 100644
index 00000000000..2ec6aa32348
--- /dev/null
+++ b/apps/mobile/.rnstorybook/index.tsx
@@ -0,0 +1,26 @@
+// Entry point used by app/_layout.tsx when EXPO_PUBLIC_STORYBOOK=true.
+// `storybook.requires` is generated by `sb-rn-get-stories` at dev-time —
+// see the "storybook" script in package.json. It is gitignored.
+import AsyncStorage from "@react-native-async-storage/async-storage";
+import { StorybookRouterProvider } from "./StorybookRouterProvider";
+import { view } from "./storybook.requires";
+
+// Storybook v9 react-native requires the `storage` adapter to be passed
+// explicitly when `shouldPersistSelection: true` — it does not auto-detect
+// AsyncStorage. Without this, attempts to read the persisted story selection
+// throw `TypeError: Cannot read property 'getItem' of undefined`.
+const StorybookUIRoot = view.getStorybookUI({
+ shouldPersistSelection: true,
+ storage: {
+ getItem: AsyncStorage.getItem,
+ setItem: AsyncStorage.setItem,
+ },
+});
+
+export default function StorybookRoot() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/mobile/.rnstorybook/main.js b/apps/mobile/.rnstorybook/main.js
new file mode 100644
index 00000000000..a1ef6908a82
--- /dev/null
+++ b/apps/mobile/.rnstorybook/main.js
@@ -0,0 +1,13 @@
+/** @type {import('@storybook/react-native').StorybookConfig} */
+const main = {
+ stories: [
+ "./stories/**/*.stories.?(ts|tsx|js|jsx)",
+ "../components/**/*.stories.?(ts|tsx|js|jsx)",
+ ],
+ addons: [
+ "@storybook/addon-ondevice-controls",
+ "@storybook/addon-ondevice-actions",
+ ],
+};
+
+module.exports = main;
diff --git a/apps/mobile/.rnstorybook/mocks/tty.js b/apps/mobile/.rnstorybook/mocks/tty.js
new file mode 100644
index 00000000000..f053ebf7976
--- /dev/null
+++ b/apps/mobile/.rnstorybook/mocks/tty.js
@@ -0,0 +1 @@
+module.exports = {};
diff --git a/apps/mobile/.rnstorybook/preview.tsx b/apps/mobile/.rnstorybook/preview.tsx
new file mode 100644
index 00000000000..aea298c6f57
--- /dev/null
+++ b/apps/mobile/.rnstorybook/preview.tsx
@@ -0,0 +1,35 @@
+import { PortalHost } from "@rn-primitives/portal";
+import type { Preview } from "@storybook/react-native";
+import { View } from "react-native";
+import { cn } from "@/lib/utils";
+
+// NavigationContainer is provided by StorybookRouterProvider —
+// do NOT add one here or SDK 56's expo-router compat check will fail.
+const preview: Preview = {
+ decorators: [
+ (Story, context) => {
+ const isFullscreen = context.parameters?.layout === "fullscreen";
+ return (
+
+
+
+
+ );
+ },
+ ],
+ parameters: {
+ controls: {
+ matchers: {
+ color: /(background|color|foreground)$/i,
+ date: /Date$/i,
+ },
+ },
+ moduleMock: {
+ mockingPairedModules: {
+ tty: () => require("./mocks/tty"),
+ },
+ },
+ },
+};
+
+export default preview;
diff --git a/apps/mobile/.rnstorybook/router/LinkingContext.ts b/apps/mobile/.rnstorybook/router/LinkingContext.ts
new file mode 100644
index 00000000000..accda4e5bdc
--- /dev/null
+++ b/apps/mobile/.rnstorybook/router/LinkingContext.ts
@@ -0,0 +1,23 @@
+import type {
+ LinkingOptions,
+ ParamListBase,
+} from "expo-router/react-navigation";
+import * as React from "react";
+
+export const storybookLinkingOptions: LinkingOptions = {
+ enabled: false,
+ prefixes: [],
+ config: {
+ screens: {
+ Storybook: "*",
+ },
+ },
+};
+
+export const storybookLinkingContext = {
+ options: storybookLinkingOptions,
+};
+
+export const LinkingContext = React.createContext(storybookLinkingContext);
+
+LinkingContext.displayName = "LinkingContext";
diff --git a/apps/mobile/.rnstorybook/router/UnhandledLinkingContext.ts b/apps/mobile/.rnstorybook/router/UnhandledLinkingContext.ts
new file mode 100644
index 00000000000..d122a7052c4
--- /dev/null
+++ b/apps/mobile/.rnstorybook/router/UnhandledLinkingContext.ts
@@ -0,0 +1,19 @@
+import * as React from "react";
+
+export type StorybookUnhandledLinkingContextValue = {
+ lastUnhandledLink: string | undefined;
+ setLastUnhandledLink: (lastUnhandledUrl: string | undefined) => void;
+};
+
+export const storybookUnhandledLinkingContext: StorybookUnhandledLinkingContextValue =
+ {
+ lastUnhandledLink: undefined,
+ setLastUnhandledLink: () => {},
+ };
+
+export const UnhandledLinkingContext =
+ React.createContext(
+ storybookUnhandledLinkingContext,
+ );
+
+UnhandledLinkingContext.displayName = "UnhandledLinkingContext";
diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx
index 283df545853..aead9f5a6b0 100644
--- a/apps/mobile/app/_layout.tsx
+++ b/apps/mobile/app/_layout.tsx
@@ -1,6 +1,51 @@
import "react-native-get-random-values"; // MUST BE FIRST IMPORT
import "../global.css";
+import {
+ Geist_400Regular,
+ Geist_500Medium,
+ Geist_600SemiBold,
+ Geist_700Bold,
+ useFonts,
+} from "@expo-google-fonts/geist";
+import {
+ GeistMono_400Regular,
+ GeistMono_500Medium,
+} from "@expo-google-fonts/geist-mono";
+import * as SplashScreen from "expo-splash-screen";
+import { useEffect } from "react";
import { RootLayout } from "@/screens/RootLayout";
-export default RootLayout;
+SplashScreen.preventAutoHideAsync().catch(() => {
+ /* splash already hidden — fine to swallow */
+});
+
+const StorybookRoot =
+ process.env.EXPO_PUBLIC_STORYBOOK === "true"
+ ? require("../.rnstorybook").default
+ : null;
+
+export default function App() {
+ const [fontsLoaded, fontError] = useFonts({
+ Geist_400Regular,
+ Geist_500Medium,
+ Geist_600SemiBold,
+ Geist_700Bold,
+ GeistMono_400Regular,
+ GeistMono_500Medium,
+ });
+
+ useEffect(() => {
+ if (fontsLoaded || fontError) {
+ if (fontError) {
+ console.error("Font loading failed:", fontError);
+ }
+ SplashScreen.hideAsync().catch(() => {});
+ }
+ }, [fontsLoaded, fontError]);
+
+ if (!fontsLoaded && !fontError) return null;
+
+ const Root = StorybookRoot ?? RootLayout;
+ return ;
+}
diff --git a/apps/mobile/components/AppHeader/AppHeader.stories.tsx b/apps/mobile/components/AppHeader/AppHeader.stories.tsx
new file mode 100644
index 00000000000..c2554e260d0
--- /dev/null
+++ b/apps/mobile/components/AppHeader/AppHeader.stories.tsx
@@ -0,0 +1,57 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import { Settings } from "lucide-react-native";
+import { AppHeader } from "./AppHeader";
+
+const meta: Meta = {
+ title: "Molecules/AppHeader",
+ component: AppHeader,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Top navigation header on every chat view. Three-region flex: leading back IconButton (optional) + centered title/subtitle + trailing actions IconButton (optional). `isScrolled` adds a layered shadow for separation from scrolling content. Composes first-party IconButton + Text.",
+ },
+ },
+ layout: "fullscreen",
+ },
+ args: {
+ title: "Fix auth bug",
+ subtitle: "superset · main",
+ showBack: true,
+ showActions: true,
+ isScrolled: false,
+ },
+ argTypes: {
+ title: { control: "text" },
+ subtitle: { control: "text" },
+ showBack: { control: "boolean" },
+ showActions: { control: "boolean" },
+ isScrolled: { control: "boolean", description: "Adds 1px bottom shadow" },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const NoSubtitle: Story = {
+ args: { subtitle: undefined },
+};
+
+export const NoBack: Story = {
+ args: { showBack: false, title: "Sessions" },
+};
+
+export const SimpleNoActions: Story = {
+ args: { subtitle: undefined, showActions: false },
+};
+
+export const Scrolled: Story = {
+ args: { isScrolled: true },
+};
+
+export const CustomActionsIcon: Story = {
+ args: { actionsIcon: Settings, actionsAccessibilityLabel: "Settings" },
+};
diff --git a/apps/mobile/components/AppHeader/AppHeader.tsx b/apps/mobile/components/AppHeader/AppHeader.tsx
new file mode 100644
index 00000000000..ba1e4b8be83
--- /dev/null
+++ b/apps/mobile/components/AppHeader/AppHeader.tsx
@@ -0,0 +1,99 @@
+import { ArrowLeft, type LucideIcon, MoreVertical } from "lucide-react-native";
+import { View, type ViewProps } from "react-native";
+import { IconButton } from "@/components/IconButton";
+import { Text } from "@/components/ui/text";
+import { cn } from "@/lib/utils";
+
+export type AppHeaderProps = ViewProps & {
+ title: string;
+ subtitle?: string;
+ /** Show the leading back button. Default true. */
+ showBack?: boolean;
+ onBack?: () => void;
+ backAccessibilityLabel?: string;
+ /** Show the trailing actions button. Default true. */
+ showActions?: boolean;
+ onActions?: () => void;
+ actionsAccessibilityLabel?: string;
+ /** Override the trailing actions icon (default MoreVertical). */
+ actionsIcon?: LucideIcon;
+ /** Adds a layered shadow for visual separation from scrolling content. */
+ isScrolled?: boolean;
+};
+
+/**
+ * Top navigation header on every chat view (UC-RENDER-01 §A, UC-SESS-04 §A).
+ *
+ * Per mol-app-header spec:
+ * - Three-region flex: leading back (optional) + centered title/subtitle + trailing actions (optional)
+ * - Subtitle (project · branch) appears below the title in --md type-meta
+ * - `isScrolled` adds a 1px bottom shadow for layered separation
+ *
+ * Composes first-party IconButton + Text.
+ */
+export function AppHeader({
+ title,
+ subtitle,
+ showBack = true,
+ onBack,
+ backAccessibilityLabel = "Back to sessions",
+ showActions = true,
+ onActions,
+ actionsAccessibilityLabel = "Session actions",
+ actionsIcon = MoreVertical,
+ isScrolled = false,
+ className,
+ ...props
+}: AppHeaderProps) {
+ return (
+
+ {showBack ? (
+
+ ) : (
+
+ )}
+
+
+
+ {title}
+
+ {subtitle ? (
+
+ {subtitle}
+
+ ) : null}
+
+
+ {showActions ? (
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/apps/mobile/components/AppHeader/index.ts b/apps/mobile/components/AppHeader/index.ts
new file mode 100644
index 00000000000..edfdab9cc64
--- /dev/null
+++ b/apps/mobile/components/AppHeader/index.ts
@@ -0,0 +1 @@
+export { AppHeader, type AppHeaderProps } from "./AppHeader";
diff --git a/apps/mobile/components/ApprovalFooter/ApprovalFooter.stories.tsx b/apps/mobile/components/ApprovalFooter/ApprovalFooter.stories.tsx
new file mode 100644
index 00000000000..392ebd4207b
--- /dev/null
+++ b/apps/mobile/components/ApprovalFooter/ApprovalFooter.stories.tsx
@@ -0,0 +1,83 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import { View } from "react-native";
+import { ApprovalFooter } from "./ApprovalFooter";
+
+const meta: Meta = {
+ title: "Molecules/ApprovalFooter",
+ component: ApprovalFooter,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Sticky footer above composer during a tool-approval pause (UC-PAUSE-01 §A). Top amber ToolStatusRule + optional queue Badge + 3 Buttons (Decline / Approve / Always). Order is intentional one-handed UX: Approve in center thumb zone, Decline outer to reduce accidental taps. `resolving` dims row + spinner on indicated button. Composes ToolStatusRule + Badge + Button.",
+ },
+ },
+ layout: "fullscreen",
+ },
+ args: {
+ queueCount: 1,
+ queueIndex: 1,
+ disabled: false,
+ },
+ argTypes: {
+ queueCount: {
+ control: { type: "number", min: 1, max: 9 },
+ description: "Total approvals queued (counter shows when > 1)",
+ },
+ queueIndex: {
+ control: { type: "number", min: 1, max: 9 },
+ description: "Current 1-indexed position in queue",
+ },
+ resolving: {
+ control: { type: "select" },
+ options: ["(none)", "decline", "approve", "always"],
+ mapping: {
+ "(none)": undefined,
+ decline: "decline",
+ approve: "approve",
+ always: "always",
+ },
+ description: "Show spinner on indicated button + dim row",
+ },
+ disabled: { control: "boolean" },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Single: Story = {};
+
+export const Queued: Story = {
+ args: { queueCount: 4, queueIndex: 1 },
+};
+
+export const ResolvingApprove: Story = {
+ args: { resolving: "approve" },
+};
+
+export const ResolvingDecline: Story = {
+ args: { resolving: "decline" },
+};
+
+export const ResolvingAlways: Story = {
+ args: { resolving: "always" },
+};
+
+export const Disabled: Story = {
+ args: { disabled: true },
+};
+
+export const InContextWithRule: Story = {
+ render: () => (
+
+
+
+ {/* PendingApprovalCard would mount here in real flow */}
+
+
+
+
+ ),
+};
diff --git a/apps/mobile/components/ApprovalFooter/ApprovalFooter.tsx b/apps/mobile/components/ApprovalFooter/ApprovalFooter.tsx
new file mode 100644
index 00000000000..f3265975d23
--- /dev/null
+++ b/apps/mobile/components/ApprovalFooter/ApprovalFooter.tsx
@@ -0,0 +1,119 @@
+import { ActivityIndicator, View, type ViewProps } from "react-native";
+import { ToolStatusRule } from "@/components/ToolStatusRule";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Text } from "@/components/ui/text";
+import { cn } from "@/lib/utils";
+
+export type ApprovalFooterResolvingAction = "decline" | "approve" | "always";
+
+export type ApprovalFooterProps = ViewProps & {
+ queueCount?: number;
+ queueIndex?: number;
+ onDecline?: () => void;
+ onApprove?: () => void;
+ onAlways?: () => void;
+ /** When set, dims the action row and shows a spinner on the indicated button. */
+ resolving?: ApprovalFooterResolvingAction;
+ /** Full-footer disabled (greyed out, no spinner). */
+ disabled?: boolean;
+};
+
+/**
+ * Sticky footer that docks above the composer during a tool-approval pause
+ * (UC-PAUSE-01 §A). Pairs with PendingApprovalCard in the message stream above.
+ *
+ * Per mol-approval-footer spec:
+ * - Top horizontal amber ToolStatusRule connects visually to the pending card
+ * - Optional queue counter Badge ("1 OF 4") visible when queueCount > 1
+ * - Action order: Decline · Approve · Always (intentional one-handed UX deviation
+ * from the wireframe — center is thumb-reachable for the most-common positive
+ * action, destructive is outer)
+ * - `resolving` dims the row + shows a spinner on the indicated button
+ *
+ * Composes ToolStatusRule + vendor Badge + vendor Button + ActivityIndicator.
+ */
+export function ApprovalFooter({
+ queueCount = 1,
+ queueIndex = 1,
+ onDecline,
+ onApprove,
+ onAlways,
+ resolving,
+ disabled,
+ className,
+ ...props
+}: ApprovalFooterProps) {
+ const showCounter = queueCount > 1;
+ const isResolving = resolving !== undefined;
+
+ return (
+
+
+
+ {showCounter ? (
+
+
+ {queueIndex} OF {queueCount}
+
+
+ ) : null}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/mobile/components/ApprovalFooter/index.ts b/apps/mobile/components/ApprovalFooter/index.ts
new file mode 100644
index 00000000000..62b16fca3fb
--- /dev/null
+++ b/apps/mobile/components/ApprovalFooter/index.ts
@@ -0,0 +1,5 @@
+export {
+ ApprovalFooter,
+ type ApprovalFooterProps,
+ type ApprovalFooterResolvingAction,
+} from "./ApprovalFooter";
diff --git a/apps/mobile/components/AssistantMessageHead/AssistantMessageHead.stories.tsx b/apps/mobile/components/AssistantMessageHead/AssistantMessageHead.stories.tsx
new file mode 100644
index 00000000000..3f4e696165e
--- /dev/null
+++ b/apps/mobile/components/AssistantMessageHead/AssistantMessageHead.stories.tsx
@@ -0,0 +1,72 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import { View } from "react-native";
+import {
+ AssistantMessageHead,
+ type AssistantMessageHeadVariant,
+} from "./AssistantMessageHead";
+
+const VARIANTS: AssistantMessageHeadVariant[] = [
+ "idle",
+ "streaming",
+ "thinking",
+ "paused",
+ "completed",
+];
+
+const meta: Meta = {
+ title: "Molecules/AssistantMessageHead",
+ component: AssistantMessageHead,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Header row for an assistant message (UC-RENDER-01). Avatar + ASSISTANT label + · + timestamp + optional status segment. 5 variants drive status visibility: idle (none) · streaming · thinking · paused · completed. Composes vendor Avatar + first-party StatusDot + Text.",
+ },
+ },
+ layout: "centered",
+ },
+ args: {
+ initials: "A",
+ label: "ASSISTANT",
+ timestamp: "12:43 PM",
+ variant: "idle",
+ },
+ argTypes: {
+ initials: { control: "text" },
+ label: { control: "text" },
+ timestamp: { control: "text" },
+ variant: { control: { type: "select" }, options: VARIANTS },
+ completedDuration: { control: "text" },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Idle: Story = {};
+
+export const Streaming: Story = { args: { variant: "streaming" } };
+
+export const Thinking: Story = { args: { variant: "thinking" } };
+
+export const Paused: Story = { args: { variant: "paused" } };
+
+export const Completed: Story = {
+ args: { variant: "completed", completedDuration: "3.2s" },
+};
+
+export const AllVariants: Story = {
+ render: () => (
+
+ {VARIANTS.map((v) => (
+
+ ))}
+
+ ),
+};
diff --git a/apps/mobile/components/AssistantMessageHead/AssistantMessageHead.tsx b/apps/mobile/components/AssistantMessageHead/AssistantMessageHead.tsx
new file mode 100644
index 00000000000..4c83aee7807
--- /dev/null
+++ b/apps/mobile/components/AssistantMessageHead/AssistantMessageHead.tsx
@@ -0,0 +1,106 @@
+import { View, type ViewProps } from "react-native";
+import { StatusDot } from "@/components/StatusDot";
+import { Avatar, AvatarFallback } from "@/components/ui/avatar";
+import { Text } from "@/components/ui/text";
+import { cn } from "@/lib/utils";
+
+export type AssistantMessageHeadVariant =
+ | "idle"
+ | "streaming"
+ | "thinking"
+ | "paused"
+ | "completed";
+
+type VariantConfig = {
+ dotVariant?: "live" | "warning" | "success" | "neutral";
+ statusText?: string;
+ dotOpacity?: number;
+};
+
+const VARIANT: Record = {
+ idle: {},
+ streaming: { dotVariant: "live", statusText: "STREAMING" },
+ thinking: { dotVariant: "warning", statusText: "THINKING" },
+ paused: { dotVariant: "warning", statusText: "PAUSED" },
+ completed: { dotVariant: "success", statusText: "COMPLETED" },
+};
+
+export type AssistantMessageHeadProps = ViewProps & {
+ /** Initial(s) shown in the avatar fallback. Default "A". */
+ initials?: string;
+ /** Label text. Defaults to "ASSISTANT". */
+ label?: string;
+ timestamp: string;
+ variant?: AssistantMessageHeadVariant;
+ /** Extra trailing text for completed (e.g. "· 3.2s"). */
+ completedDuration?: string;
+};
+
+/**
+ * Header row for an assistant message (UC-RENDER-01).
+ *
+ * Per mol-assistant-message-head spec:
+ * - Avatar (sm accent) + ASSISTANT label + · + timestamp + optional status segment
+ * - 5 variants drive the status segment visibility + content
+ * - Non-interactive — body organism handles long-press
+ *
+ * Composes vendor Avatar + first-party StatusDot + Text.
+ */
+export function AssistantMessageHead({
+ initials = "A",
+ label = "ASSISTANT",
+ timestamp,
+ variant = "idle",
+ completedDuration,
+ className,
+ ...props
+}: AssistantMessageHeadProps) {
+ const cfg = VARIANT[variant];
+ const showStatus = variant !== "idle" && cfg.statusText;
+
+ return (
+
+
+
+
+ {initials}
+
+
+
+
+
+ {label}
+
+ ·
+
+ {timestamp}
+
+
+ {showStatus ? (
+
+ ·
+ {cfg.dotVariant ? (
+
+ ) : null}
+
+ {cfg.statusText}
+ {variant === "completed" && completedDuration
+ ? ` · ${completedDuration}`
+ : ""}
+
+
+ ) : null}
+
+ );
+}
diff --git a/apps/mobile/components/AssistantMessageHead/index.ts b/apps/mobile/components/AssistantMessageHead/index.ts
new file mode 100644
index 00000000000..5f3e0916fa5
--- /dev/null
+++ b/apps/mobile/components/AssistantMessageHead/index.ts
@@ -0,0 +1,5 @@
+export {
+ AssistantMessageHead,
+ type AssistantMessageHeadProps,
+ type AssistantMessageHeadVariant,
+} from "./AssistantMessageHead";
diff --git a/apps/mobile/components/Banner/Banner.stories.tsx b/apps/mobile/components/Banner/Banner.stories.tsx
new file mode 100644
index 00000000000..69b68a2683f
--- /dev/null
+++ b/apps/mobile/components/Banner/Banner.stories.tsx
@@ -0,0 +1,113 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import { View } from "react-native";
+import { Button } from "@/components/ui/button";
+import { Text } from "@/components/ui/text";
+import { Banner } from "./Banner";
+
+const meta: Meta = {
+ title: "Molecules/Banner",
+ component: Banner,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Full-width status banner above chat content. 4 variants (offline · unpaid · dispatch-failed · permission-denied) × 2 shapes (inline · stacked). Top horizontal ToolStatusRule accent in variant color. Composes ToolStatusRule + Icon + Text + IconButton.",
+ },
+ },
+ layout: "fullscreen",
+ },
+ args: {
+ variant: "offline",
+ shape: "inline",
+ headline: "Host offline · auto-retrying in 3s",
+ },
+ argTypes: {
+ variant: {
+ control: { type: "select" },
+ options: ["offline", "unpaid", "dispatch-failed", "permission-denied"],
+ },
+ shape: {
+ control: { type: "select" },
+ options: ["inline", "stacked"],
+ },
+ headline: { control: "text" },
+ body: { control: "text", description: "Body text — stacked shape only" },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Offline: Story = {};
+
+export const Unpaid: Story = {
+ args: {
+ variant: "unpaid",
+ headline: "Workspace plan upgrade required",
+ },
+ render: (args) => (
+
+ Upgrade
+
+ }
+ />
+ ),
+};
+
+export const DispatchFailed: Story = {
+ args: {
+ variant: "dispatch-failed",
+ headline: "Failed to dispatch — host unreachable",
+ },
+ render: (args) => (
+
+ Retry
+
+ }
+ />
+ ),
+};
+
+export const PermissionDeniedStacked: Story = {
+ args: {
+ variant: "permission-denied",
+ shape: "stacked",
+ headline: "Notifications disabled",
+ body: "Enable notifications in iOS Settings to receive pause approvals while the app is in the background.",
+ },
+ render: (args) => (
+
+ Open Settings →
+
+ }
+ />
+ ),
+};
+
+export const Dismissible: Story = {
+ args: {
+ headline: "Host offline · auto-retrying",
+ onDismiss: () => {},
+ },
+};
+
+export const AllInlineVariants: Story = {
+ render: () => (
+
+
+
+
+
+
+ ),
+};
diff --git a/apps/mobile/components/Banner/Banner.tsx b/apps/mobile/components/Banner/Banner.tsx
new file mode 100644
index 00000000000..48735505752
--- /dev/null
+++ b/apps/mobile/components/Banner/Banner.tsx
@@ -0,0 +1,139 @@
+import {
+ AlertTriangle,
+ Bell,
+ type LucideIcon,
+ WifiOff,
+ X,
+} from "lucide-react-native";
+import type { ReactNode } from "react";
+import { View, type ViewProps } from "react-native";
+import { IconButton } from "@/components/IconButton";
+import { ToolStatusRule } from "@/components/ToolStatusRule";
+import { Icon } from "@/components/ui/icon";
+import { Text } from "@/components/ui/text";
+import { cn } from "@/lib/utils";
+
+export type BannerVariant =
+ | "offline"
+ | "unpaid"
+ | "dispatch-failed"
+ | "permission-denied";
+
+type VariantConfig = {
+ bgClass: string;
+ textClass: string;
+ ruleVariant: "pending" | "error";
+ defaultIcon: LucideIcon;
+};
+
+const VARIANT: Record = {
+ offline: {
+ bgClass: "bg-state-warning-bg",
+ textClass: "text-state-warning-fg",
+ ruleVariant: "pending",
+ defaultIcon: WifiOff,
+ },
+ unpaid: {
+ bgClass: "bg-state-danger-bg",
+ textClass: "text-state-danger-fg",
+ ruleVariant: "error",
+ defaultIcon: AlertTriangle,
+ },
+ "dispatch-failed": {
+ bgClass: "bg-state-danger-bg",
+ textClass: "text-state-danger-fg",
+ ruleVariant: "error",
+ defaultIcon: AlertTriangle,
+ },
+ "permission-denied": {
+ bgClass: "bg-state-warning-bg",
+ textClass: "text-state-warning-fg",
+ ruleVariant: "pending",
+ defaultIcon: Bell,
+ },
+};
+
+export type BannerProps = ViewProps & {
+ variant?: BannerVariant;
+ shape?: "inline" | "stacked";
+ headline: string;
+ body?: string;
+ icon?: LucideIcon;
+ cta?: ReactNode;
+ onDismiss?: () => void;
+ dismissAccessibilityLabel?: string;
+};
+
+/**
+ * Full-width status banner above chat (UC-PLATF-01 + UC-PLATF-03).
+ *
+ * Per mol-banner spec:
+ * - 4 variants: offline · unpaid · dispatch-failed · permission-denied
+ * - 2 shapes: inline (icon · headline · CTA in one row) · stacked
+ * (icon+headline row, then body, then CTA below)
+ * - Top horizontal ToolStatusRule accent in variant color
+ * - Optional dismiss IconButton via onDismiss
+ *
+ * Composes ToolStatusRule + Icon + Text + IconButton.
+ */
+export function Banner({
+ variant = "offline",
+ shape = "inline",
+ headline,
+ body,
+ icon,
+ cta,
+ onDismiss,
+ dismissAccessibilityLabel = "Dismiss",
+ className,
+ ...props
+}: BannerProps) {
+ const cfg = VARIANT[variant];
+ const resolvedIcon = icon ?? cfg.defaultIcon;
+ const isStacked = shape === "stacked";
+
+ return (
+
+
+
+
+
+
+
+ {headline}
+
+
+ {!isStacked && cta ? {cta} : null}
+ {onDismiss ? (
+
+ ) : null}
+
+ {isStacked && body ? (
+
+ {body}
+
+ ) : null}
+ {isStacked && cta ? {cta} : null}
+
+
+ );
+}
diff --git a/apps/mobile/components/Banner/index.ts b/apps/mobile/components/Banner/index.ts
new file mode 100644
index 00000000000..e862ece0074
--- /dev/null
+++ b/apps/mobile/components/Banner/index.ts
@@ -0,0 +1 @@
+export { Banner, type BannerProps, type BannerVariant } from "./Banner";
diff --git a/apps/mobile/components/CodeBlock/CodeBlock.stories.tsx b/apps/mobile/components/CodeBlock/CodeBlock.stories.tsx
new file mode 100644
index 00000000000..ecffde44d0a
--- /dev/null
+++ b/apps/mobile/components/CodeBlock/CodeBlock.stories.tsx
@@ -0,0 +1,67 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import { CodeBlock } from "./CodeBlock";
+
+const SAMPLE_TS = `export const billing = router({
+ getInvoice: publicProcedure
+ .input(z.string())
+ .query(({ input }) => db.invoice.find(input)),
+});`;
+
+const SAMPLE_BASH = `bun install
+bun run typecheck
+bun run lint:fix`;
+
+const SAMPLE_LONG = Array.from({ length: 40 })
+ .map((_, i) => `console.log("line ${i + 1} of the long code block");`)
+ .join("\n");
+
+const meta: Meta = {
+ title: "Molecules/CodeBlock",
+ component: CodeBlock,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Fenced code block for assistant messages. Language label (mono uppercase) + Copy IconButton + Separator + monospace body. Copy shows check icon + 'Copied' for 1500ms. Composes IconButton + Separator + Text.",
+ },
+ },
+ layout: "fullscreen",
+ },
+ args: {
+ code: SAMPLE_TS,
+ language: "typescript",
+ overflow: false,
+ bare: false,
+ },
+ argTypes: {
+ code: { control: "text" },
+ language: { control: "text" },
+ overflow: {
+ control: "boolean",
+ description: "Internal scroll for long code",
+ },
+ bare: { control: "boolean", description: "No border (only sunken bg)" },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Typescript: Story = {};
+
+export const Bash: Story = {
+ args: { code: SAMPLE_BASH, language: "bash" },
+};
+
+export const NoLanguage: Story = {
+ args: { code: SAMPLE_BASH, language: undefined },
+};
+
+export const Overflow: Story = {
+ args: { code: SAMPLE_LONG, overflow: true, language: "javascript" },
+};
+
+export const Bare: Story = {
+ args: { bare: true },
+};
diff --git a/apps/mobile/components/CodeBlock/CodeBlock.tsx b/apps/mobile/components/CodeBlock/CodeBlock.tsx
new file mode 100644
index 00000000000..163bcf6db11
--- /dev/null
+++ b/apps/mobile/components/CodeBlock/CodeBlock.tsx
@@ -0,0 +1,88 @@
+import { Check, Copy } from "lucide-react-native";
+import { useState } from "react";
+import { ScrollView, View, type ViewProps } from "react-native";
+import { IconButton } from "@/components/IconButton";
+import { Separator } from "@/components/ui/separator";
+import { Text } from "@/components/ui/text";
+import { cn } from "@/lib/utils";
+
+export type CodeBlockProps = ViewProps & {
+ code: string;
+ language?: string;
+ /** Called when user taps the Copy button. Caller handles clipboard write. */
+ onCopy?: (code: string) => void;
+ /** When true, body is internally scrollable (>320pt content). */
+ overflow?: boolean;
+ /** No border variant (only sunken bg). */
+ bare?: boolean;
+};
+
+/**
+ * Fenced code block for assistant message stream (UC-RENDER-03 §A).
+ *
+ * Per mol-code-block spec:
+ * - header: language label (mono uppercase muted) + Copy IconButton
+ * - hairline divider via Separator
+ * - body: monospace text, optional internal scroll when overflow=true
+ * - Copy button briefly shows check icon + "Copied" for 1500ms
+ *
+ * Composes first-party IconButton + vendor Separator + Text.
+ */
+export function CodeBlock({
+ code,
+ language,
+ onCopy,
+ overflow,
+ bare,
+ className,
+ ...props
+}: CodeBlockProps) {
+ const [copied, setCopied] = useState(false);
+
+ const handleCopy = () => {
+ onCopy?.(code);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1500);
+ };
+
+ return (
+
+
+ {language ? (
+
+ {language}
+
+ ) : (
+
+ )}
+
+
+
+ {overflow ? (
+
+
+ {code}
+
+
+ ) : (
+
+ {code}
+
+ )}
+
+ );
+}
diff --git a/apps/mobile/components/CodeBlock/index.ts b/apps/mobile/components/CodeBlock/index.ts
new file mode 100644
index 00000000000..a7b0c9f0e1a
--- /dev/null
+++ b/apps/mobile/components/CodeBlock/index.ts
@@ -0,0 +1 @@
+export { CodeBlock, type CodeBlockProps } from "./CodeBlock";
diff --git a/apps/mobile/components/CollapsedBlock/CollapsedBlock.stories.tsx b/apps/mobile/components/CollapsedBlock/CollapsedBlock.stories.tsx
new file mode 100644
index 00000000000..259c8b251be
--- /dev/null
+++ b/apps/mobile/components/CollapsedBlock/CollapsedBlock.stories.tsx
@@ -0,0 +1,115 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import { View } from "react-native";
+import { Text } from "@/components/ui/text";
+import { CollapsedBlock } from "./CollapsedBlock";
+
+const PLAN_STEPS = [
+ "1. Locate the reconnect backoff constant",
+ "2. Replace 250ms with exponential backoff",
+ "3. Preserve inner try/catch",
+ "4. Add unit tests for the backoff schedule",
+];
+
+const REASONING_TEXT =
+ "The user is reporting reconnect storms on Wi-Fi flap. The backoff is hardcoded to 250ms which is too aggressive — I should adjust it to be exponential starting at 500ms.";
+
+const meta: Meta = {
+ title: "Molecules/CollapsedBlock",
+ component: CollapsedBlock,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Collapsible block wrapping agent-generated structured content (UC-RENDER-05/06). 3 kinds: plan (sparkles + accent) · reasoning (brain + muted) · subagent (bot + muted, indented with left rule). Tap summary to toggle. Composes vendor Collapsible + Separator + Icon + Text.",
+ },
+ },
+ layout: "fullscreen",
+ },
+ args: {
+ kind: "plan",
+ meta: "12 steps · 1m est",
+ defaultOpen: false,
+ },
+ argTypes: {
+ kind: {
+ control: { type: "select" },
+ options: ["plan", "reasoning", "subagent"],
+ },
+ meta: { control: "text", description: "Optional meta text after label" },
+ defaultOpen: { control: "boolean" },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const PlanCollapsed: Story = {
+ render: (args) => (
+
+
+ {PLAN_STEPS.map((s) => (
+
+ {s}
+
+ ))}
+
+
+ ),
+};
+
+export const PlanExpanded: Story = {
+ args: { defaultOpen: true },
+ render: (args) => (
+
+
+ {PLAN_STEPS.map((s) => (
+
+ {s}
+
+ ))}
+
+
+ ),
+};
+
+export const Reasoning: Story = {
+ args: { kind: "reasoning", meta: "2.4s", defaultOpen: true },
+ render: (args) => (
+
+
+ {REASONING_TEXT}
+
+
+ ),
+};
+
+export const Subagent: Story = {
+ args: {
+ kind: "subagent",
+ meta: "code-reviewer · 12 tool calls",
+ defaultOpen: true,
+ },
+ render: (args) => (
+
+
+
+ Sub-agent invoked code-reviewer with the staged diff.
+
+
+ Verdict: 0 blocking findings, 2 nit-level suggestions.
+
+
+
+ ),
+};
+
+export const AllKinds: Story = {
+ render: () => (
+
+
+
+
+
+ ),
+};
diff --git a/apps/mobile/components/CollapsedBlock/CollapsedBlock.tsx b/apps/mobile/components/CollapsedBlock/CollapsedBlock.tsx
new file mode 100644
index 00000000000..2d21db662d5
--- /dev/null
+++ b/apps/mobile/components/CollapsedBlock/CollapsedBlock.tsx
@@ -0,0 +1,112 @@
+import {
+ Bot,
+ Brain,
+ ChevronDown,
+ type LucideIcon,
+ Sparkles,
+} from "lucide-react-native";
+import { type ReactNode, useState } from "react";
+import { Pressable, View, type ViewProps } from "react-native";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible";
+import { Icon } from "@/components/ui/icon";
+import { Separator } from "@/components/ui/separator";
+import { Text } from "@/components/ui/text";
+import { cn } from "@/lib/utils";
+
+export type CollapsedBlockKind = "plan" | "reasoning" | "subagent";
+
+type KindConfig = {
+ icon: LucideIcon;
+ label: string;
+ iconColorClass: string;
+ indentClass?: string;
+};
+
+const KIND: Record = {
+ plan: { icon: Sparkles, label: "PLAN", iconColorClass: "text-primary" },
+ reasoning: {
+ icon: Brain,
+ label: "REASONING",
+ iconColorClass: "text-muted-foreground",
+ },
+ subagent: {
+ icon: Bot,
+ label: "SUBAGENT",
+ iconColorClass: "text-muted-foreground",
+ indentClass: "ml-6 border-l border-muted-foreground/40 pl-3",
+ },
+};
+
+export type CollapsedBlockProps = ViewProps & {
+ kind?: CollapsedBlockKind;
+ meta?: string;
+ defaultOpen?: boolean;
+ children?: ReactNode;
+};
+
+/**
+ * Collapsible block wrapping agent-generated structured content (UC-RENDER-05/06).
+ *
+ * Per mol-collapsed-block spec, 3 kinds:
+ * - plan — sparkles + PLAN + accent icon, agent's proposed step list
+ * - reasoning — brain + REASONING + muted icon, extended thinking trace
+ * - subagent — bot + SUBAGENT + muted, indented with left accent rule
+ *
+ * Tap on the summary toggles expand/collapse via vendor Collapsible primitives.
+ * Chevron rotates 180° when open.
+ *
+ * Composes vendor Collapsible + Separator + Icon + Text.
+ */
+export function CollapsedBlock({
+ kind = "plan",
+ meta,
+ defaultOpen = false,
+ children,
+ className,
+ ...props
+}: CollapsedBlockProps) {
+ const cfg = KIND[kind];
+ const [open, setOpen] = useState(defaultOpen);
+
+ return (
+
+
+
+
+
+
+ {cfg.label}
+
+ {meta ? (
+
+ · {meta}
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+ );
+}
diff --git a/apps/mobile/components/CollapsedBlock/index.ts b/apps/mobile/components/CollapsedBlock/index.ts
new file mode 100644
index 00000000000..4712707afd6
--- /dev/null
+++ b/apps/mobile/components/CollapsedBlock/index.ts
@@ -0,0 +1,5 @@
+export {
+ CollapsedBlock,
+ type CollapsedBlockKind,
+ type CollapsedBlockProps,
+} from "./CollapsedBlock";
diff --git a/apps/mobile/components/ComposerRow/ComposerRow.stories.tsx b/apps/mobile/components/ComposerRow/ComposerRow.stories.tsx
new file mode 100644
index 00000000000..1fa655ce67e
--- /dev/null
+++ b/apps/mobile/components/ComposerRow/ComposerRow.stories.tsx
@@ -0,0 +1,121 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import { View } from "react-native";
+import { ComposerRow, type ComposerRowVariant } from "./ComposerRow";
+
+const VARIANTS: ComposerRowVariant[] = [
+ "idle",
+ "typing",
+ "streaming",
+ "sending",
+];
+
+const meta: Meta = {
+ title: "Molecules/ComposerRow",
+ component: ComposerRow,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Composer cluster — single rounded container with Textarea on top and an action toolbar inside the same chrome below. Toolbar order mirrors the Claude iOS reference: LEFT [+ commands] [⚙ settings pill] · spacer · RIGHT [send / stop / dots]. 4 state variants drive the right-slot swap and editability. Composes vendor Textarea + first-party IconButton + ComposerSettingsButton + ProgressDots.",
+ },
+ },
+ layout: "fullscreen",
+ },
+ args: {
+ variant: "idle",
+ placeholder: "Type a message…",
+ settings: {
+ modelName: "Sonnet 4.6",
+ permissionMode: "default",
+ thinkingLevel: "off",
+ },
+ onCommandsPress: () => {},
+ },
+ argTypes: {
+ variant: {
+ control: { type: "select" },
+ options: VARIANTS,
+ description:
+ "idle (Send disabled) · typing (Send active) · streaming (Stop) · sending (ProgressDots)",
+ },
+ value: {
+ control: "text",
+ description: "Controlled textarea value",
+ },
+ placeholder: { control: "text" },
+ commandsAccessibilityLabel: { control: "text" },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Idle: Story = {};
+
+export const Typing: Story = {
+ args: {
+ variant: "typing",
+ value: "Refactor the relay tunnel reconnect loop",
+ },
+};
+
+export const Streaming: Story = {
+ args: { variant: "streaming" },
+};
+
+export const Sending: Story = {
+ args: { variant: "sending", value: "Sending this message…" },
+};
+
+export const ThinkingOnPermissionAcceptEdits: Story = {
+ args: {
+ variant: "typing",
+ value: "About to refactor",
+ settings: {
+ modelName: "Opus 4.7",
+ permissionMode: "acceptEdits",
+ thinkingLevel: "medium",
+ },
+ },
+};
+
+export const NoCommandsButton: Story = {
+ args: { onCommandsPress: undefined },
+};
+
+export const NoSettingsButton: Story = {
+ args: { settings: undefined },
+};
+
+export const Minimal: Story = {
+ args: { settings: undefined, onCommandsPress: undefined },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "Composer with no leading toolbar buttons — just textarea + send. Useful for embedded contexts where settings/commands live elsewhere.",
+ },
+ },
+ },
+};
+
+export const AllStates: Story = {
+ render: () => (
+
+ {VARIANTS.map((v) => (
+ {}}
+ />
+ ))}
+
+ ),
+};
diff --git a/apps/mobile/components/ComposerRow/ComposerRow.tsx b/apps/mobile/components/ComposerRow/ComposerRow.tsx
new file mode 100644
index 00000000000..b9a9501fa0f
--- /dev/null
+++ b/apps/mobile/components/ComposerRow/ComposerRow.tsx
@@ -0,0 +1,146 @@
+import { Send, Slash, Square } from "lucide-react-native";
+import { View, type ViewProps } from "react-native";
+import {
+ ComposerSettingsButton,
+ type PermissionMode,
+ type ThinkingLevel,
+} from "@/components/ComposerSettingsButton";
+import { IconButton } from "@/components/IconButton";
+import { ProgressDots } from "@/components/ProgressDots";
+import { Textarea } from "@/components/ui/textarea";
+import { cn } from "@/lib/utils";
+
+export type ComposerRowVariant = "idle" | "typing" | "streaming" | "sending";
+
+export type ComposerRowProps = ViewProps & {
+ variant?: ComposerRowVariant;
+ value?: string;
+ onChangeText?: (text: string) => void;
+ onSend?: () => void;
+ onStop?: () => void;
+ placeholder?: string;
+ /** Composer settings (model + permission + thinking) — pass undefined to hide the settings button. */
+ settings?: {
+ modelName: string;
+ permissionMode?: PermissionMode;
+ thinkingLevel?: ThinkingLevel;
+ isOpen?: boolean;
+ onPress?: () => void;
+ };
+ /** Tap handler for the leading commands (+) button. When omitted, the button is hidden. */
+ onCommandsPress?: () => void;
+ commandsAccessibilityLabel?: string;
+};
+
+/**
+ * Composer cluster — Claude iOS layout. Single rounded container with the
+ * textarea on top and an action toolbar inside the same chrome below.
+ *
+ * Toolbar order (mirrors Claude iOS reference):
+ * - LEFT: [+] commands button → [Shield/Model/Brain] settings pill
+ * - RIGHT: send / stop / progress-dots (state-driven swap)
+ *
+ * Variants (textarea + right slot):
+ * - idle — empty input, Send disabled
+ * - typing — populated input, Send active (primary ember)
+ * - streaming — input non-editable, Stop replaces Send (destructive)
+ * - sending — input non-editable, ProgressDots replaces Send in 44pt slot
+ *
+ * Composes vendor Textarea + first-party IconButton + ComposerSettingsButton +
+ * ProgressDots. The slash-command popover the `+` button opens is the host's
+ * concern (organism, deferred to Wave 3).
+ */
+export function ComposerRow({
+ variant = "idle",
+ value,
+ onChangeText,
+ onSend,
+ onStop,
+ placeholder = "Type a message…",
+ settings,
+ onCommandsPress,
+ commandsAccessibilityLabel = "Open commands",
+ className,
+ ...props
+}: ComposerRowProps) {
+ const isDisabled = variant === "streaming" || variant === "sending";
+
+ return (
+
+
+
+
+
+ {onCommandsPress ? (
+
+ ) : null}
+
+ {settings ? (
+
+ ) : null}
+
+
+
+ {variant === "sending" ? (
+
+
+
+ ) : variant === "streaming" ? (
+
+ ) : (
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/mobile/components/ComposerRow/index.ts b/apps/mobile/components/ComposerRow/index.ts
new file mode 100644
index 00000000000..e894b6c15c7
--- /dev/null
+++ b/apps/mobile/components/ComposerRow/index.ts
@@ -0,0 +1,5 @@
+export {
+ ComposerRow,
+ type ComposerRowProps,
+ type ComposerRowVariant,
+} from "./ComposerRow";
diff --git a/apps/mobile/components/ComposerSettingsButton/ComposerSettingsButton.stories.tsx b/apps/mobile/components/ComposerSettingsButton/ComposerSettingsButton.stories.tsx
new file mode 100644
index 00000000000..35219bbd0b1
--- /dev/null
+++ b/apps/mobile/components/ComposerSettingsButton/ComposerSettingsButton.stories.tsx
@@ -0,0 +1,121 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import { View } from "react-native";
+import {
+ ComposerSettingsButton,
+ type PermissionMode,
+ type ThinkingLevel,
+} from "./ComposerSettingsButton";
+
+const PERMISSION_MODES: PermissionMode[] = [
+ "default",
+ "acceptEdits",
+ "plan",
+ "bypassPermissions",
+];
+const THINKING_LEVELS: ThinkingLevel[] = [
+ "off",
+ "low",
+ "medium",
+ "high",
+ "xhigh",
+];
+
+const meta: Meta = {
+ title: "Molecules/ComposerSettingsButton",
+ component: ComposerSettingsButton,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Single trigger pill on the composer that opens the composer-settings bottom sheet. Mirrors desktop `ComposerSettingsMenu` (PR #4866 / SUPER-755) — consolidates legacy 3 sibling pills (Model · Permission · Thinking) into one tap surface. Anatomy: [Shield(perm-variant)] [ModelName(truncate)] [Brain(status)]. State via semantic color, never opacity. The bottom-sheet itself is an organism deferred to Wave 3.",
+ },
+ },
+ layout: "centered",
+ },
+ args: {
+ modelName: "Sonnet 4.6",
+ permissionMode: "default",
+ thinkingLevel: "off",
+ isOpen: false,
+ disabled: false,
+ },
+ argTypes: {
+ modelName: {
+ control: "text",
+ description: "Currently selected model name — truncates at ~180pt",
+ },
+ permissionMode: {
+ control: { type: "select" },
+ options: PERMISSION_MODES,
+ description: "Drives the Shield icon variant",
+ },
+ thinkingLevel: {
+ control: { type: "select" },
+ options: THINKING_LEVELS,
+ description:
+ "Off → brain icon muted color; any non-off level → foreground color",
+ },
+ isOpen: {
+ control: "boolean",
+ description:
+ "When sheet is open: bg→accent + accessibilityState.expanded",
+ },
+ disabled: { control: "boolean" },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const ThinkingOn: Story = {
+ args: { thinkingLevel: "medium" },
+};
+
+export const AcceptEditsPermission: Story = {
+ args: { permissionMode: "acceptEdits" },
+};
+
+export const BypassPermissions: Story = {
+ args: { permissionMode: "bypassPermissions", thinkingLevel: "high" },
+};
+
+export const LongModelName: Story = {
+ args: { modelName: "Anthropic Claude Opus 4.7 Extended" },
+};
+
+export const Open: Story = {
+ args: { isOpen: true },
+};
+
+export const AllPermissionStates: Story = {
+ render: () => (
+
+ {PERMISSION_MODES.map((pm) => (
+
+ ))}
+
+ ),
+};
+
+export const AllThinkingStates: Story = {
+ render: () => (
+
+ {THINKING_LEVELS.map((tl) => (
+
+ ))}
+
+ ),
+};
diff --git a/apps/mobile/components/ComposerSettingsButton/ComposerSettingsButton.tsx b/apps/mobile/components/ComposerSettingsButton/ComposerSettingsButton.tsx
new file mode 100644
index 00000000000..1e683c8be51
--- /dev/null
+++ b/apps/mobile/components/ComposerSettingsButton/ComposerSettingsButton.tsx
@@ -0,0 +1,109 @@
+import {
+ Brain,
+ type LucideIcon,
+ Shield,
+ ShieldCheck,
+ ShieldOff,
+} from "lucide-react-native";
+import { Pressable, type PressableProps } from "react-native";
+import { Icon } from "@/components/ui/icon";
+import { Text } from "@/components/ui/text";
+import { cn } from "@/lib/utils";
+
+export type PermissionMode =
+ | "default"
+ | "acceptEdits"
+ | "plan"
+ | "bypassPermissions";
+
+export type ThinkingLevel = "off" | "low" | "medium" | "high" | "xhigh";
+
+const PERMISSION_ICON: Record = {
+ default: Shield,
+ acceptEdits: ShieldCheck,
+ plan: Shield,
+ bypassPermissions: ShieldOff,
+};
+
+export type ComposerSettingsButtonProps = PressableProps & {
+ /** Current model display name — truncated past ~180pt. */
+ modelName: string;
+ permissionMode?: PermissionMode;
+ thinkingLevel?: ThinkingLevel;
+ /** Open state — accessibility hint, doesn't change visual chrome itself. */
+ isOpen?: boolean;
+};
+
+/**
+ * Single trigger pill that opens the composer settings bottom-sheet on mobile.
+ * Mirrors desktop ComposerSettingsMenu (SUPER-755 / PR #4866) — consolidates
+ * the legacy 3 sibling pills (Model · Permission · Thinking) into one tap
+ * surface that surfaces all 3 indicators at a glance.
+ *
+ * Trigger anatomy: [Shield (perm variant)] [ModelName (truncate)] [Brain (status)]
+ *
+ * State via semantic color, NEVER opacity (per binding scope amendment §1):
+ * - BrainIcon → text-muted-foreground (thinking off) / text-foreground (any on level)
+ * - ShieldIcon → always text-foreground (permission is never "off")
+ * - Trigger always uses pressable cursor; never visually disabled
+ *
+ * Caller wires onPress to open a bottom sheet that contains:
+ * - Model section (radio rows)
+ * - Permission section (4 modes)
+ * - Thinking section (5 levels)
+ *
+ * The bottom sheet itself is an organism-level molecule deferred to Wave 3
+ * (needs `@gorhom/bottom-sheet` or similar sheet primitive).
+ *
+ * Composes vendor Icon + Text; otherwise pure Pressable.
+ */
+export function ComposerSettingsButton({
+ modelName,
+ permissionMode = "default",
+ thinkingLevel = "off",
+ isOpen,
+ disabled,
+ className,
+ accessibilityLabel,
+ ...props
+}: ComposerSettingsButtonProps) {
+ const PermissionIcon = PERMISSION_ICON[permissionMode];
+ const isThinkingOn = thinkingLevel !== "off";
+ const composedAccessibilityLabel =
+ accessibilityLabel ??
+ `Composer settings — Model: ${modelName} · Permission: ${permissionMode} · Thinking: ${thinkingLevel}`;
+
+ return (
+
+
+
+ {modelName}
+
+
+
+ );
+}
diff --git a/apps/mobile/components/ComposerSettingsButton/index.ts b/apps/mobile/components/ComposerSettingsButton/index.ts
new file mode 100644
index 00000000000..ffe7962a876
--- /dev/null
+++ b/apps/mobile/components/ComposerSettingsButton/index.ts
@@ -0,0 +1,6 @@
+export {
+ ComposerSettingsButton,
+ type ComposerSettingsButtonProps,
+ type PermissionMode,
+ type ThinkingLevel,
+} from "./ComposerSettingsButton";
diff --git a/apps/mobile/components/FabBase/FabBase.stories.tsx b/apps/mobile/components/FabBase/FabBase.stories.tsx
new file mode 100644
index 00000000000..05a29be72e4
--- /dev/null
+++ b/apps/mobile/components/FabBase/FabBase.stories.tsx
@@ -0,0 +1,154 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import {
+ ArrowDown,
+ type LucideIcon,
+ MessageSquarePlus,
+ Plus,
+} from "lucide-react-native";
+import { View } from "react-native";
+import { FabBase } from "./FabBase";
+
+const ICON_MAP: Record = {
+ Plus,
+ ArrowDown,
+ MessageSquarePlus,
+};
+
+const meta: Meta = {
+ title: "Components/FabBase",
+ component: FabBase,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Floating action button base — sessions-list +, scroll-back-button, extended pill FAB. Three variants (accent · neutral · overlay) × two sizes (md=56pt · lg=64pt). Optional `label` enables extended pill; optional `liveRing` adds a pulsing mint halo. Always carries elevation shadow; aria-label is required.",
+ },
+ },
+ layout: "centered",
+ },
+ args: {
+ icon: Plus,
+ accessibilityLabel: "New chat session",
+ variant: "accent",
+ size: "md",
+ loading: false,
+ liveRing: false,
+ disabled: false,
+ },
+ argTypes: {
+ icon: {
+ control: { type: "select" },
+ options: Object.keys(ICON_MAP),
+ mapping: ICON_MAP,
+ description: "Lucide icon component (centered when no label)",
+ },
+ accessibilityLabel: {
+ control: "text",
+ description: "Required — action description, e.g. 'New chat session'",
+ },
+ label: {
+ control: "text",
+ description: "Optional visible label — enables extended pill variant",
+ },
+ variant: {
+ control: { type: "select" },
+ options: ["accent", "neutral", "overlay"],
+ },
+ size: {
+ control: { type: "select" },
+ options: ["md", "lg"],
+ description: "md=56pt diameter (icon 24) · lg=64pt diameter (icon 28)",
+ },
+ loading: {
+ control: "boolean",
+ description: "Hides icon, renders ActivityIndicator, sets aria-busy",
+ },
+ liveRing: {
+ control: "boolean",
+ description: "Decorative pulsing mint ring; honors reduced-motion",
+ },
+ disabled: { control: "boolean" },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const NewChat: Story = {};
+
+export const ExtendedPill: Story = {
+ args: { label: "New chat", icon: MessageSquarePlus },
+};
+
+export const ScrollBack: Story = {
+ args: {
+ icon: ArrowDown,
+ accessibilityLabel: "Scroll to latest",
+ variant: "overlay",
+ },
+};
+
+export const Neutral: Story = {
+ args: {
+ icon: Plus,
+ accessibilityLabel: "New chat session (neutral)",
+ variant: "neutral",
+ },
+};
+
+export const Large: Story = {
+ args: { size: "lg" },
+};
+
+export const Loading: Story = {
+ args: { loading: true },
+};
+
+export const Disabled: Story = {
+ args: { disabled: true },
+};
+
+export const WithLiveRing: Story = {
+ args: { liveRing: true },
+};
+
+export const AllVariantsAllSizes: Story = {
+ render: () => (
+
+
+
+
+
+
+
+
+
+
+
+
+ ),
+};
diff --git a/apps/mobile/components/FabBase/FabBase.tsx b/apps/mobile/components/FabBase/FabBase.tsx
new file mode 100644
index 00000000000..5765dfcc4b1
--- /dev/null
+++ b/apps/mobile/components/FabBase/FabBase.tsx
@@ -0,0 +1,213 @@
+import { cva, type VariantProps } from "class-variance-authority";
+import type { LucideIcon } from "lucide-react-native";
+import { useEffect, useState } from "react";
+import {
+ AccessibilityInfo,
+ ActivityIndicator,
+ Pressable,
+ type PressableProps,
+ View,
+} from "react-native";
+import Animated, {
+ useAnimatedStyle,
+ useSharedValue,
+ withRepeat,
+ withTiming,
+} from "react-native-reanimated";
+import { Icon } from "@/components/ui/icon";
+import { Text } from "@/components/ui/text";
+import { cn } from "@/lib/utils";
+
+const fabBaseVariants = cva(
+ "items-center justify-center active:opacity-90 shadow-lg",
+ {
+ variants: {
+ variant: {
+ accent: "bg-primary",
+ neutral: "bg-foreground",
+ overlay: "bg-popover border border-border",
+ },
+ size: {
+ md: "h-14",
+ lg: "h-16",
+ },
+ withLabel: {
+ true: "px-5 flex-row gap-2 rounded-full",
+ false: "rounded-full",
+ },
+ },
+ compoundVariants: [
+ { withLabel: false, size: "md", className: "w-14" },
+ { withLabel: false, size: "lg", className: "w-16" },
+ ],
+ defaultVariants: {
+ variant: "accent",
+ size: "md",
+ withLabel: false,
+ },
+ },
+);
+
+type FabBaseVariant = NonNullable<
+ VariantProps["variant"]
+>;
+type FabBaseSize = NonNullable["size"]>;
+
+const iconColorByVariant: Record = {
+ accent: "text-primary-foreground",
+ neutral: "text-background",
+ overlay: "text-foreground",
+};
+
+const iconSizeBySize: Record = {
+ md: "size-6",
+ lg: "size-7",
+};
+
+const ringSizeBySize: Record = {
+ md: "size-14 rounded-full",
+ lg: "size-16 rounded-full",
+};
+
+export type FabBaseProps = PressableProps &
+ Omit, "withLabel"> & {
+ icon: LucideIcon;
+ accessibilityLabel: string;
+ label?: string;
+ loading?: boolean;
+ liveRing?: boolean;
+ };
+
+/**
+ * Floating action button — sessions-list `+`, scroll-back-button, extended pill.
+ *
+ * Per atom · fab-base spec:
+ * - Variants: accent (default ember), neutral (inverted), overlay (subtle).
+ * - Sizes: md (56pt, icon 24) · lg (64pt, icon 28).
+ * - `label` enables the extended pill variant (icon + text, auto-width).
+ * - `liveRing` renders a decorative pulsing mint halo via Reanimated;
+ * respects AccessibilityInfo.isReduceMotionEnabled().
+ * - Carries `shadow-lg` elevation; `aria-label` is required.
+ *
+ * Placement is the caller's responsibility (typically absolute positioning).
+ */
+export function FabBase({
+ icon,
+ accessibilityLabel,
+ label,
+ variant,
+ size,
+ loading,
+ liveRing,
+ disabled,
+ className,
+ ...props
+}: FabBaseProps) {
+ const resolvedVariant: FabBaseVariant = variant ?? "accent";
+ const resolvedSize: FabBaseSize = size ?? "md";
+ const hasLabel = Boolean(label);
+ const isDisabled = disabled || loading;
+
+ const [reduceMotion, setReduceMotion] = useState(false);
+ useEffect(() => {
+ let mounted = true;
+ AccessibilityInfo.isReduceMotionEnabled().then((enabled) => {
+ if (mounted) setReduceMotion(enabled);
+ });
+ const sub = AccessibilityInfo.addEventListener(
+ "reduceMotionChanged",
+ (enabled) => setReduceMotion(enabled),
+ );
+ return () => {
+ mounted = false;
+ sub.remove();
+ };
+ }, []);
+
+ const ring = useSharedValue(0);
+ const shouldPulse = Boolean(liveRing) && !reduceMotion;
+
+ useEffect(() => {
+ if (shouldPulse) {
+ ring.value = withRepeat(withTiming(1, { duration: 1400 }), -1, false);
+ } else {
+ ring.value = 0;
+ }
+ }, [ring, shouldPulse]);
+
+ const ringStyle = useAnimatedStyle(() => {
+ if (!shouldPulse) return { opacity: 0 };
+ return {
+ opacity: 0.5 - ring.value * 0.5,
+ transform: [{ scale: 1 + ring.value * 0.4 }],
+ };
+ }, [ring, shouldPulse]);
+
+ const fab = (
+
+ {loading ? (
+
+ ) : (
+ <>
+
+ {hasLabel && label ? (
+
+ {label}
+
+ ) : null}
+ >
+ )}
+
+ );
+
+ if (!liveRing) return fab;
+
+ return (
+
+
+ {fab}
+
+ );
+}
diff --git a/apps/mobile/components/FabBase/index.ts b/apps/mobile/components/FabBase/index.ts
new file mode 100644
index 00000000000..0aa16aea0cb
--- /dev/null
+++ b/apps/mobile/components/FabBase/index.ts
@@ -0,0 +1 @@
+export { FabBase, type FabBaseProps } from "./FabBase";
diff --git a/apps/mobile/components/IconButton/IconButton.stories.tsx b/apps/mobile/components/IconButton/IconButton.stories.tsx
new file mode 100644
index 00000000000..13cc5fd8c22
--- /dev/null
+++ b/apps/mobile/components/IconButton/IconButton.stories.tsx
@@ -0,0 +1,227 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import {
+ ArrowLeft,
+ Copy,
+ type LucideIcon,
+ MoreVertical,
+ Plus,
+ Send,
+ Square,
+ Trash2,
+ X,
+} from "lucide-react-native";
+import { View } from "react-native";
+import { IconButton } from "./IconButton";
+
+const ICON_MAP: Record = {
+ Send,
+ Square,
+ X,
+ ArrowLeft,
+ MoreVertical,
+ Copy,
+ Trash2,
+ Plus,
+};
+
+const meta: Meta = {
+ title: "Components/IconButton",
+ component: IconButton,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Icon-only Pressable with guaranteed 44pt hit target at the default `md` size. Five variants (ghost · soft · primary · neutral · destructive) × four sizes (xs 28 · sm 36 · md 44 · lg 56) × two shapes (default · pill). Loading state hides the icon and shows a spinner with aria-busy.",
+ },
+ },
+ },
+ args: {
+ icon: Send,
+ accessibilityLabel: "Send message",
+ variant: "ghost",
+ size: "md",
+ shape: "default",
+ disabled: false,
+ loading: false,
+ },
+ argTypes: {
+ icon: {
+ control: { type: "select" },
+ options: Object.keys(ICON_MAP),
+ mapping: ICON_MAP,
+ description: "Lucide icon component rendered in the button center",
+ },
+ accessibilityLabel: {
+ control: "text",
+ description:
+ "Required — action description (not icon name). e.g. 'Send message', 'Back to sessions'",
+ },
+ variant: {
+ control: { type: "select" },
+ options: ["ghost", "soft", "primary", "neutral", "destructive"],
+ description: "Color/background tint",
+ },
+ size: {
+ control: { type: "select" },
+ options: ["xs", "sm", "md", "lg"],
+ description:
+ "xs=28×28 · sm=36×36 · md=44×44 (touch-target) · lg=56×56 (FAB-adjacent)",
+ },
+ shape: {
+ control: { type: "select" },
+ options: ["default", "pill"],
+ description: "rounded-md (default) or rounded-full (pill / FAB-style)",
+ },
+ disabled: { control: "boolean" },
+ loading: {
+ control: "boolean",
+ description: "Hides icon, renders ActivityIndicator, sets aria-busy=true",
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const GhostBack: Story = {
+ args: {
+ icon: ArrowLeft,
+ accessibilityLabel: "Back to sessions",
+ variant: "ghost",
+ size: "lg",
+ },
+};
+
+export const PrimarySend: Story = {
+ args: {
+ icon: Send,
+ accessibilityLabel: "Send message",
+ variant: "primary",
+ shape: "pill",
+ },
+};
+
+export const SoftCopy: Story = {
+ args: {
+ icon: Copy,
+ accessibilityLabel: "Copy code",
+ variant: "soft",
+ size: "sm",
+ },
+};
+
+export const DestructiveDelete: Story = {
+ args: {
+ icon: Trash2,
+ accessibilityLabel: "Delete session",
+ variant: "destructive",
+ size: "sm",
+ },
+};
+
+export const NeutralClose: Story = {
+ args: {
+ icon: X,
+ accessibilityLabel: "Close",
+ variant: "neutral",
+ size: "sm",
+ shape: "pill",
+ },
+};
+
+export const StreamingStop: Story = {
+ args: {
+ icon: Square,
+ accessibilityLabel: "Stop streaming",
+ variant: "destructive",
+ shape: "pill",
+ },
+};
+
+export const LoadingSend: Story = {
+ args: {
+ icon: Send,
+ accessibilityLabel: "Send message",
+ variant: "primary",
+ shape: "pill",
+ loading: true,
+ },
+};
+
+export const DisabledMore: Story = {
+ args: {
+ icon: MoreVertical,
+ accessibilityLabel: "Session menu",
+ variant: "ghost",
+ disabled: true,
+ },
+};
+
+export const AllVariants: Story = {
+ render: () => (
+
+
+
+
+
+
+
+ ),
+};
+
+export const AllSizes: Story = {
+ render: () => (
+
+
+
+
+
+
+ ),
+};
+
+export const PillShapes: Story = {
+ render: () => (
+
+
+
+
+ ),
+};
diff --git a/apps/mobile/components/IconButton/IconButton.tsx b/apps/mobile/components/IconButton/IconButton.tsx
new file mode 100644
index 00000000000..2f3dfb41cdf
--- /dev/null
+++ b/apps/mobile/components/IconButton/IconButton.tsx
@@ -0,0 +1,142 @@
+import { cva, type VariantProps } from "class-variance-authority";
+import type { LucideIcon } from "lucide-react-native";
+import { ActivityIndicator } from "react-native";
+import { Button, type ButtonProps } from "@/components/ui/button";
+import { Icon } from "@/components/ui/icon";
+import { cn } from "@/lib/utils";
+
+// Override vendor Button geometry for icon-only square buttons.
+// Vendor's `size="icon"` is 40×40 — we need explicit 28/36/44/56 per spec.
+const iconButtonGeometry = cva("p-0", {
+ variants: {
+ variant: {
+ ghost: "",
+ soft: "bg-card active:bg-accent",
+ primary: "",
+ neutral: "bg-foreground active:bg-foreground/90",
+ destructive: "",
+ },
+ size: {
+ xs: "h-7 w-7",
+ sm: "h-9 w-9",
+ md: "h-touch-min w-touch-min", // 44pt — WCAG AA + iOS HIG default
+ lg: "h-14 w-14",
+ },
+ shape: {
+ default: "rounded-md",
+ pill: "rounded-full",
+ },
+ },
+ defaultVariants: {
+ variant: "ghost",
+ size: "md",
+ shape: "default",
+ },
+});
+
+type IconButtonVariant = NonNullable<
+ VariantProps["variant"]
+>;
+type IconButtonSize = NonNullable<
+ VariantProps["size"]
+>;
+
+// Map our 5 spec variants onto vendor Button's variants. `soft` and `neutral`
+// have no vendor equivalent — they fall back to vendor `ghost` and add our own
+// background via the geometry override above.
+const vendorVariantByOurVariant: Record<
+ IconButtonVariant,
+ NonNullable
+> = {
+ ghost: "ghost",
+ soft: "ghost",
+ primary: "default",
+ neutral: "ghost",
+ destructive: "destructive",
+};
+
+const iconColorByVariant: Record = {
+ ghost: "text-foreground",
+ soft: "text-foreground",
+ primary: "text-primary-foreground",
+ neutral: "text-background",
+ destructive: "text-white",
+};
+
+const iconSizeBySize: Record = {
+ xs: "size-3.5", // 14px
+ sm: "size-4", // 16px
+ md: "size-5", // 20px
+ lg: "size-6", // 24px
+};
+
+export type IconButtonProps = Omit &
+ VariantProps & {
+ icon: LucideIcon;
+ accessibilityLabel: string;
+ iconClassName?: string;
+ loading?: boolean;
+ };
+
+/**
+ * Icon-only Button — composes vendor `
+ }
+ />
+ ),
+};
+
+export const Scrolled: Story = {
+ args: { isScrolled: true },
+};
diff --git a/apps/mobile/components/ModalHeader/ModalHeader.tsx b/apps/mobile/components/ModalHeader/ModalHeader.tsx
new file mode 100644
index 00000000000..6122a5c02a4
--- /dev/null
+++ b/apps/mobile/components/ModalHeader/ModalHeader.tsx
@@ -0,0 +1,76 @@
+import { X } from "lucide-react-native";
+import type { ReactNode } from "react";
+import { View, type ViewProps } from "react-native";
+import { IconButton } from "@/components/IconButton";
+import { Text } from "@/components/ui/text";
+import { cn } from "@/lib/utils";
+
+export type ModalHeaderProps = ViewProps & {
+ title: string;
+ onClose?: () => void;
+ closeAccessibilityLabel?: string;
+ /** Optional trailing slot (e.g. Save / Share button). Replaces the centering spacer. */
+ action?: ReactNode;
+ /** When true, title left-aligns instead of center (no spacer). */
+ simple?: boolean;
+ /** Adds subtle shadow when modal body scrolls beneath. */
+ isScrolled?: boolean;
+};
+
+/**
+ * Modal-specific header for full-screen sheets (UC-PAUSE-03 §A). Distinct from
+ * AppHeader — modals dismiss rather than navigate back.
+ *
+ * Per mol-modal-header spec:
+ * - default: leading ✕ + centered title + invisible spacer (mirrors close width)
+ * - with action: spacer replaced by trailing slot (e.g. Save)
+ * - simple: leading ✕ + left-aligned title, no spacer
+ * - isScrolled adds 1px shadow
+ *
+ * Composes first-party IconButton + Text.
+ */
+export function ModalHeader({
+ title,
+ onClose,
+ closeAccessibilityLabel = `Close ${title}`,
+ action,
+ simple = false,
+ isScrolled = false,
+ className,
+ ...props
+}: ModalHeaderProps) {
+ return (
+
+
+
+
+
+ {title}
+
+
+
+ {simple ? null : action ? (
+ {action}
+ ) : (
+ // Spacer that mirrors the close button width (44pt) to center the title.
+
+ )}
+
+ );
+}
diff --git a/apps/mobile/components/ModalHeader/index.ts b/apps/mobile/components/ModalHeader/index.ts
new file mode 100644
index 00000000000..c99c70a6669
--- /dev/null
+++ b/apps/mobile/components/ModalHeader/index.ts
@@ -0,0 +1 @@
+export { ModalHeader, type ModalHeaderProps } from "./ModalHeader";
diff --git a/apps/mobile/components/ModelPickerOption/ModelPickerOption.stories.tsx b/apps/mobile/components/ModelPickerOption/ModelPickerOption.stories.tsx
new file mode 100644
index 00000000000..539728c844a
--- /dev/null
+++ b/apps/mobile/components/ModelPickerOption/ModelPickerOption.stories.tsx
@@ -0,0 +1,145 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import { useState } from "react";
+import { View } from "react-native";
+import { RadioGroup } from "@/components/ui/radio-group";
+import { Text } from "@/components/ui/text";
+import { ModelPickerOption } from "./ModelPickerOption";
+
+const meta: Meta = {
+ title: "Molecules/ModelPickerOption",
+ component: ModelPickerOption,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Single selectable row inside the model-picker popover. Radio + name + optional NEW badge. 2 variants — default (accent bg) · featured (ember-tinted). Composes vendor RadioGroupItem + Badge. Must be rendered inside a .",
+ },
+ },
+ layout: "fullscreen",
+ },
+ args: {
+ value: "sonnet-4-6",
+ label: "Claude Sonnet 4.6",
+ isNew: false,
+ variant: "default",
+ isSelected: false,
+ disabled: false,
+ },
+ argTypes: {
+ value: { control: "text", description: "Unique radio value" },
+ label: { control: "text", description: "Model display name" },
+ isNew: { control: "boolean", description: "Show trailing NEW badge" },
+ variant: {
+ control: { type: "select" },
+ options: ["default", "featured"],
+ description: "default (accent selected bg) · featured (ember-tinted)",
+ },
+ isSelected: { control: "boolean", description: "Selection state" },
+ disabled: { control: "boolean" },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+function Wrap({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+export const Default: Story = {
+ render: (args) => (
+
+ {}}>
+
+
+
+ ),
+};
+
+export const Selected: Story = {
+ args: { isSelected: true },
+ render: (args) => (
+
+ {}}>
+
+
+
+ ),
+};
+
+export const NewBadge: Story = {
+ args: { label: "Claude Opus 4.7", isNew: true, isSelected: true },
+ render: (args) => (
+
+ {}}>
+
+
+
+ ),
+};
+
+export const FeaturedVariant: Story = {
+ args: { variant: "featured", isSelected: true },
+ render: (args) => (
+
+ {}}>
+
+
+
+ ),
+};
+
+export const VendorGroupList: Story = {
+ render: () => {
+ const [selected, setSelected] = useState("sonnet-4-6");
+ return (
+
+
+
+ Anthropic
+
+ setSelected("opus-4-7")}
+ />
+ setSelected("sonnet-4-6")}
+ />
+ setSelected("haiku-4-5")}
+ />
+
+ OpenAI
+
+ setSelected("gpt-5")}
+ />
+
+
+ );
+ },
+};
diff --git a/apps/mobile/components/ModelPickerOption/ModelPickerOption.tsx b/apps/mobile/components/ModelPickerOption/ModelPickerOption.tsx
new file mode 100644
index 00000000000..019a14f0bb6
--- /dev/null
+++ b/apps/mobile/components/ModelPickerOption/ModelPickerOption.tsx
@@ -0,0 +1,102 @@
+import { cva, type VariantProps } from "class-variance-authority";
+import { Pressable, type PressableProps, View } from "react-native";
+import { Badge } from "@/components/ui/badge";
+import { RadioGroupItem } from "@/components/ui/radio-group";
+import { Text } from "@/components/ui/text";
+import { cn } from "@/lib/utils";
+
+const modelPickerOptionVariants = cva(
+ "flex-row items-center min-h-touch-min gap-3 px-4 py-3 rounded-md active:opacity-70",
+ {
+ variants: {
+ variant: {
+ default: "",
+ featured: "",
+ },
+ isSelected: {
+ true: "",
+ false: "",
+ },
+ },
+ compoundVariants: [
+ {
+ variant: "default",
+ isSelected: true,
+ className: "bg-accent",
+ },
+ {
+ variant: "featured",
+ isSelected: true,
+ className: "bg-primary/15",
+ },
+ ],
+ defaultVariants: {
+ variant: "default",
+ isSelected: false,
+ },
+ },
+);
+
+export type ModelPickerOptionProps = PressableProps &
+ VariantProps & {
+ /** RadioGroupItem `value` — must be unique within the parent RadioGroup. */
+ value: string;
+ /** Model display name. */
+ label: string;
+ /** Show a "NEW" badge after the label. */
+ isNew?: boolean;
+ };
+
+/**
+ * Single selectable row inside the model-picker popover.
+ *
+ * Per mol-model-picker-option spec:
+ * - 2 variants: default (accent bg when selected) · featured (ember-tinted)
+ * - 44pt min-height; tap anywhere selects the radio
+ * - isNew → trailing "NEW" Badge
+ * - Must be rendered inside a vendor with matching value prop.
+ *
+ * Composes vendor RadioGroupItem + Badge + Text.
+ */
+export function ModelPickerOption({
+ value,
+ label,
+ isNew,
+ variant,
+ isSelected,
+ className,
+ disabled,
+ ...props
+}: ModelPickerOptionProps) {
+ return (
+
+
+
+
+ {label}
+
+ {isNew ? (
+
+
+ NEW
+
+
+ ) : null}
+
+
+ );
+}
diff --git a/apps/mobile/components/ModelPickerOption/index.ts b/apps/mobile/components/ModelPickerOption/index.ts
new file mode 100644
index 00000000000..f952f3385ab
--- /dev/null
+++ b/apps/mobile/components/ModelPickerOption/index.ts
@@ -0,0 +1,4 @@
+export {
+ ModelPickerOption,
+ type ModelPickerOptionProps,
+} from "./ModelPickerOption";
diff --git a/apps/mobile/components/PendingActionPill/PendingActionPill.stories.tsx b/apps/mobile/components/PendingActionPill/PendingActionPill.stories.tsx
new file mode 100644
index 00000000000..19e766dcc9f
--- /dev/null
+++ b/apps/mobile/components/PendingActionPill/PendingActionPill.stories.tsx
@@ -0,0 +1,94 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import { View } from "react-native";
+import { PendingActionPill } from "./PendingActionPill";
+
+const meta: Meta = {
+ title: "Molecules/PendingActionPill",
+ component: PendingActionPill,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Floating pill above the composer when a session has an active pause and user scrolled away (UC-PAUSE-04). 3 kinds — approval (target + ↓) · question (warning + ↑) · plan (sparkles + ↑). Reanimated FadeIn/Out via `visible` prop. Warning amber palette. Composes Icon + Text.",
+ },
+ },
+ layout: "centered",
+ },
+ args: {
+ kind: "approval",
+ count: 1,
+ visible: true,
+ disabled: false,
+ },
+ argTypes: {
+ kind: {
+ control: { type: "select" },
+ options: ["approval", "question", "plan"],
+ },
+ label: {
+ control: "text",
+ description: "Override default label ('1 PENDING' / 'QUESTION' / 'PLAN')",
+ },
+ count: {
+ control: "number",
+ description: "Count prefix — approval kind only",
+ },
+ direction: {
+ control: { type: "select" },
+ options: ["(default)", "down", "up", "(none)"],
+ mapping: {
+ "(default)": undefined,
+ down: "down",
+ up: "up",
+ "(none)": null,
+ },
+ },
+ visible: { control: "boolean", description: "Toggles FadeIn/FadeOut" },
+ disabled: { control: "boolean" },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Approval: Story = {};
+
+export const Question: Story = {
+ args: { kind: "question" },
+};
+
+export const Plan: Story = {
+ args: { kind: "plan" },
+};
+
+export const MultipleApprovals: Story = {
+ args: { count: 3 },
+};
+
+export const NoDirectionArrow: Story = {
+ args: { direction: null },
+};
+
+export const Hidden: Story = {
+ args: { visible: false },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "visible=false → FadeOut animation; component returns null when fully faded.",
+ },
+ },
+ },
+};
+
+export const AllKinds: Story = {
+ render: () => (
+
+
+
+
+
+
+ ),
+};
diff --git a/apps/mobile/components/PendingActionPill/PendingActionPill.tsx b/apps/mobile/components/PendingActionPill/PendingActionPill.tsx
new file mode 100644
index 00000000000..a149745d731
--- /dev/null
+++ b/apps/mobile/components/PendingActionPill/PendingActionPill.tsx
@@ -0,0 +1,137 @@
+import {
+ AlertTriangle,
+ ArrowDown,
+ ArrowUp,
+ type LucideIcon,
+ Sparkles,
+ Target,
+} from "lucide-react-native";
+import { useEffect } from "react";
+import { Pressable, type PressableProps } from "react-native";
+import Animated, {
+ useAnimatedStyle,
+ useSharedValue,
+ withTiming,
+} from "react-native-reanimated";
+import { Icon } from "@/components/ui/icon";
+import { Text } from "@/components/ui/text";
+import { cn } from "@/lib/utils";
+
+export type PendingActionPillKind = "approval" | "question" | "plan";
+
+type KindConfig = {
+ leadingIcon: LucideIcon;
+ defaultDirection: "down" | "up" | undefined;
+ defaultLabel: string;
+};
+
+const KIND: Record = {
+ approval: {
+ leadingIcon: Target,
+ defaultDirection: "down",
+ defaultLabel: "PENDING",
+ },
+ question: {
+ leadingIcon: AlertTriangle,
+ defaultDirection: "up",
+ defaultLabel: "QUESTION",
+ },
+ plan: { leadingIcon: Sparkles, defaultDirection: "up", defaultLabel: "PLAN" },
+};
+
+export type PendingActionPillProps = PressableProps & {
+ kind?: PendingActionPillKind;
+ /** Override the count label. Defaults to "1 PENDING" / "QUESTION" / "PLAN". */
+ label?: string;
+ /** Count prefix (e.g. 1 → "1 PENDING"). Approval kind only. */
+ count?: number;
+ /** Show + direction of trailing arrow. Defaults to kind default; pass null to hide. */
+ direction?: "down" | "up" | null;
+ /** When true (default), mounts with FadeIn animation; false renders static. */
+ visible?: boolean;
+};
+
+/**
+ * Floating pill above the composer when a session has an active pause and the
+ * user has scrolled away or dismissed the relevant inline card / sheet
+ * (UC-PAUSE-04 §A).
+ *
+ * Per mol-pending-action-pill spec:
+ * - 3 kinds: approval (target + ↓) · question (warning + ↑) · plan (sparkles + ↑)
+ * - FadeIn enter (opacity 0 → 1, translateY 8 → 0) when `visible` toggles true,
+ * FadeOut exit on false
+ * - Always warning-amber palette via Pill-like styling
+ *
+ * Pressable positioning is caller's responsibility (typically absolute, bottom-right
+ * above composer).
+ */
+export function PendingActionPill({
+ kind = "approval",
+ label,
+ count,
+ direction,
+ visible = true,
+ className,
+ disabled,
+ ...props
+}: PendingActionPillProps) {
+ const cfg = KIND[kind];
+ const resolvedLabel =
+ label ??
+ (count !== undefined && kind === "approval"
+ ? `${count} ${cfg.defaultLabel}`
+ : cfg.defaultLabel);
+ const resolvedDirection =
+ direction === undefined ? cfg.defaultDirection : direction;
+
+ const opacity = useSharedValue(visible ? 1 : 0);
+ const translateY = useSharedValue(visible ? 0 : 8);
+
+ useEffect(() => {
+ opacity.value = withTiming(visible ? 1 : 0, { duration: 200 });
+ translateY.value = withTiming(visible ? 0 : 8, { duration: 200 });
+ }, [opacity, translateY, visible]);
+
+ const animatedStyle = useAnimatedStyle(
+ () => ({
+ opacity: opacity.value,
+ transform: [{ translateY: translateY.value }],
+ }),
+ [opacity, translateY],
+ );
+
+ if (!visible && opacity.value === 0) {
+ return null;
+ }
+
+ const directionIcon =
+ resolvedDirection === "down"
+ ? ArrowDown
+ : resolvedDirection === "up"
+ ? ArrowUp
+ : null;
+
+ return (
+
+
+
+
+ {resolvedLabel}
+
+ {directionIcon ? (
+
+ ) : null}
+
+
+ );
+}
diff --git a/apps/mobile/components/PendingActionPill/index.ts b/apps/mobile/components/PendingActionPill/index.ts
new file mode 100644
index 00000000000..b1e94913d3d
--- /dev/null
+++ b/apps/mobile/components/PendingActionPill/index.ts
@@ -0,0 +1,5 @@
+export {
+ PendingActionPill,
+ type PendingActionPillKind,
+ type PendingActionPillProps,
+} from "./PendingActionPill";
diff --git a/apps/mobile/components/PendingApprovalCard/PendingApprovalCard.stories.tsx b/apps/mobile/components/PendingApprovalCard/PendingApprovalCard.stories.tsx
new file mode 100644
index 00000000000..7e0ec9a918b
--- /dev/null
+++ b/apps/mobile/components/PendingApprovalCard/PendingApprovalCard.stories.tsx
@@ -0,0 +1,105 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import { View } from "react-native";
+import {
+ PendingApprovalCard,
+ type PendingApprovalCardState,
+} from "./PendingApprovalCard";
+
+const STATES: PendingApprovalCardState[] = [
+ "pending",
+ "resolving",
+ "approved",
+ "declined",
+];
+
+const meta: Meta = {
+ title: "Molecules/PendingApprovalCard",
+ component: PendingApprovalCard,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Inline pending-approval card in the message stream (UC-PAUSE-01 §A). Vertical ToolStatusRule + icon (⌖/✓/✕) + title + subtitle + optional ALLOWABLE badge + args preview below hairline. 4 states drive rule color + icon + title override. Composes ToolStatusRule + Icon + vendor Badge + Separator + Text.",
+ },
+ },
+ layout: "fullscreen",
+ },
+ args: {
+ title: "Tool approval required",
+ subtitle: "bash · run_tests.sh",
+ argsPreview: "$ bun test --filter billing",
+ state: "pending",
+ alwaysAllowable: false,
+ detailed: false,
+ },
+ argTypes: {
+ title: { control: "text" },
+ subtitle: { control: "text" },
+ argsPreview: { control: "text" },
+ state: {
+ control: { type: "select" },
+ options: STATES,
+ description: "pending · resolving (50% opacity) · approved · declined",
+ },
+ alwaysAllowable: {
+ control: "boolean",
+ description: "Show ALLOWABLE badge in header (always-allow tools)",
+ },
+ detailed: {
+ control: "boolean",
+ description: "Allow multi-line args preview up to ~120pt",
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Pending: Story = {};
+
+export const AlwaysAllowable: Story = {
+ args: { alwaysAllowable: true },
+};
+
+export const Resolving: Story = {
+ args: { state: "resolving" },
+};
+
+export const Approved: Story = {
+ args: { state: "approved" },
+};
+
+export const Declined: Story = {
+ args: { state: "declined" },
+};
+
+export const Detailed: Story = {
+ args: {
+ detailed: true,
+ argsPreview: [
+ "$ bun test --filter billing",
+ " · src/billing/router.test.ts",
+ " · src/billing/invoice.test.ts",
+ " · src/billing/calc.test.ts",
+ " · timeout: 30s",
+ " · concurrency: 4",
+ ].join("\n"),
+ },
+};
+
+export const AllStates: Story = {
+ render: () => (
+
+ {STATES.map((s) => (
+
+ ))}
+
+ ),
+};
diff --git a/apps/mobile/components/PendingApprovalCard/PendingApprovalCard.tsx b/apps/mobile/components/PendingApprovalCard/PendingApprovalCard.tsx
new file mode 100644
index 00000000000..63d4041a694
--- /dev/null
+++ b/apps/mobile/components/PendingApprovalCard/PendingApprovalCard.tsx
@@ -0,0 +1,133 @@
+import { Check, type LucideIcon, Target, X } from "lucide-react-native";
+import { View, type ViewProps } from "react-native";
+import { ToolStatusRule } from "@/components/ToolStatusRule";
+import { Badge } from "@/components/ui/badge";
+import { Icon } from "@/components/ui/icon";
+import { Separator } from "@/components/ui/separator";
+import { Text } from "@/components/ui/text";
+import { cn } from "@/lib/utils";
+
+export type PendingApprovalCardState =
+ | "pending"
+ | "resolving"
+ | "approved"
+ | "declined";
+
+type StateConfig = {
+ ruleVariant: "pending" | "done" | "error";
+ icon: LucideIcon;
+ iconColorClass: string;
+ titleOverride?: string;
+};
+
+const STATE: Record = {
+ pending: {
+ ruleVariant: "pending",
+ icon: Target,
+ iconColorClass: "text-state-warning-fg",
+ },
+ resolving: {
+ ruleVariant: "pending",
+ icon: Target,
+ iconColorClass: "text-state-warning-fg",
+ },
+ approved: {
+ ruleVariant: "done",
+ icon: Check,
+ iconColorClass: "text-state-success-fg",
+ titleOverride: "Tool approved",
+ },
+ declined: {
+ ruleVariant: "error",
+ icon: X,
+ iconColorClass: "text-state-danger-fg",
+ titleOverride: "Tool declined",
+ },
+};
+
+export type PendingApprovalCardProps = ViewProps & {
+ title: string;
+ subtitle?: string;
+ argsPreview?: string;
+ state?: PendingApprovalCardState;
+ /** Show ALLOWABLE badge in header (tool supports always-allow). */
+ alwaysAllowable?: boolean;
+ /** Internal scroll on the args preview when multi-line (≤120pt tall). */
+ detailed?: boolean;
+};
+
+/**
+ * Inline pending-approval card in the message stream (UC-PAUSE-01 §A).
+ * Pairs with ApprovalFooter molecule below.
+ *
+ * Per mol-pending-approval-card spec:
+ * - vertical ToolStatusRule (amber pending → green done → red error)
+ * - icon (⌖ target / ✓ check / ✕) matches state palette
+ * - optional ALLOWABLE badge in header (always-allow tools)
+ * - resolving = pending appearance at 50% opacity (optimistic tap)
+ * - args preview shown below hairline divider
+ *
+ * Composes ToolStatusRule + Icon + Text + vendor Badge + Separator.
+ */
+export function PendingApprovalCard({
+ title,
+ subtitle,
+ argsPreview,
+ state = "pending",
+ alwaysAllowable,
+ detailed,
+ className,
+ ...props
+}: PendingApprovalCardProps) {
+ const cfg = STATE[state];
+ const displayTitle = cfg.titleOverride ?? title;
+ const isResolving = state === "resolving";
+
+ return (
+
+
+
+
+
+
+ {displayTitle}
+
+ {alwaysAllowable && state === "pending" ? (
+
+
+ ALLOWABLE
+
+
+ ) : null}
+
+ {subtitle ? (
+
+ {subtitle}
+
+ ) : null}
+ {argsPreview ? (
+ <>
+
+
+ {argsPreview}
+
+ >
+ ) : null}
+
+
+ );
+}
diff --git a/apps/mobile/components/PendingApprovalCard/index.ts b/apps/mobile/components/PendingApprovalCard/index.ts
new file mode 100644
index 00000000000..f4c31cac132
--- /dev/null
+++ b/apps/mobile/components/PendingApprovalCard/index.ts
@@ -0,0 +1,5 @@
+export {
+ PendingApprovalCard,
+ type PendingApprovalCardProps,
+ type PendingApprovalCardState,
+} from "./PendingApprovalCard";
diff --git a/apps/mobile/components/PickerTrigger/PickerTrigger.stories.tsx b/apps/mobile/components/PickerTrigger/PickerTrigger.stories.tsx
new file mode 100644
index 00000000000..e4cc9349fc5
--- /dev/null
+++ b/apps/mobile/components/PickerTrigger/PickerTrigger.stories.tsx
@@ -0,0 +1,79 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import { View } from "react-native";
+import { PickerTrigger } from "./PickerTrigger";
+
+const meta: Meta = {
+ title: "Molecules/PickerTrigger",
+ component: PickerTrigger,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Pill-shaped trigger button used by the composer toolbar — opens a picker popover for model / thinking-level / permission-mode selection. Three kinds (model · thinking · permission) × two sizes (sm 20pt · md 28pt default). `isOpen` rotates the chevron + swaps background to accent.",
+ },
+ },
+ layout: "centered",
+ },
+ args: {
+ kind: "model",
+ value: "Sonnet 4.6",
+ size: "md",
+ isOpen: false,
+ disabled: false,
+ },
+ argTypes: {
+ kind: {
+ control: { type: "select" },
+ options: ["model", "thinking", "permission"],
+ description:
+ "model (sparkles, no prefix) · thinking (zap, 'Thinking:') · permission (shield, 'Permission:')",
+ },
+ value: { control: "text", description: "Current selected value" },
+ size: {
+ control: { type: "select" },
+ options: ["sm", "md"],
+ description: "sm=20pt · md=28pt (default)",
+ },
+ isOpen: {
+ control: "boolean",
+ description: "Open state — bg→accent + chevron rotates 180°",
+ },
+ disabled: { control: "boolean" },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Model: Story = {};
+
+export const Thinking: Story = {
+ args: { kind: "thinking", value: "low" },
+};
+
+export const Permission: Story = {
+ args: { kind: "permission", value: "default" },
+};
+
+export const Open: Story = {
+ args: { isOpen: true },
+};
+
+export const Small: Story = {
+ args: { size: "sm" },
+};
+
+export const Disabled: Story = {
+ args: { disabled: true },
+};
+
+export const ComposerToolbarRow: Story = {
+ render: () => (
+
+
+
+
+
+ ),
+};
diff --git a/apps/mobile/components/PickerTrigger/PickerTrigger.tsx b/apps/mobile/components/PickerTrigger/PickerTrigger.tsx
new file mode 100644
index 00000000000..38abff3f460
--- /dev/null
+++ b/apps/mobile/components/PickerTrigger/PickerTrigger.tsx
@@ -0,0 +1,135 @@
+import { cva, type VariantProps } from "class-variance-authority";
+import {
+ ChevronDown,
+ type LucideIcon,
+ Shield,
+ Sparkles,
+ Zap,
+} from "lucide-react-native";
+import { Pressable, type PressableProps, View } from "react-native";
+import { Icon } from "@/components/ui/icon";
+import { Text } from "@/components/ui/text";
+import { cn } from "@/lib/utils";
+
+const pickerTriggerVariants = cva(
+ "flex-row items-center gap-1.5 rounded-full border border-border bg-card active:opacity-70",
+ {
+ variants: {
+ size: {
+ sm: "h-5 px-2",
+ md: "h-7 px-3",
+ },
+ isOpen: {
+ true: "bg-accent",
+ false: "",
+ },
+ },
+ defaultVariants: {
+ size: "md",
+ isOpen: false,
+ },
+ },
+);
+
+export type PickerTriggerKind = "model" | "thinking" | "permission";
+
+type KindConfig = {
+ icon: LucideIcon;
+ prefix?: string;
+ ariaPrefix: string;
+};
+
+const KIND_CONFIG: Record = {
+ model: { icon: Sparkles, ariaPrefix: "Model picker" },
+ thinking: { icon: Zap, prefix: "Thinking:", ariaPrefix: "Thinking level" },
+ permission: {
+ icon: Shield,
+ prefix: "Permission:",
+ ariaPrefix: "Permission mode",
+ },
+};
+
+export type PickerTriggerProps = PressableProps &
+ VariantProps & {
+ kind?: PickerTriggerKind;
+ value: string;
+ /** Override the leading icon (otherwise resolved from `kind`). */
+ icon?: LucideIcon;
+ /** Override the prefix label (otherwise resolved from `kind`). */
+ prefix?: string;
+ };
+
+/**
+ * Pill-shaped trigger button that opens a picker popover. Used by the composer
+ * toolbar for model, thinking-level, and permission-mode selection.
+ *
+ * Per mol-picker-trigger spec:
+ * - kinds: model (sparkles, no prefix) · thinking (zap, "Thinking:") · permission (shield, "Permission:")
+ * - sizes: sm (20pt) · md (28pt, default, touch-friendly with parent tap area)
+ * - is-open swaps background to accent + rotates chevron 180° (rotation handled by caller via prop if animated)
+ *
+ * Anatomy: leading icon (xs muted) + optional prefix label (mono small muted)
+ * + value (body) + trailing chevron-down (xs faint).
+ */
+export function PickerTrigger({
+ kind = "model",
+ value,
+ icon,
+ prefix,
+ size,
+ isOpen,
+ disabled,
+ className,
+ ...props
+}: PickerTriggerProps) {
+ const config = KIND_CONFIG[kind];
+ const resolvedIcon = icon ?? config.icon;
+ const resolvedPrefix = prefix ?? config.prefix;
+ const resolvedSize = size ?? "md";
+
+ return (
+
+
+ {resolvedPrefix ? (
+
+ {resolvedPrefix}
+
+ ) : null}
+
+ {value}
+
+
+
+
+
+ );
+}
diff --git a/apps/mobile/components/PickerTrigger/index.ts b/apps/mobile/components/PickerTrigger/index.ts
new file mode 100644
index 00000000000..2e58cd81b12
--- /dev/null
+++ b/apps/mobile/components/PickerTrigger/index.ts
@@ -0,0 +1,5 @@
+export {
+ PickerTrigger,
+ type PickerTriggerKind,
+ type PickerTriggerProps,
+} from "./PickerTrigger";
diff --git a/apps/mobile/components/Pill/Pill.stories.tsx b/apps/mobile/components/Pill/Pill.stories.tsx
new file mode 100644
index 00000000000..0d746f95f6c
--- /dev/null
+++ b/apps/mobile/components/Pill/Pill.stories.tsx
@@ -0,0 +1,162 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import {
+ GitBranch,
+ type LucideIcon,
+ Shield,
+ Sparkles,
+ Zap,
+} from "lucide-react-native";
+import { View } from "react-native";
+import { Pill } from "./Pill";
+
+const ICON_MAP: Record = {
+ GitBranch,
+ Shield,
+ Sparkles,
+ Zap,
+};
+
+const meta: Meta = {
+ title: "Components/Pill",
+ component: Pill,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Chat-domain rounded chip — model chips, slash-command pills, suggested answers, applied filter tags, status badges. Six variants × three sizes × monospace/uppercase modifiers. Optional dismiss button renders a separate ✕ child with its own 14pt hitSlop for 44pt total touch target.",
+ },
+ },
+ },
+ args: {
+ label: "Sonnet 4.6",
+ variant: "default",
+ size: "md",
+ selected: false,
+ interactive: false,
+ monospace: false,
+ uppercase: false,
+ disabled: false,
+ },
+ argTypes: {
+ label: { control: "text", description: "Pill body text" },
+ variant: {
+ control: { type: "select" },
+ options: ["default", "strong", "accent", "live", "warning", "danger"],
+ description:
+ "default (neutral) · strong (selected/active) · accent (ember slash-command) · live · warning · danger",
+ },
+ size: {
+ control: { type: "select" },
+ options: ["sm", "md", "lg"],
+ description: "sm=20h · md=28h (default) · lg=36h",
+ },
+ selected: {
+ control: "boolean",
+ description: "is-selected composable state — upgrades bg to accent",
+ },
+ interactive: {
+ control: "boolean",
+ description:
+ "When true (or when onPress is provided), renders as Pressable",
+ },
+ monospace: {
+ control: "boolean",
+ description: "Switches label to Geist Mono (code-like values)",
+ },
+ uppercase: {
+ control: "boolean",
+ description: "UPPERCASE + tracking-wider, e.g. STREAMING badge",
+ },
+ leadingIcon: {
+ control: { type: "select" },
+ options: ["(none)", ...Object.keys(ICON_MAP)],
+ mapping: { "(none)": undefined, ...ICON_MAP },
+ description: "Optional Lucide icon before the label",
+ },
+ disabled: { control: "boolean" },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const ModelChip: Story = {
+ args: { label: "Sonnet 4.6", leadingIcon: Sparkles, monospace: true },
+};
+
+export const ThinkingChip: Story = {
+ args: { label: "low", leadingIcon: Zap, variant: "default" },
+};
+
+export const PermissionChip: Story = {
+ args: { label: "default", leadingIcon: Shield, variant: "default" },
+};
+
+export const AccentSlashCommand: Story = {
+ args: { label: "/clear", variant: "accent", monospace: true },
+};
+
+export const Selected: Story = {
+ args: { label: "All projects", selected: true, interactive: true },
+};
+
+export const LiveStreaming: Story = {
+ args: { label: "STREAMING", variant: "live", uppercase: true, size: "sm" },
+};
+
+export const WarningPending: Story = {
+ args: {
+ label: "AWAITING APPROVAL",
+ variant: "warning",
+ uppercase: true,
+ size: "sm",
+ },
+};
+
+export const DangerOffline: Story = {
+ args: { label: "OFFLINE", variant: "danger", uppercase: true, size: "sm" },
+};
+
+export const FilterTagDismissible: Story = {
+ args: {
+ label: "main · macbook-pro",
+ leadingIcon: GitBranch,
+ variant: "strong",
+ interactive: true,
+ onDismiss: () => {},
+ dismissAccessibilityLabel: "Remove filter main · macbook-pro",
+ },
+};
+
+export const SuggestedAnswer: Story = {
+ args: {
+ label: "Yes, retry the connection",
+ variant: "accent",
+ interactive: true,
+ size: "lg",
+ },
+};
+
+export const AllVariants: Story = {
+ render: () => (
+
+
+
+
+
+
+
+
+ ),
+};
+
+export const AllSizes: Story = {
+ render: () => (
+
+
+
+
+
+ ),
+};
diff --git a/apps/mobile/components/Pill/Pill.tsx b/apps/mobile/components/Pill/Pill.tsx
new file mode 100644
index 00000000000..cddd557bba5
--- /dev/null
+++ b/apps/mobile/components/Pill/Pill.tsx
@@ -0,0 +1,184 @@
+import { cva, type VariantProps } from "class-variance-authority";
+import { type LucideIcon, X } from "lucide-react-native";
+import { Pressable, type PressableProps } from "react-native";
+import { Badge, type BadgeProps } from "@/components/ui/badge";
+import { Icon } from "@/components/ui/icon";
+import { Text } from "@/components/ui/text";
+import { cn } from "@/lib/utils";
+
+// Pill composes vendor (already rounded-full + asChild slot). We
+// override variant tinting + size geometry via className. For interactive
+// pills, asChild swaps the underlying View for our Pressable so the Badge
+// styling still applies but the tap surface is real.
+const pillVariants = cva("", {
+ variants: {
+ variant: {
+ default: "bg-card border-border",
+ strong: "bg-accent border-border",
+ accent: "bg-primary/15 border-transparent",
+ live: "bg-state-live-bg border-transparent",
+ warning: "bg-state-warning-bg border-transparent",
+ danger: "bg-state-danger-bg border-transparent",
+ },
+ size: {
+ sm: "h-5 px-2 gap-1",
+ md: "h-7 px-3 gap-1.5",
+ lg: "h-9 px-4 gap-2",
+ },
+ selected: {
+ true: "bg-accent border-border",
+ false: "",
+ },
+ interactive: {
+ true: "active:opacity-70",
+ false: "",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "md",
+ selected: false,
+ interactive: false,
+ },
+});
+
+type PillVariant = NonNullable["variant"]>;
+type PillSize = NonNullable["size"]>;
+
+const pillTextColorByVariant: Record = {
+ default: "text-foreground",
+ strong: "text-foreground",
+ accent: "text-primary",
+ live: "text-state-live-fg",
+ warning: "text-state-warning-fg",
+ danger: "text-state-danger-fg",
+};
+
+const pillIconSizeBySize: Record = {
+ sm: "size-3",
+ md: "size-4",
+ lg: "size-4",
+};
+
+const pillTextSizeBySize: Record = {
+ sm: "text-xs",
+ md: "text-sm",
+ lg: "text-sm",
+};
+
+export type PillProps = Omit &
+ Omit &
+ VariantProps & {
+ label: string;
+ leadingIcon?: LucideIcon;
+ onDismiss?: () => void;
+ dismissAccessibilityLabel?: string;
+ monospace?: boolean;
+ uppercase?: boolean;
+ onPress?: PressableProps["onPress"];
+ };
+
+/**
+ * Chat-domain pill — composes vendor `` (already rounded-full) and
+ * overrides tinting + sizing via className. Vendor variants don't cover the
+ * status palette we need (default/strong/accent/live/warning/danger), so we
+ * always pass `variant="outline"` and let our cva handle the visual.
+ *
+ * For interactive pills (`onPress` provided or `interactive=true`), uses
+ * `asChild` + Pressable so the Badge style applies to a real tap surface.
+ *
+ * Per atom · pill spec: monospace + uppercase modifiers, separate dismiss ✕
+ * with its own 14pt hitSlop = 44pt total tap target.
+ */
+export function Pill({
+ label,
+ leadingIcon,
+ onDismiss,
+ dismissAccessibilityLabel,
+ variant,
+ size,
+ selected,
+ interactive,
+ monospace,
+ uppercase,
+ className,
+ disabled,
+ onPress,
+ ...props
+}: PillProps) {
+ const resolvedVariant: PillVariant = variant ?? "default";
+ const resolvedSize: PillSize = size ?? "md";
+ const resolvedInteractive = interactive ?? Boolean(onPress);
+ const containerClass = cn(
+ // Reset vendor Badge's bg/border to be replaced by our cva variants.
+ "border",
+ pillVariants({
+ variant: resolvedVariant,
+ size: resolvedSize,
+ selected,
+ interactive: resolvedInteractive,
+ }),
+ disabled && "opacity-50",
+ className,
+ );
+ const textClass = cn(
+ pillTextColorByVariant[resolvedVariant],
+ pillTextSizeBySize[resolvedSize],
+ "font-medium",
+ monospace && "font-mono",
+ uppercase && "uppercase tracking-wider",
+ );
+ const iconClass = cn(
+ pillIconSizeBySize[resolvedSize],
+ pillTextColorByVariant[resolvedVariant],
+ );
+
+ const contents = (
+ <>
+ {leadingIcon ? : null}
+ {label}
+ {onDismiss ? (
+
+
+
+ ) : null}
+ >
+ );
+
+ if (resolvedInteractive) {
+ // asChild swaps Badge's underlying for our , preserving
+ // the rounded-full + flex-row layout from vendor Badge.
+ return (
+
+
+ {contents}
+
+
+ );
+ }
+
+ return (
+
+ {contents}
+
+ );
+}
diff --git a/apps/mobile/components/Pill/index.ts b/apps/mobile/components/Pill/index.ts
new file mode 100644
index 00000000000..bb582bec987
--- /dev/null
+++ b/apps/mobile/components/Pill/index.ts
@@ -0,0 +1,2 @@
+export type { PillProps } from "./Pill";
+export { Pill } from "./Pill";
diff --git a/apps/mobile/components/ProgressDots/ProgressDots.stories.tsx b/apps/mobile/components/ProgressDots/ProgressDots.stories.tsx
new file mode 100644
index 00000000000..2e0188a455e
--- /dev/null
+++ b/apps/mobile/components/ProgressDots/ProgressDots.stories.tsx
@@ -0,0 +1,117 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import { View } from "react-native";
+import { Text } from "@/components/ui/text";
+import { ProgressDots } from "./ProgressDots";
+
+const meta: Meta = {
+ title: "Components/ProgressDots",
+ component: ProgressDots,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "3-dot staggered pulse loading indicator. Four variants (muted · accent · live · faint) × three sizes (xs=4px · sm=6px default · md=8px). 1.4s cycle, 200ms stagger per dot via Reanimated. Container is `accessibilityRole=progressbar` with live-region. Respects AccessibilityInfo.isReduceMotionEnabled() — reduced-motion users see static dots at 0.8 opacity.",
+ },
+ },
+ layout: "centered",
+ },
+ args: {
+ variant: "muted",
+ size: "sm",
+ accessibilityLabel: "Loading",
+ paused: false,
+ },
+ argTypes: {
+ variant: {
+ control: { type: "select" },
+ options: ["muted", "accent", "live", "faint"],
+ description:
+ "Dot color — muted (default) · accent (ember) · live (mint) · faint",
+ },
+ size: {
+ control: { type: "select" },
+ options: ["xs", "sm", "md"],
+ description: "xs=4px · sm=6px (default) · md=8px dot diameter",
+ },
+ accessibilityLabel: {
+ control: "text",
+ description: "Announced via the live region. Defaults to 'Loading'",
+ },
+ paused: {
+ control: "boolean",
+ description:
+ "Freezes animation at static opacity — snapshot tests / debug only",
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
+export const Accent: Story = { args: { variant: "accent" } };
+export const Live: Story = { args: { variant: "live", size: "md" } };
+export const Faint: Story = { args: { variant: "faint" } };
+
+export const InLoadingFooter: Story = {
+ render: () => (
+
+
+
+ Loading history…
+
+
+ ),
+};
+
+export const AssistantTyping: Story = {
+ render: () => (
+
+
+ A
+
+
+
+ ),
+};
+
+export const SlashCommandPreview: Story = {
+ render: () => (
+
+ /deploy
+
+
+ ),
+};
+
+export const AllVariantsAllSizes: Story = {
+ render: () => (
+
+ {(["muted", "accent", "live", "faint"] as const).map((v) => (
+
+
+
+ {v}
+
+
+
+
+
+
+ ))}
+
+ ),
+};
diff --git a/apps/mobile/components/ProgressDots/ProgressDots.tsx b/apps/mobile/components/ProgressDots/ProgressDots.tsx
new file mode 100644
index 00000000000..f67930fddfd
--- /dev/null
+++ b/apps/mobile/components/ProgressDots/ProgressDots.tsx
@@ -0,0 +1,158 @@
+import { cva, type VariantProps } from "class-variance-authority";
+import { useEffect, useState } from "react";
+import { AccessibilityInfo, View, type ViewProps } from "react-native";
+import Animated, {
+ Easing,
+ useAnimatedStyle,
+ useSharedValue,
+ withDelay,
+ withRepeat,
+ withTiming,
+} from "react-native-reanimated";
+import { cn } from "@/lib/utils";
+
+const containerVariants = cva("flex-row items-center", {
+ variants: {
+ size: {
+ xs: "gap-0.5",
+ sm: "gap-1",
+ md: "gap-1.5",
+ },
+ },
+ defaultVariants: {
+ size: "sm",
+ },
+});
+
+const dotVariants = cva("rounded-full", {
+ variants: {
+ variant: {
+ muted: "bg-muted-foreground",
+ accent: "bg-primary",
+ live: "bg-state-live-fg",
+ faint: "bg-muted",
+ },
+ size: {
+ xs: "size-1", // 4px
+ sm: "size-1.5", // 6px
+ md: "size-2", // 8px
+ },
+ },
+ defaultVariants: {
+ variant: "muted",
+ size: "sm",
+ },
+});
+
+export type ProgressDotsProps = ViewProps &
+ VariantProps & {
+ /** Accessibility label announced via the live-region wrapper. Defaults to "Loading". */
+ accessibilityLabel?: string;
+ /** When true, freezes the animation (snapshot tests / debug inspection). */
+ paused?: boolean;
+ };
+
+const DOT_INDICES = [0, 1, 2] as const;
+
+/**
+ * 3-dot staggered pulse loading indicator. Distinct from a spinner (rotation)
+ * and a streaming-cursor (inline blink).
+ *
+ * Per atom · progress-dots spec:
+ * - Variants: muted (default) · accent · live · faint.
+ * - Sizes: xs (4px dots) · sm (6px dots, default) · md (8px dots).
+ * - 1.4s pulse cycle per dot; 200ms stagger between dots.
+ * - Respects AccessibilityInfo.isReduceMotionEnabled() — reduced-motion users
+ * see static dots at opacity 0.8.
+ * - Container carries `accessibilityRole="progressbar"` + `accessibilityState.busy`.
+ * - Dots are decorative children of the live-region container.
+ */
+export function ProgressDots({
+ variant,
+ size,
+ accessibilityLabel = "Loading",
+ paused,
+ className,
+ ...props
+}: ProgressDotsProps) {
+ const [reduceMotion, setReduceMotion] = useState(false);
+ useEffect(() => {
+ let mounted = true;
+ AccessibilityInfo.isReduceMotionEnabled().then((enabled) => {
+ if (mounted) setReduceMotion(enabled);
+ });
+ const sub = AccessibilityInfo.addEventListener(
+ "reduceMotionChanged",
+ (enabled) => setReduceMotion(enabled),
+ );
+ return () => {
+ mounted = false;
+ sub.remove();
+ };
+ }, []);
+
+ const shouldAnimate = !paused && !reduceMotion;
+
+ return (
+
+ {DOT_INDICES.map((i) => (
+
+ ))}
+
+ );
+}
+
+type DotProps = {
+ index: number;
+ variant: NonNullable["variant"]>;
+ size: NonNullable["size"]>;
+ animate: boolean;
+};
+
+function Dot({ index, variant, size, animate }: DotProps) {
+ const opacity = useSharedValue(0.4);
+
+ useEffect(() => {
+ if (!animate) {
+ opacity.value = 0.8;
+ return;
+ }
+ // 1.4s cycle, staggered by 200ms per dot.
+ const cycleMs = 1400;
+ opacity.value = withDelay(
+ index * 200,
+ withRepeat(
+ withTiming(1, {
+ duration: cycleMs / 2,
+ easing: Easing.inOut(Easing.ease),
+ }),
+ -1,
+ true,
+ ),
+ );
+ }, [opacity, animate, index]);
+
+ const style = useAnimatedStyle(() => ({ opacity: opacity.value }), [opacity]);
+
+ return (
+
+ );
+}
diff --git a/apps/mobile/components/ProgressDots/index.ts b/apps/mobile/components/ProgressDots/index.ts
new file mode 100644
index 00000000000..a12245687a5
--- /dev/null
+++ b/apps/mobile/components/ProgressDots/index.ts
@@ -0,0 +1 @@
+export { ProgressDots, type ProgressDotsProps } from "./ProgressDots";
diff --git a/apps/mobile/components/ScrollBackButton/ScrollBackButton.stories.tsx b/apps/mobile/components/ScrollBackButton/ScrollBackButton.stories.tsx
new file mode 100644
index 00000000000..b625d31b711
--- /dev/null
+++ b/apps/mobile/components/ScrollBackButton/ScrollBackButton.stories.tsx
@@ -0,0 +1,55 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import { View } from "react-native";
+import { ScrollBackButton } from "./ScrollBackButton";
+
+const meta: Meta = {
+ title: "Molecules/ScrollBackButton",
+ component: ScrollBackButton,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Floating scroll-back button (UC-RENDER-07). Appears when user scrolls away from latest message; tap returns to bottom. 2 variants — idle (bare chevron) · new-messages (accent dot at top-right). FadeIn/Out via Reanimated when `visible` toggles. 56pt FAB diameter satisfies 44pt touch target. Composes FabBase + StatusDot + Animated.View.",
+ },
+ },
+ layout: "centered",
+ },
+ args: {
+ visible: true,
+ newMessagesCount: 0,
+ },
+ argTypes: {
+ visible: {
+ control: "boolean",
+ description: "FadeIn/Out toggle (200ms)",
+ },
+ newMessagesCount: {
+ control: { type: "number", min: 0, max: 99 },
+ description: "When > 0 renders accent dot at top-right",
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Idle: Story = {};
+
+export const WithNewMessages: Story = {
+ args: { newMessagesCount: 3 },
+};
+
+export const Hidden: Story = {
+ args: { visible: false },
+};
+
+export const InScrollContextOverlay: Story = {
+ render: () => (
+
+
+
+
+
+ ),
+};
diff --git a/apps/mobile/components/ScrollBackButton/ScrollBackButton.tsx b/apps/mobile/components/ScrollBackButton/ScrollBackButton.tsx
new file mode 100644
index 00000000000..4335e7cb8cc
--- /dev/null
+++ b/apps/mobile/components/ScrollBackButton/ScrollBackButton.tsx
@@ -0,0 +1,89 @@
+import { ArrowDown } from "lucide-react-native";
+import { useEffect } from "react";
+import { View, type ViewProps } from "react-native";
+import Animated, {
+ useAnimatedStyle,
+ useSharedValue,
+ withTiming,
+} from "react-native-reanimated";
+import { FabBase } from "@/components/FabBase";
+import { StatusDot } from "@/components/StatusDot";
+import { cn } from "@/lib/utils";
+
+export type ScrollBackButtonProps = ViewProps & {
+ onPress?: () => void;
+ /** Toggle visibility with FadeIn/Out (200ms). Default true. */
+ visible?: boolean;
+ /** New-messages count — when > 0, renders a small accent dot badge. */
+ newMessagesCount?: number;
+};
+
+/**
+ * Floating circular scroll-back button (UC-RENDER-07). Appears when user has
+ * scrolled away from the latest message; tap snaps thread to bottom.
+ *
+ * Per mol-scroll-back-button spec:
+ * - 2 variants: idle (bare chevron) · new-messages (accent dot badge at top-right)
+ * - Composes FabBase (md, overlay) with ArrowDown icon
+ * - FadeIn/Out via Reanimated when `visible` toggles
+ * - 56pt diameter satisfies 44pt minimum touch target
+ *
+ * Positioning is caller's responsibility (typically absolute, bottom-right
+ * above composer, with safe-area inset).
+ *
+ * Composes first-party FabBase + StatusDot + Animated.View.
+ */
+export function ScrollBackButton({
+ onPress,
+ visible = true,
+ newMessagesCount = 0,
+ className,
+ ...props
+}: ScrollBackButtonProps) {
+ const opacity = useSharedValue(visible ? 1 : 0);
+ const translateY = useSharedValue(visible ? 0 : 8);
+
+ useEffect(() => {
+ opacity.value = withTiming(visible ? 1 : 0, { duration: 200 });
+ translateY.value = withTiming(visible ? 0 : 8, { duration: 200 });
+ }, [opacity, translateY, visible]);
+
+ const animatedStyle = useAnimatedStyle(
+ () => ({
+ opacity: opacity.value,
+ transform: [{ translateY: translateY.value }],
+ }),
+ [opacity, translateY],
+ );
+
+ const hasNewMessages = newMessagesCount > 0;
+ const accessibilityLabel = hasNewMessages
+ ? `${newMessagesCount} new message${newMessagesCount === 1 ? "" : "s"}, scroll to latest`
+ : "Scroll to latest message";
+
+ return (
+
+
+ {hasNewMessages ? (
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/apps/mobile/components/ScrollBackButton/index.ts b/apps/mobile/components/ScrollBackButton/index.ts
new file mode 100644
index 00000000000..3537b137b8a
--- /dev/null
+++ b/apps/mobile/components/ScrollBackButton/index.ts
@@ -0,0 +1,4 @@
+export {
+ ScrollBackButton,
+ type ScrollBackButtonProps,
+} from "./ScrollBackButton";
diff --git a/apps/mobile/components/ScrollFade/ScrollFade.stories.tsx b/apps/mobile/components/ScrollFade/ScrollFade.stories.tsx
new file mode 100644
index 00000000000..cdb6b1ae7e5
--- /dev/null
+++ b/apps/mobile/components/ScrollFade/ScrollFade.stories.tsx
@@ -0,0 +1,116 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import { ScrollView, View } from "react-native";
+import { Text } from "@/components/ui/text";
+import { cn } from "@/lib/utils";
+import { ScrollFade } from "./ScrollFade";
+
+function ScrollFadeShowcase({
+ direction,
+ surface,
+ size,
+ hidden,
+}: {
+ direction: "top" | "bottom";
+ surface: "page" | "soft" | "overlay";
+ size: "sm" | "md" | "lg";
+ hidden: boolean;
+}) {
+ const surfaceClass =
+ surface === "page"
+ ? "bg-background"
+ : surface === "soft"
+ ? "bg-card"
+ : "bg-popover";
+ return (
+
+
+ {Array.from({ length: 20 }, (_, i) => `line-${i}`).map((id, idx) => (
+
+ Line {idx + 1} — scrollable content beneath the fade
+
+ ))}
+
+
+
+ );
+}
+
+const meta: Meta = {
+ title: "Components/ScrollFade",
+ component: ScrollFadeShowcase,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Theme-aware gradient overlay (expo-linear-gradient) anchored to the top or bottom edge of a scroll container. Surface variant matches the underlying background (page/soft/overlay) so the opaque stop blends seamlessly under both light + dark themes. `hidden` triggers a 120ms opacity transition via Reanimated. Always `pointerEvents=none` + `aria-hidden`.",
+ },
+ },
+ layout: "centered",
+ },
+ args: {
+ direction: "top",
+ surface: "page",
+ size: "md",
+ hidden: false,
+ },
+ argTypes: {
+ direction: {
+ control: { type: "select" },
+ options: ["top", "bottom"],
+ description: "Edge of scroll container to anchor the fade against",
+ },
+ surface: {
+ control: { type: "select" },
+ options: ["page", "soft", "overlay"],
+ description:
+ "Underlying surface — page (default, bg-background) · soft (bg-card) · overlay (bg-popover)",
+ },
+ size: {
+ control: { type: "select" },
+ options: ["sm", "md", "lg"],
+ description: "sm=24px · md=40px (default) · lg=64px fade height",
+ },
+ hidden: {
+ control: "boolean",
+ description:
+ "Triggers a 120ms opacity-0 transition (apply when scrolled to the boundary)",
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const TopFade: Story = {};
+
+export const BottomFade: Story = { args: { direction: "bottom" } };
+
+export const OnSoftSurface: Story = { args: { surface: "soft" } };
+
+export const OnOverlaySurface: Story = { args: { surface: "overlay" } };
+
+export const SmallFade: Story = { args: { size: "sm" } };
+export const LargeFade: Story = { args: { size: "lg" } };
+
+export const Hidden: Story = {
+ args: { hidden: true },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "Fade scrolled into hidden state — opacity transitions 1→0 over 120ms.",
+ },
+ },
+ },
+};
diff --git a/apps/mobile/components/ScrollFade/ScrollFade.tsx b/apps/mobile/components/ScrollFade/ScrollFade.tsx
new file mode 100644
index 00000000000..9765666d869
--- /dev/null
+++ b/apps/mobile/components/ScrollFade/ScrollFade.tsx
@@ -0,0 +1,127 @@
+import { LinearGradient } from "expo-linear-gradient";
+import { useEffect } from "react";
+import { useColorScheme, type ViewProps } from "react-native";
+import Animated, {
+ useAnimatedStyle,
+ useSharedValue,
+ withTiming,
+} from "react-native-reanimated";
+import { cn } from "@/lib/utils";
+
+// Inlined surface colors — duplicated from apps/mobile/global.css to avoid
+// importing lib/theme.ts, which transitively pulls in expo-router's
+// UnhandledLinkingContext (breaks Storybook RN rendering). Keep in sync
+// with `--color-background` / `--color-card` / `--color-popover` in
+// global.css.
+const SURFACE_COLORS: Record<
+ "light" | "dark",
+ { page: string; soft: string; overlay: string }
+> = {
+ light: {
+ page: "hsl(0, 0%, 100%)",
+ soft: "hsl(0, 0%, 100%)",
+ overlay: "hsl(0, 0%, 100%)",
+ },
+ dark: {
+ page: "hsl(13, 16%, 7%)",
+ soft: "hsl(20, 7%, 12%)",
+ overlay: "hsl(20, 7%, 12%)",
+ },
+};
+
+export type ScrollFadeProps = Omit & {
+ /** Top edge (default) or bottom edge of the scroll container. */
+ direction?: "top" | "bottom";
+ /** Which underlying surface to blend into. `page` (default) · `soft` · `overlay`. */
+ surface?: "page" | "soft" | "overlay";
+ /** Fade height — `sm` (24) · `md` (40, default) · `lg` (64). */
+ size?: "sm" | "md" | "lg";
+ /** Hide the fade with a 120ms opacity transition (apply when scrolled to the boundary). */
+ hidden?: boolean;
+};
+
+const sizeClass: Record, string> = {
+ sm: "h-6", // 24px
+ md: "h-10", // 40px — default
+ lg: "h-16", // 64px
+};
+
+const directionClass: Record<
+ NonNullable,
+ string
+> = {
+ top: "top-0",
+ bottom: "bottom-0",
+};
+
+function surfaceColor(
+ surface: NonNullable,
+ scheme: "light" | "dark",
+): string {
+ return SURFACE_COLORS[scheme][surface];
+}
+
+/**
+ * Decorative gradient overlay signaling that scrollable content extends beyond
+ * the visible region. Composes expo-linear-gradient inside a Reanimated wrapper
+ * so it can fade in/out at scroll boundaries.
+ *
+ * Per atom · scroll-fade spec:
+ * - direction: top (default) | bottom — anchors to that edge of the scroll container.
+ * - surface: page (default) | soft | overlay — gradient opaque stop matches the
+ * underlying surface so it blends seamlessly under both light + dark themes.
+ * - size: sm (24px) | md (40px, default) | lg (64px) — fade height.
+ * - hidden: triggers a 120ms opacity-0 transition (apply when scrolled to the boundary).
+ *
+ * Always `aria-hidden` + `pointerEvents: none` — decorative chrome only.
+ *
+ * Caller must position the scroll container as `relative` so the absolutely
+ * positioned fade anchors correctly.
+ */
+export function ScrollFade({
+ direction = "top",
+ surface = "page",
+ size = "md",
+ hidden = false,
+ className,
+ style,
+ ...props
+}: ScrollFadeProps) {
+ const scheme = (useColorScheme() ?? "dark") as "light" | "dark";
+ const opaque = surfaceColor(surface, scheme);
+ const gradientColors: [string, string] =
+ direction === "top" ? [opaque, "transparent"] : ["transparent", opaque];
+
+ const opacity = useSharedValue(hidden ? 0 : 1);
+ useEffect(() => {
+ opacity.value = withTiming(hidden ? 0 : 1, { duration: 120 });
+ }, [opacity, hidden]);
+
+ const animatedStyle = useAnimatedStyle(
+ () => ({ opacity: opacity.value }),
+ [opacity],
+ );
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/mobile/components/ScrollFade/index.ts b/apps/mobile/components/ScrollFade/index.ts
new file mode 100644
index 00000000000..ebcfe7f2cbf
--- /dev/null
+++ b/apps/mobile/components/ScrollFade/index.ts
@@ -0,0 +1 @@
+export { ScrollFade, type ScrollFadeProps } from "./ScrollFade";
diff --git a/apps/mobile/components/SlashCommandOption/SlashCommandOption.stories.tsx b/apps/mobile/components/SlashCommandOption/SlashCommandOption.stories.tsx
new file mode 100644
index 00000000000..b2971105d55
--- /dev/null
+++ b/apps/mobile/components/SlashCommandOption/SlashCommandOption.stories.tsx
@@ -0,0 +1,127 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import {
+ Brain,
+ Eraser,
+ type LucideIcon,
+ Sparkles,
+ Square,
+} from "lucide-react-native";
+import { View } from "react-native";
+import { SlashCommandOption } from "./SlashCommandOption";
+
+const ICON_MAP: Record = {
+ Sparkles,
+ Brain,
+ Square,
+ Eraser,
+};
+
+const meta: Meta = {
+ title: "Molecules/SlashCommandOption",
+ component: SlashCommandOption,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Single row in the slash-command popover. Slash-prefixed name (accent mono) + description + source badge. 3 source variants — builtin (BUILT-IN neutral) · project (PROJECT accent) · user (USER live). 44pt min-height, isHighlighted for keyboard focus, isLoading swaps to ProgressDots.",
+ },
+ },
+ layout: "fullscreen",
+ },
+ args: {
+ name: "/model",
+ description: "Switch model",
+ source: "builtin",
+ isHighlighted: false,
+ isLoading: false,
+ disabled: false,
+ },
+ argTypes: {
+ name: { control: "text", description: "Slash-prefixed command name" },
+ description: { control: "text", description: "Short description" },
+ source: {
+ control: { type: "select" },
+ options: ["builtin", "project", "user"],
+ description: "Source kind — drives badge variant",
+ },
+ icon: {
+ control: { type: "select" },
+ options: ["(none)", ...Object.keys(ICON_MAP)],
+ mapping: { "(none)": undefined, ...ICON_MAP },
+ },
+ isHighlighted: { control: "boolean" },
+ isLoading: { control: "boolean" },
+ disabled: { control: "boolean" },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Builtin: Story = {};
+
+export const Project: Story = {
+ args: {
+ name: "/deploy",
+ description: "Deploy current branch to staging",
+ source: "project",
+ },
+};
+
+export const User: Story = {
+ args: {
+ name: "/snippets",
+ description: "Insert a personal snippet",
+ source: "user",
+ },
+};
+
+export const Highlighted: Story = {
+ args: { isHighlighted: true },
+};
+
+export const Loading: Story = {
+ args: { isLoading: true },
+};
+
+export const WithIcon: Story = {
+ args: { icon: Sparkles },
+};
+
+export const InPopoverList: Story = {
+ render: () => (
+
+
+
+
+
+
+
+ ),
+};
diff --git a/apps/mobile/components/SlashCommandOption/SlashCommandOption.tsx b/apps/mobile/components/SlashCommandOption/SlashCommandOption.tsx
new file mode 100644
index 00000000000..aa54894a4ab
--- /dev/null
+++ b/apps/mobile/components/SlashCommandOption/SlashCommandOption.tsx
@@ -0,0 +1,118 @@
+import { cva, type VariantProps } from "class-variance-authority";
+import type { LucideIcon } from "lucide-react-native";
+import { Pressable, type PressableProps, View } from "react-native";
+import { ProgressDots } from "@/components/ProgressDots";
+import { Badge } from "@/components/ui/badge";
+import { Icon } from "@/components/ui/icon";
+import { Text } from "@/components/ui/text";
+import { cn } from "@/lib/utils";
+
+const slashCommandOptionVariants = cva(
+ "flex-row items-start min-h-touch-min gap-3 px-4 py-3 active:opacity-70",
+ {
+ variants: {
+ isHighlighted: {
+ true: "bg-accent",
+ false: "",
+ },
+ isLoading: {
+ true: "opacity-70",
+ false: "",
+ },
+ },
+ defaultVariants: { isHighlighted: false, isLoading: false },
+ },
+);
+
+const sourceBadgeVariantByKind = {
+ builtin: "secondary",
+ project: "default",
+ user: "destructive",
+} as const;
+
+const sourceBadgeLabelByKind = {
+ builtin: "BUILT-IN",
+ project: "PROJECT",
+ user: "USER",
+} as const;
+
+export type SlashCommandSourceKind = keyof typeof sourceBadgeVariantByKind;
+
+export type SlashCommandOptionProps = PressableProps &
+ VariantProps & {
+ /** Slash-prefixed command name (e.g. "/model", "/clear"). */
+ name: string;
+ /** Short description shown below the name. */
+ description: string;
+ /** Source kind — drives the trailing badge. */
+ source?: SlashCommandSourceKind;
+ /** Optional leading lucide icon. */
+ icon?: LucideIcon;
+ };
+
+/**
+ * Single row inside the slash-command popover. Slash-prefixed command name
+ * (accent monospace) + plain-text description + optional source badge.
+ *
+ * Per mol-slash-command-option spec:
+ * - sources: builtin (neutral badge "BUILT-IN") · project (accent "PROJECT")
+ * · user (live "USER")
+ * - 44pt minimum height for iOS HIG
+ * - `isHighlighted` for keyboard/arrow-key focus state
+ * - `isLoading` swaps in ProgressDots trailing
+ *
+ * Composes vendor Badge + first-party ProgressDots + vendor Icon + Text.
+ */
+export function SlashCommandOption({
+ name,
+ description,
+ source = "builtin",
+ icon,
+ isHighlighted,
+ isLoading,
+ className,
+ disabled,
+ ...props
+}: SlashCommandOptionProps) {
+ return (
+
+ {icon ? (
+
+ ) : null}
+
+
+ {name}
+
+
+ {sourceBadgeLabelByKind[source]}
+
+
+
+ {description}
+
+ {isLoading ? (
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/apps/mobile/components/SlashCommandOption/index.ts b/apps/mobile/components/SlashCommandOption/index.ts
new file mode 100644
index 00000000000..0e38887faaa
--- /dev/null
+++ b/apps/mobile/components/SlashCommandOption/index.ts
@@ -0,0 +1,5 @@
+export {
+ SlashCommandOption,
+ type SlashCommandOptionProps,
+ type SlashCommandSourceKind,
+} from "./SlashCommandOption";
diff --git a/apps/mobile/components/StatusDot/StatusDot.stories.tsx b/apps/mobile/components/StatusDot/StatusDot.stories.tsx
new file mode 100644
index 00000000000..a8b3632c259
--- /dev/null
+++ b/apps/mobile/components/StatusDot/StatusDot.stories.tsx
@@ -0,0 +1,111 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import { View } from "react-native";
+import { Text } from "@/components/ui/text";
+import { StatusDot } from "./StatusDot";
+
+const meta: Meta = {
+ title: "Components/StatusDot",
+ component: StatusDot,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Single colored circle for status indication. Five variants from the state palette (live · warning · danger · success · neutral) × three explicit sizes (xs=6px · sm=8px default · md=10px). `live` pulses (1.4s scale + opacity halo); `warning` shows a static ring halo. Both respect AccessibilityInfo.isReduceMotionEnabled() — reduced-motion users see only the static glow.",
+ },
+ },
+ },
+ args: {
+ variant: "live",
+ size: "sm",
+ accessibilityLabel: "Streaming",
+ },
+ argTypes: {
+ variant: {
+ control: { type: "select" },
+ options: ["live", "warning", "danger", "success", "neutral"],
+ description:
+ "State-palette tint. live and warning render an additional halo behind the dot.",
+ },
+ size: {
+ control: { type: "select" },
+ options: ["xs", "sm", "md"],
+ description: "xs=6px · sm=8px (default) · md=10px",
+ },
+ accessibilityLabel: {
+ control: "text",
+ description:
+ "When provided, dot is treated as a standalone image landmark — otherwise it is decorative and relies on adjacent label text",
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Live: Story = {};
+export const Warning: Story = {
+ args: { variant: "warning", accessibilityLabel: "Pause pending" },
+};
+export const Danger: Story = {
+ args: { variant: "danger", accessibilityLabel: "Host offline" },
+};
+export const Success: Story = {
+ args: { variant: "success", accessibilityLabel: "Complete" },
+};
+export const Neutral: Story = {
+ args: { variant: "neutral", accessibilityLabel: "Idle" },
+};
+
+export const SizeXs: Story = { args: { size: "xs" } };
+export const SizeSm: Story = { args: { size: "sm" } };
+export const SizeMd: Story = { args: { size: "md" } };
+
+export const InSessionRow: Story = {
+ render: () => (
+
+
+
+ Refactor relay tunnel
+
+ · main · macbook-pro · 2m
+
+
+
+
+ Migrate auth flow
+
+ · feat-auth · macbook-pro · 5m
+
+
+
+
+ Doc cleanup
+
+ · main · server · 3h
+
+
+
+ ),
+};
+
+export const AllVariantsAllSizes: Story = {
+ render: () => (
+
+ {(["live", "warning", "danger", "success", "neutral"] as const).map(
+ (v) => (
+
+
+
+ {v}
+
+
+
+
+
+
+ ),
+ )}
+
+ ),
+};
diff --git a/apps/mobile/components/StatusDot/StatusDot.tsx b/apps/mobile/components/StatusDot/StatusDot.tsx
new file mode 100644
index 00000000000..a5621c73ad7
--- /dev/null
+++ b/apps/mobile/components/StatusDot/StatusDot.tsx
@@ -0,0 +1,145 @@
+import { cva, type VariantProps } from "class-variance-authority";
+import { useEffect, useState } from "react";
+import { AccessibilityInfo, View, type ViewProps } from "react-native";
+import Animated, {
+ useAnimatedStyle,
+ useSharedValue,
+ withRepeat,
+ withTiming,
+} from "react-native-reanimated";
+import { cn } from "@/lib/utils";
+
+const statusDotVariants = cva("rounded-full", {
+ variants: {
+ variant: {
+ live: "bg-state-live-fg",
+ warning: "bg-state-warning-fg",
+ danger: "bg-state-danger-fg",
+ success: "bg-state-success-fg",
+ neutral: "bg-state-neutral-fg",
+ },
+ size: {
+ xs: "size-1.5", // 6px
+ sm: "size-2", // 8px — default
+ md: "size-2.5", // 10px
+ },
+ },
+ defaultVariants: {
+ variant: "neutral",
+ size: "sm",
+ },
+});
+
+type StatusDotVariant = NonNullable<
+ VariantProps["variant"]
+>;
+type StatusDotSize = NonNullable<
+ VariantProps["size"]
+>;
+
+const haloBgByVariant: Record = {
+ live: "bg-state-live-bg",
+ warning: "bg-state-warning-bg",
+ danger: "bg-state-danger-bg",
+ success: "bg-state-success-bg",
+ neutral: "bg-transparent",
+};
+
+const haloSizeBySize: Record = {
+ xs: "size-3", // 12px halo behind 6px dot
+ sm: "size-4", // 16px halo behind 8px dot
+ md: "size-5", // 20px halo behind 10px dot
+};
+
+export type StatusDotProps = ViewProps &
+ VariantProps & {
+ /** Optional accessibility label — e.g. "Streaming". When provided, dot is treated as standalone image. */
+ accessibilityLabel?: string;
+ };
+
+/**
+ * Single colored circle indicating status. Variants drawn from the state palette
+ * (live · warning · danger · success · neutral).
+ *
+ * Behavior per atom · status-dot spec:
+ * - `live` pulses at ~1.4s scale + opacity, gated by AccessibilityInfo.isReduceMotionEnabled() —
+ * reduced-motion users see only the static glow halo.
+ * - `warning` shows a static ring halo at low opacity (the "box-shadow ring" in web spec).
+ * - Other variants render no halo.
+ *
+ * Sizes (explicit per spec): xs=6px · sm=8px (default) · md=10px.
+ */
+export function StatusDot({
+ variant,
+ size,
+ className,
+ accessibilityLabel,
+ ...props
+}: StatusDotProps) {
+ const resolvedVariant: StatusDotVariant = variant ?? "neutral";
+ const resolvedSize: StatusDotSize = size ?? "sm";
+ const [reduceMotion, setReduceMotion] = useState(false);
+
+ useEffect(() => {
+ let mounted = true;
+ AccessibilityInfo.isReduceMotionEnabled().then((enabled) => {
+ if (mounted) setReduceMotion(enabled);
+ });
+ const sub = AccessibilityInfo.addEventListener(
+ "reduceMotionChanged",
+ (enabled) => setReduceMotion(enabled),
+ );
+ return () => {
+ mounted = false;
+ sub.remove();
+ };
+ }, []);
+
+ const pulse = useSharedValue(0);
+ const isLive = resolvedVariant === "live";
+ const shouldPulse = isLive && !reduceMotion;
+
+ useEffect(() => {
+ if (shouldPulse) {
+ pulse.value = withRepeat(withTiming(1, { duration: 1400 }), -1, false);
+ } else {
+ pulse.value = 0;
+ }
+ }, [pulse, shouldPulse]);
+
+ const haloStyle = useAnimatedStyle(() => {
+ if (!shouldPulse) return { opacity: 0.35, transform: [{ scale: 1.5 }] };
+ return {
+ opacity: 0.6 - pulse.value * 0.6,
+ transform: [{ scale: 1 + pulse.value * 0.8 }],
+ };
+ }, [pulse, shouldPulse]);
+
+ const showHalo = isLive || resolvedVariant === "warning";
+
+ return (
+
+ {showHalo ? (
+
+ ) : null}
+
+
+ );
+}
diff --git a/apps/mobile/components/StatusDot/index.ts b/apps/mobile/components/StatusDot/index.ts
new file mode 100644
index 00000000000..5d347c55384
--- /dev/null
+++ b/apps/mobile/components/StatusDot/index.ts
@@ -0,0 +1,2 @@
+export type { StatusDotProps } from "./StatusDot";
+export { StatusDot } from "./StatusDot";
diff --git a/apps/mobile/components/StreamingCursor/StreamingCursor.stories.tsx b/apps/mobile/components/StreamingCursor/StreamingCursor.stories.tsx
new file mode 100644
index 00000000000..8ac5bdae71d
--- /dev/null
+++ b/apps/mobile/components/StreamingCursor/StreamingCursor.stories.tsx
@@ -0,0 +1,97 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import { View } from "react-native";
+import { Text } from "@/components/ui/text";
+import {
+ StreamingCursor,
+ type StreamingCursorVariant,
+} from "./StreamingCursor";
+
+const VARIANTS: StreamingCursorVariant[] = ["default", "steady", "paused"];
+
+const meta: Meta = {
+ title: "Components/StreamingCursor",
+ component: StreamingCursor,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Blinking text cursor (▌) appended to streaming assistant content (UC-RENDER-01). Three variants: `default` (1s steps(2) mint), `steady` (no animation), `paused` (0.6s steps(2) amber). Animation emulates CSS steps(2) via Reanimated withSequence. Respects AccessibilityInfo.isReduceMotionEnabled() — reduced-motion users see a static cursor regardless of variant.",
+ },
+ },
+ },
+ args: {
+ variant: "default",
+ glyph: "▌",
+ },
+ argTypes: {
+ variant: {
+ control: { type: "select" },
+ options: VARIANTS,
+ description:
+ "default (mint 1s blink) · steady (no animation) · paused (amber 0.6s blink)",
+ },
+ glyph: {
+ control: "text",
+ description: "Override the cursor character (default ▌)",
+ },
+ durationMs: {
+ control: { type: "range", min: 100, max: 2000, step: 50 },
+ description:
+ "Override variant default — default=1000 · paused=600 · steady=0",
+ },
+ className: {
+ control: "text",
+ description:
+ "Tailwind override — e.g. text-primary, text-foreground. Overrides variant color.",
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
+export const Steady: Story = { args: { variant: "steady" } };
+export const Paused: Story = { args: { variant: "paused" } };
+
+export const InStreamingMessage: Story = {
+ render: () => (
+
+
+
+ Refactoring the relay tunnel reconnect loop now—I'll preserve the
+ inner try/catch and log err.code before the backoff sleeps
+
+
+
+
+ ),
+};
+
+export const InPausedMessage: Story = {
+ render: () => (
+
+
+ Awaiting approval to continue
+
+
+
+ ),
+};
+
+export const AllVariants: Story = {
+ render: () => (
+
+ {VARIANTS.map((v) => (
+
+
+ {v}
+
+ generating
+
+
+ ))}
+
+ ),
+};
diff --git a/apps/mobile/components/StreamingCursor/StreamingCursor.tsx b/apps/mobile/components/StreamingCursor/StreamingCursor.tsx
new file mode 100644
index 00000000000..9066df815ee
--- /dev/null
+++ b/apps/mobile/components/StreamingCursor/StreamingCursor.tsx
@@ -0,0 +1,119 @@
+import { useEffect, useState } from "react";
+import { AccessibilityInfo } from "react-native";
+import Animated, {
+ Easing,
+ useAnimatedStyle,
+ useSharedValue,
+ withRepeat,
+ withSequence,
+ withTiming,
+} from "react-native-reanimated";
+import { cn } from "@/lib/utils";
+
+export type StreamingCursorVariant = "default" | "steady" | "paused";
+
+export type StreamingCursorProps = {
+ /** Variant per atom · streaming-cursor spec. */
+ variant?: StreamingCursorVariant;
+ /** Override the cursor glyph. Defaults to a 2px-wide tall block (▌). */
+ glyph?: string;
+ /** Override the duration (ms) of a single full blink cycle. Defaults vary by variant. */
+ durationMs?: number;
+ /** Tailwind text-* class — overrides default variant color. */
+ className?: string;
+};
+
+const variantColorClass: Record = {
+ default: "text-streaming-cursor",
+ steady: "text-streaming-cursor",
+ paused: "text-state-warning-fg",
+};
+
+const variantDurationMs: Record = {
+ default: 1000, // 1s steps(2)
+ steady: 0,
+ paused: 600, // 0.6s steps(2)
+};
+
+/**
+ * Blinking text cursor (▌) appended to streaming assistant content (UC-RENDER-01).
+ *
+ * Per atom · streaming-cursor spec:
+ * - `default` — mint glow, 1s steps(2) blink. Active streaming.
+ * - `steady` — mint glow, no animation. Snapshot tests / paused-paint frames.
+ * - `paused` — amber, 0.6s steps(2) blink. Stream pause-pending.
+ *
+ * Respects AccessibilityInfo.isReduceMotionEnabled() — reduced-motion users see a
+ * static steady cursor regardless of variant.
+ *
+ * Decorative — `aria-hidden`. The containing paragraph carries
+ * `role="status" aria-live="polite"` (caller's responsibility).
+ */
+export function StreamingCursor({
+ variant = "default",
+ glyph = "▌",
+ durationMs,
+ className,
+}: StreamingCursorProps) {
+ const opacity = useSharedValue(1);
+ const [reduceMotion, setReduceMotion] = useState(false);
+
+ useEffect(() => {
+ let mounted = true;
+ AccessibilityInfo.isReduceMotionEnabled().then((enabled) => {
+ if (mounted) setReduceMotion(enabled);
+ });
+ const sub = AccessibilityInfo.addEventListener(
+ "reduceMotionChanged",
+ (enabled) => setReduceMotion(enabled),
+ );
+ return () => {
+ mounted = false;
+ sub.remove();
+ };
+ }, []);
+
+ const resolvedDuration = durationMs ?? variantDurationMs[variant];
+ const shouldAnimate =
+ variant !== "steady" && resolvedDuration > 0 && !reduceMotion;
+
+ useEffect(() => {
+ if (!shouldAnimate) {
+ opacity.value = 1;
+ return;
+ }
+ // Emulate CSS `animation-timing-function: steps(2)` — discrete on/off.
+ // Each cycle: half duration off (opacity 0.1), half duration on (opacity 1).
+ const half = resolvedDuration / 2;
+ opacity.value = 1;
+ opacity.value = withRepeat(
+ withSequence(
+ withTiming(0.1, { duration: 1, easing: Easing.linear }),
+ withTiming(0.1, { duration: half - 1, easing: Easing.linear }),
+ withTiming(1, { duration: 1, easing: Easing.linear }),
+ withTiming(1, { duration: half - 1, easing: Easing.linear }),
+ ),
+ -1,
+ );
+ }, [opacity, resolvedDuration, shouldAnimate]);
+
+ const animatedStyle = useAnimatedStyle(
+ () => ({ opacity: opacity.value }),
+ [opacity],
+ );
+
+ return (
+
+ {glyph}
+
+ );
+}
diff --git a/apps/mobile/components/StreamingCursor/index.ts b/apps/mobile/components/StreamingCursor/index.ts
new file mode 100644
index 00000000000..c8758a354bf
--- /dev/null
+++ b/apps/mobile/components/StreamingCursor/index.ts
@@ -0,0 +1,2 @@
+export type { StreamingCursorProps } from "./StreamingCursor";
+export { StreamingCursor } from "./StreamingCursor";
diff --git a/apps/mobile/components/SuggestedAnswerPill/SuggestedAnswerPill.stories.tsx b/apps/mobile/components/SuggestedAnswerPill/SuggestedAnswerPill.stories.tsx
new file mode 100644
index 00000000000..a8bd573bcfa
--- /dev/null
+++ b/apps/mobile/components/SuggestedAnswerPill/SuggestedAnswerPill.stories.tsx
@@ -0,0 +1,62 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import { ScrollView, View } from "react-native";
+import { SuggestedAnswerPill } from "./SuggestedAnswerPill";
+
+const meta: Meta = {
+ title: "Molecules/SuggestedAnswerPill",
+ component: SuggestedAnswerPill,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Tappable pill in the ask_user bottom sheet's suggested-answers horizontal row. 3 variants — default (neutral) · accent (recommended ember) · ghost (subtle). 44pt touch zone via Pill md. Composes first-party Pill.",
+ },
+ },
+ layout: "centered",
+ },
+ args: {
+ text: "tRPC",
+ variant: "default",
+ disabled: false,
+ },
+ argTypes: {
+ text: { control: "text" },
+ variant: {
+ control: { type: "select" },
+ options: ["default", "accent", "ghost"],
+ },
+ disabled: { control: "boolean" },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const Accent: Story = {
+ args: { variant: "accent", text: "Yes, retry connection" },
+};
+
+export const Ghost: Story = {
+ args: { variant: "ghost", text: "Maybe later" },
+};
+
+export const HorizontalScrollRow: Story = {
+ render: () => (
+
+
+
+
+
+
+
+
+
+ ),
+};
diff --git a/apps/mobile/components/SuggestedAnswerPill/SuggestedAnswerPill.tsx b/apps/mobile/components/SuggestedAnswerPill/SuggestedAnswerPill.tsx
new file mode 100644
index 00000000000..28790486a8c
--- /dev/null
+++ b/apps/mobile/components/SuggestedAnswerPill/SuggestedAnswerPill.tsx
@@ -0,0 +1,48 @@
+import { Pill, type PillProps } from "@/components/Pill";
+
+export type SuggestedAnswerPillVariant = "default" | "accent" | "ghost";
+
+export type SuggestedAnswerPillProps = Omit<
+ PillProps,
+ "variant" | "interactive" | "label"
+> & {
+ text: string;
+ variant?: SuggestedAnswerPillVariant;
+};
+
+/**
+ * Tappable pill in the ask_user bottom sheet's suggested-answers horizontal row.
+ *
+ * Per mol-suggested-answer-pill spec:
+ * - 3 variants: default (neutral) · accent (recommended ember) · ghost (subtle)
+ * - Pill IS the tap zone — Pill `md` already gives 44pt touch height
+ * - aria-label pattern: "Use suggested answer: {text}"
+ *
+ * Composes first-party Pill in interactive mode.
+ */
+export function SuggestedAnswerPill({
+ text,
+ variant = "default",
+ onPress,
+ disabled,
+ ...props
+}: SuggestedAnswerPillProps) {
+ const pillVariant: PillProps["variant"] =
+ variant === "accent" ? "accent" : "default";
+ const className =
+ variant === "ghost" ? "bg-transparent border-border" : undefined;
+
+ return (
+
+ );
+}
diff --git a/apps/mobile/components/SuggestedAnswerPill/index.ts b/apps/mobile/components/SuggestedAnswerPill/index.ts
new file mode 100644
index 00000000000..2178142b9d5
--- /dev/null
+++ b/apps/mobile/components/SuggestedAnswerPill/index.ts
@@ -0,0 +1,5 @@
+export {
+ SuggestedAnswerPill,
+ type SuggestedAnswerPillProps,
+ type SuggestedAnswerPillVariant,
+} from "./SuggestedAnswerPill";
diff --git a/apps/mobile/components/ThinkingLevelOption/ThinkingLevelOption.stories.tsx b/apps/mobile/components/ThinkingLevelOption/ThinkingLevelOption.stories.tsx
new file mode 100644
index 00000000000..9d7fe11bc9e
--- /dev/null
+++ b/apps/mobile/components/ThinkingLevelOption/ThinkingLevelOption.stories.tsx
@@ -0,0 +1,129 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import { useState } from "react";
+import { View } from "react-native";
+import { RadioGroup } from "@/components/ui/radio-group";
+import { ThinkingLevelOption } from "./ThinkingLevelOption";
+
+const meta: Meta = {
+ title: "Molecules/ThinkingLevelOption",
+ component: ThinkingLevelOption,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Single row in the thinking-level / permission-mode picker popover. Radio + label + right-aligned mono hint. Hint text is included in accessibilityLabel. Must be rendered inside a vendor .",
+ },
+ },
+ layout: "fullscreen",
+ },
+ args: {
+ value: "low",
+ label: "low",
+ hint: "~1K tokens",
+ kind: "thinking",
+ isSelected: false,
+ disabled: false,
+ },
+ argTypes: {
+ value: { control: "text" },
+ label: { control: "text" },
+ hint: { control: "text", description: "Right-aligned mono hint" },
+ kind: {
+ control: { type: "select" },
+ options: ["thinking", "permission"],
+ },
+ isSelected: { control: "boolean" },
+ disabled: { control: "boolean" },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+function Wrap({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+export const Default: Story = {
+ render: (args) => (
+
+ {}}>
+
+
+
+ ),
+};
+
+export const Selected: Story = {
+ args: { isSelected: true },
+ render: (args) => (
+
+ {}}>
+
+
+
+ ),
+};
+
+export const ThinkingLevels: Story = {
+ render: () => {
+ const [selected, setSelected] = useState("low");
+ const items = [
+ { value: "off", label: "off", hint: "no thinking" },
+ { value: "low", label: "low", hint: "~1K tokens" },
+ { value: "medium", label: "medium", hint: "~5K tokens" },
+ { value: "high", label: "high", hint: "~10K tokens" },
+ { value: "xhigh", label: "xhigh", hint: "~25K tokens" },
+ ];
+ return (
+
+
+ {items.map((item) => (
+ setSelected(item.value)}
+ />
+ ))}
+
+
+ );
+ },
+};
+
+export const PermissionModes: Story = {
+ render: () => {
+ const [selected, setSelected] = useState("default");
+ const items = [
+ { value: "default", label: "default", hint: "Ask for risky ops" },
+ { value: "acceptEdits", label: "acceptEdits", hint: "Auto-accept edits" },
+ { value: "plan", label: "plan", hint: "Plan first, then ask" },
+ {
+ value: "bypassPermissions",
+ label: "bypassPermissions",
+ hint: "No prompts",
+ },
+ ];
+ return (
+
+
+ {items.map((item) => (
+ setSelected(item.value)}
+ />
+ ))}
+
+
+ );
+ },
+};
diff --git a/apps/mobile/components/ThinkingLevelOption/ThinkingLevelOption.tsx b/apps/mobile/components/ThinkingLevelOption/ThinkingLevelOption.tsx
new file mode 100644
index 00000000000..783892c7477
--- /dev/null
+++ b/apps/mobile/components/ThinkingLevelOption/ThinkingLevelOption.tsx
@@ -0,0 +1,68 @@
+import { Pressable, type PressableProps } from "react-native";
+import { RadioGroupItem } from "@/components/ui/radio-group";
+import { Text } from "@/components/ui/text";
+import { cn } from "@/lib/utils";
+
+export type ThinkingLevelOptionKind = "thinking" | "permission";
+
+export type ThinkingLevelOptionProps = PressableProps & {
+ value: string;
+ label: string;
+ /** Right-aligned hint: token budget (~1K tokens) or mode note (Ask for risky ops). */
+ hint?: string;
+ /** Kind — `thinking` (default) or `permission`. Reserved for future per-kind styling. */
+ kind?: ThinkingLevelOptionKind;
+ isSelected?: boolean;
+};
+
+/**
+ * Single row in the thinking-level / permission-mode picker popover.
+ *
+ * Per mol-thinking-level-option spec:
+ * - 44pt min-height; tap anywhere selects the radio
+ * - Hint text (~1K tokens / Ask for risky ops) is included in
+ * accessibilityLabel so screen readers hear it
+ * - kind reserved for future per-kind styling (warning on bypassPermissions)
+ *
+ * Must be rendered inside a vendor .
+ * Composes vendor RadioGroupItem + Text.
+ */
+export function ThinkingLevelOption({
+ value,
+ label,
+ hint,
+ kind = "thinking",
+ isSelected,
+ disabled,
+ className,
+ ...props
+}: ThinkingLevelOptionProps) {
+ const accessibilityLabel = hint ? `${label} — ${hint}` : label;
+ return (
+
+
+ {label}
+ {hint ? (
+
+ {hint}
+
+ ) : null}
+
+ );
+}
diff --git a/apps/mobile/components/ThinkingLevelOption/index.ts b/apps/mobile/components/ThinkingLevelOption/index.ts
new file mode 100644
index 00000000000..63f43063f34
--- /dev/null
+++ b/apps/mobile/components/ThinkingLevelOption/index.ts
@@ -0,0 +1,5 @@
+export {
+ ThinkingLevelOption,
+ type ThinkingLevelOptionKind,
+ type ThinkingLevelOptionProps,
+} from "./ThinkingLevelOption";
diff --git a/apps/mobile/components/ToastBase/ToastBase.stories.tsx b/apps/mobile/components/ToastBase/ToastBase.stories.tsx
new file mode 100644
index 00000000000..c39cc76a727
--- /dev/null
+++ b/apps/mobile/components/ToastBase/ToastBase.stories.tsx
@@ -0,0 +1,132 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import { View } from "react-native";
+import { Button } from "@/components/ui/button";
+import { Text } from "@/components/ui/text";
+import { ToastBase } from "./ToastBase";
+
+const meta: Meta = {
+ title: "Components/ToastBase",
+ component: ToastBase,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Transient notification surface composing internal atoms (ToolStatusRule + Icon + Text + IconButton). Five variants (info default · success · warning · danger · loading) × two shapes (inline default · stacked for longer messages). Variant color conveyed via 3px left rule + matching icon tint; surface stays neutral bg-popover. Caller manages timeout + position.",
+ },
+ },
+ layout: "centered",
+ },
+ args: {
+ variant: "info",
+ shape: "inline",
+ body: "Session renamed",
+ },
+ argTypes: {
+ variant: {
+ control: { type: "select" },
+ options: ["info", "success", "warning", "danger", "loading"],
+ description:
+ "info (default) · success (rename, send confirm) · warning (reconnecting) · danger (failed) · loading (connecting, downloading)",
+ },
+ shape: {
+ control: { type: "select" },
+ options: ["inline", "stacked"],
+ description:
+ "inline (icon · body · actions one row) · stacked (title + body + action vertically)",
+ },
+ body: {
+ control: "text",
+ description: "Body text — always present",
+ },
+ title: {
+ control: "text",
+ description: "Title text — stacked shape only; bold above body",
+ },
+ dismissAccessibilityLabel: {
+ control: "text",
+ description: "Override the ✕ button label (default 'Dismiss')",
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const InfoInline: Story = {
+ args: { body: "1 update available" },
+};
+
+export const SuccessInline: Story = {
+ args: { variant: "success", body: "Session renamed to ‘refactor-relay’" },
+};
+
+export const WarningInline: Story = {
+ args: { variant: "warning", body: "Host reconnecting…" },
+};
+
+export const DangerInline: Story = {
+ args: { variant: "danger", body: "Failed to send message" },
+};
+
+export const LoadingInline: Story = {
+ args: { variant: "loading", body: "Downloading update…" },
+};
+
+export const WithDismiss: Story = {
+ args: {
+ variant: "success",
+ body: "Session renamed",
+ onDismiss: () => {},
+ },
+};
+
+export const WithAction: Story = {
+ args: {
+ variant: "danger",
+ body: "Failed to send message",
+ onDismiss: () => {},
+ },
+ render: (args) => (
+
+ Retry
+
+ }
+ />
+ ),
+};
+
+export const StackedWithCTA: Story = {
+ args: {
+ variant: "warning",
+ shape: "stacked",
+ title: "Host offline",
+ body: "The macbook-pro host hasn't responded in 45 seconds. New messages will queue until it reconnects.",
+ onDismiss: () => {},
+ },
+ render: (args) => (
+
+ Reconnect
+
+ }
+ />
+ ),
+};
+
+export const AllVariantsInline: Story = {
+ render: () => (
+
+
+
+
+
+
+
+ ),
+};
diff --git a/apps/mobile/components/ToastBase/ToastBase.tsx b/apps/mobile/components/ToastBase/ToastBase.tsx
new file mode 100644
index 00000000000..c7fcf5faa46
--- /dev/null
+++ b/apps/mobile/components/ToastBase/ToastBase.tsx
@@ -0,0 +1,184 @@
+import { cva, type VariantProps } from "class-variance-authority";
+import {
+ AlertCircle,
+ AlertTriangle,
+ CheckCircle2,
+ Info,
+ type LucideIcon,
+ X,
+} from "lucide-react-native";
+import type { ReactNode } from "react";
+import { ActivityIndicator, View, type ViewProps } from "react-native";
+import { IconButton } from "@/components/IconButton";
+import { ToolStatusRule } from "@/components/ToolStatusRule";
+import { Icon } from "@/components/ui/icon";
+import { Text } from "@/components/ui/text";
+import { cn } from "@/lib/utils";
+
+const toastVariants = cva(
+ "flex-row items-stretch overflow-hidden rounded-xl border border-border bg-popover shadow-lg",
+ {
+ variants: {
+ variant: {
+ info: "",
+ success: "",
+ warning: "",
+ danger: "",
+ loading: "",
+ },
+ shape: {
+ inline: "",
+ stacked: "",
+ },
+ },
+ defaultVariants: {
+ variant: "info",
+ shape: "inline",
+ },
+ },
+);
+
+export type ToastBaseVariant =
+ | "info"
+ | "success"
+ | "warning"
+ | "danger"
+ | "loading";
+
+type Mapping = {
+ ruleVariant: "running" | "done" | "pending" | "error" | "neutral";
+ defaultIcon: LucideIcon;
+ iconColor: string;
+};
+
+const variantMapping: Record = {
+ info: {
+ ruleVariant: "neutral",
+ defaultIcon: Info,
+ iconColor: "text-primary",
+ },
+ success: {
+ ruleVariant: "done",
+ defaultIcon: CheckCircle2,
+ iconColor: "text-state-success-fg",
+ },
+ warning: {
+ ruleVariant: "pending",
+ defaultIcon: AlertTriangle,
+ iconColor: "text-state-warning-fg",
+ },
+ danger: {
+ ruleVariant: "error",
+ defaultIcon: AlertCircle,
+ iconColor: "text-state-danger-fg",
+ },
+ loading: {
+ ruleVariant: "running",
+ defaultIcon: Info,
+ iconColor: "text-muted-foreground",
+ },
+};
+
+export type ToastBaseProps = ViewProps &
+ VariantProps & {
+ variant?: ToastBaseVariant;
+ shape?: "inline" | "stacked";
+ body: string;
+ /** Stacked shape only — bolded title above the body. */
+ title?: string;
+ /** Override the default lucide icon for this variant. */
+ icon?: LucideIcon;
+ /** Optional action button slot (e.g. Retry, Open Settings). */
+ action?: ReactNode;
+ /** When provided, renders a ✕ dismiss button via IconButton (44pt touch target). */
+ onDismiss?: () => void;
+ dismissAccessibilityLabel?: string;
+ accessibilityLabel?: string;
+ };
+
+/**
+ * Single transient notification surface. Caller manages timeout + position.
+ *
+ * Per atom · toast-base spec:
+ * - 5 variants: info (default) · success · warning · danger · loading.
+ * - 2 shapes: inline (icon · body · actions in one row) · stacked
+ * (title row → body row → action row, useful for longer messages).
+ * - Variant color is conveyed via the 3px left ToolStatusRule + matching
+ * icon tint; the surface itself stays neutral (bg-popover) for legibility.
+ * - `loading` swaps the leading icon for ActivityIndicator.
+ * - `onDismiss` renders ✕ via IconButton (variant=ghost, shape=pill) for a
+ * guaranteed 44pt touch target.
+ *
+ * Composes existing internals: ToolStatusRule + Icon + Text + IconButton.
+ * No external vendor component fits the left-rule layout cleanly.
+ */
+export function ToastBase({
+ variant = "info",
+ shape = "inline",
+ body,
+ title,
+ icon,
+ action,
+ onDismiss,
+ dismissAccessibilityLabel,
+ accessibilityLabel,
+ className,
+ ...props
+}: ToastBaseProps) {
+ const mapping = variantMapping[variant];
+ const resolvedIcon = icon ?? mapping.defaultIcon;
+ const isStacked = shape === "stacked";
+
+ return (
+
+
+
+
+ {variant === "loading" ? (
+
+ ) : (
+
+ )}
+
+
+
+ {isStacked && title ? (
+ {title}
+ ) : null}
+ {body}
+ {isStacked && action ? {action} : null}
+
+
+ {!isStacked && action ? {action} : null}
+ {onDismiss ? (
+
+ ) : null}
+
+
+ );
+}
diff --git a/apps/mobile/components/ToastBase/index.ts b/apps/mobile/components/ToastBase/index.ts
new file mode 100644
index 00000000000..96532dd8b58
--- /dev/null
+++ b/apps/mobile/components/ToastBase/index.ts
@@ -0,0 +1,5 @@
+export {
+ ToastBase,
+ type ToastBaseProps,
+ type ToastBaseVariant,
+} from "./ToastBase";
diff --git a/apps/mobile/components/ToolCallCard/ToolCallCard.stories.tsx b/apps/mobile/components/ToolCallCard/ToolCallCard.stories.tsx
new file mode 100644
index 00000000000..0446e537e00
--- /dev/null
+++ b/apps/mobile/components/ToolCallCard/ToolCallCard.stories.tsx
@@ -0,0 +1,118 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import {
+ Code,
+ FileText,
+ Globe,
+ type LucideIcon,
+ Search,
+ Terminal,
+} from "lucide-react-native";
+import { View } from "react-native";
+import { ToolCallCard, type ToolCallStatus } from "./ToolCallCard";
+
+const STATUSES: ToolCallStatus[] = [
+ "running",
+ "done",
+ "pending",
+ "error",
+ "neutral",
+];
+
+const ICON_MAP: Record = {
+ Terminal,
+ Code,
+ FileText,
+ Search,
+ Globe,
+};
+
+const meta: Meta = {
+ title: "Molecules/ToolCallCard",
+ component: ToolCallCard,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Collapsed tool-call card (UC-RENDER-04). Tappable to navigate to detail; never expands in-place. 5 status variants drive ToolStatusRule + status Pill + icon color. running shows ActivityIndicator inline. Composes ToolStatusRule + Pill + Icon + Text.",
+ },
+ },
+ layout: "fullscreen",
+ },
+ args: {
+ name: "bash",
+ args: "$ bun test --filter billing",
+ status: "running",
+ duration: "0.3s",
+ },
+ argTypes: {
+ name: { control: "text" },
+ args: { control: "text" },
+ status: { control: { type: "select" }, options: STATUSES },
+ icon: {
+ control: { type: "select" },
+ options: Object.keys(ICON_MAP),
+ mapping: ICON_MAP,
+ },
+ duration: { control: "text", description: "Appended to DONE label" },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Running: Story = {};
+
+export const Done: Story = {
+ args: { status: "done", duration: "0.3s" },
+};
+
+export const Pending: Story = {
+ args: { status: "pending" },
+};
+
+export const Failed: Story = {
+ args: { status: "error", args: "Error: ENOENT — file not found" },
+};
+
+export const Neutral: Story = {
+ args: { status: "neutral" },
+};
+
+export const AllStatuses: Story = {
+ render: () => (
+
+
+
+
+
+
+
+ ),
+};
diff --git a/apps/mobile/components/ToolCallCard/ToolCallCard.tsx b/apps/mobile/components/ToolCallCard/ToolCallCard.tsx
new file mode 100644
index 00000000000..86d53dd5cca
--- /dev/null
+++ b/apps/mobile/components/ToolCallCard/ToolCallCard.tsx
@@ -0,0 +1,152 @@
+import { ChevronRight, Cog, type LucideIcon } from "lucide-react-native";
+import {
+ ActivityIndicator,
+ Pressable,
+ type PressableProps,
+ View,
+} from "react-native";
+import { Pill } from "@/components/Pill";
+import { ToolStatusRule } from "@/components/ToolStatusRule";
+import { Icon } from "@/components/ui/icon";
+import { Text } from "@/components/ui/text";
+import { cn } from "@/lib/utils";
+
+export type ToolCallStatus =
+ | "running"
+ | "done"
+ | "pending"
+ | "error"
+ | "neutral";
+
+type StatusConfig = {
+ ruleVariant: "running" | "done" | "pending" | "error" | "neutral";
+ statusPillVariant: "live" | "default" | "warning" | "danger" | "default";
+ statusLabel?: string;
+ iconColorClass: string;
+ showSpinner?: boolean;
+};
+
+const STATUS: Record = {
+ running: {
+ ruleVariant: "running",
+ statusPillVariant: "live",
+ statusLabel: "RUNNING",
+ iconColorClass: "text-state-live-fg",
+ showSpinner: true,
+ },
+ done: {
+ ruleVariant: "done",
+ statusPillVariant: "default",
+ statusLabel: "DONE",
+ iconColorClass: "text-muted-foreground",
+ },
+ pending: {
+ ruleVariant: "pending",
+ statusPillVariant: "warning",
+ statusLabel: "AWAITING",
+ iconColorClass: "text-state-warning-fg",
+ },
+ error: {
+ ruleVariant: "error",
+ statusPillVariant: "danger",
+ statusLabel: "FAILED",
+ iconColorClass: "text-state-danger-fg",
+ },
+ neutral: {
+ ruleVariant: "neutral",
+ statusPillVariant: "default",
+ iconColorClass: "text-muted-foreground/60",
+ },
+};
+
+export type ToolCallCardProps = PressableProps & {
+ name: string;
+ args?: string;
+ status?: ToolCallStatus;
+ icon?: LucideIcon;
+ /** e.g. "0.3s" appended to DONE label. */
+ duration?: string;
+};
+
+/**
+ * Collapsed tool-call card (UC-RENDER-04). Tappable to navigate to detail view;
+ * never expands in-place.
+ *
+ * Per mol-tool-call-card spec:
+ * - 5 status variants drive ToolStatusRule + status Pill + icon color
+ * - running shows inline ActivityIndicator instead of static spinner
+ * - tool name in monospace Pill (default variant)
+ * - args preview shown as truncated mono line below header
+ * - trailing chevron-right indicates tappability
+ *
+ * Composes ToolStatusRule + first-party Pill + Icon + Text + ActivityIndicator.
+ */
+export function ToolCallCard({
+ name,
+ args,
+ status = "running",
+ icon = Cog,
+ duration,
+ disabled,
+ className,
+ ...props
+}: ToolCallCardProps) {
+ const cfg = STATUS[status];
+ const statusText =
+ status === "done" && duration
+ ? `${cfg.statusLabel} · ${duration}`
+ : cfg.statusLabel;
+
+ return (
+
+
+
+
+
+
+
+ {statusText ? (
+
+ {cfg.showSpinner ? (
+
+ ) : null}
+
+
+ ) : null}
+
+ {args ? (
+
+
+ {args}
+
+
+
+ ) : null}
+
+
+ );
+}
diff --git a/apps/mobile/components/ToolCallCard/index.ts b/apps/mobile/components/ToolCallCard/index.ts
new file mode 100644
index 00000000000..586df1afad8
--- /dev/null
+++ b/apps/mobile/components/ToolCallCard/index.ts
@@ -0,0 +1,5 @@
+export {
+ ToolCallCard,
+ type ToolCallCardProps,
+ type ToolCallStatus,
+} from "./ToolCallCard";
diff --git a/apps/mobile/components/ToolStatusRule/ToolStatusRule.stories.tsx b/apps/mobile/components/ToolStatusRule/ToolStatusRule.stories.tsx
new file mode 100644
index 00000000000..b9cef7b651d
--- /dev/null
+++ b/apps/mobile/components/ToolStatusRule/ToolStatusRule.stories.tsx
@@ -0,0 +1,124 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import { View } from "react-native";
+import { Text } from "@/components/ui/text";
+import { ToolStatusRule } from "./ToolStatusRule";
+
+type Variant = "running" | "done" | "pending" | "error" | "neutral";
+
+function ToolStatusRuleShowcase({
+ variant,
+ orientation,
+}: {
+ variant: Variant;
+ orientation: "vertical" | "horizontal";
+}) {
+ if (orientation === "horizontal") {
+ return (
+
+
+
+ approval-footer
+
+ horizontal rule · variant={variant}
+
+
+
+ );
+ }
+ return (
+
+
+
+ ReadFile
+
+ src/handlers/chat.ts · 1.2KB · variant={variant}
+
+
+
+ );
+}
+
+const meta: Meta = {
+ title: "Components/ToolStatusRule",
+ component: ToolStatusRuleShowcase,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "3px colored rule indicating tool-call / approval status. Five status variants (running · done · pending · error · neutral) × two orientations (vertical default · horizontal for approval-footer). `running` and `pending` carry a soft glow via boxShadow.",
+ },
+ },
+ },
+ args: {
+ variant: "running",
+ orientation: "vertical",
+ },
+ argTypes: {
+ variant: {
+ control: { type: "select" },
+ options: ["running", "done", "pending", "error", "neutral"],
+ description:
+ "Status palette — running (mint + glow) · done (green) · pending (amber + glow) · error (red) · neutral (gray)",
+ },
+ orientation: {
+ control: { type: "select" },
+ options: ["vertical", "horizontal"],
+ description:
+ "vertical (default, left-edge of tool-call cards) or horizontal (top-edge of approval-footer)",
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Running: Story = {};
+export const Done: Story = { args: { variant: "done" } };
+export const Pending: Story = { args: { variant: "pending" } };
+export const ErrorVariant: Story = { args: { variant: "error" } };
+export const Neutral: Story = { args: { variant: "neutral" } };
+
+export const HorizontalRunning: Story = {
+ args: { orientation: "horizontal", variant: "running" },
+};
+
+export const AllVariantsVertical: Story = {
+ render: () => (
+
+ {(["running", "done", "pending", "error", "neutral"] as const).map(
+ (v) => (
+
+
+
+ {v}
+
+
+ ),
+ )}
+
+ ),
+};
+
+export const AllVariantsHorizontal: Story = {
+ render: () => (
+
+ {(["running", "done", "pending", "error", "neutral"] as const).map(
+ (v) => (
+
+
+
+ {v}
+
+
+ ),
+ )}
+
+ ),
+};
diff --git a/apps/mobile/components/ToolStatusRule/ToolStatusRule.tsx b/apps/mobile/components/ToolStatusRule/ToolStatusRule.tsx
new file mode 100644
index 00000000000..89135ac21af
--- /dev/null
+++ b/apps/mobile/components/ToolStatusRule/ToolStatusRule.tsx
@@ -0,0 +1,94 @@
+import { cva, type VariantProps } from "class-variance-authority";
+import { View, type ViewProps } from "react-native";
+import { cn } from "@/lib/utils";
+
+const toolStatusRuleVariants = cva("rounded-sm", {
+ variants: {
+ variant: {
+ running: "bg-state-live-fg",
+ done: "bg-state-success-fg",
+ pending: "bg-state-warning-fg",
+ error: "bg-state-danger-fg",
+ neutral: "bg-muted-foreground",
+ },
+ orientation: {
+ // 3px = --domain-tool-rule-width per spec. Fixed; not configurable.
+ vertical: "w-[3px] self-stretch min-h-6",
+ horizontal: "h-[3px] w-full",
+ },
+ },
+ defaultVariants: {
+ variant: "running",
+ orientation: "vertical",
+ },
+});
+
+type ToolStatusRuleVariant = NonNullable<
+ VariantProps["variant"]
+>;
+
+// RN custom-colored shadows have no NativeWind utility — keep these inline.
+const glowStyleByVariant: Record<
+ ToolStatusRuleVariant,
+ ViewProps["style"] | undefined
+> = {
+ running: {
+ shadowColor: "rgba(80, 168, 120, 0.6)",
+ shadowOpacity: 1,
+ shadowRadius: 6,
+ shadowOffset: { width: 0, height: 0 },
+ },
+ pending: {
+ shadowColor: "rgba(212, 168, 75, 0.6)",
+ shadowOpacity: 1,
+ shadowRadius: 4,
+ shadowOffset: { width: 0, height: 0 },
+ },
+ done: undefined,
+ error: undefined,
+ neutral: undefined,
+};
+
+export type ToolStatusRuleProps = ViewProps &
+ VariantProps;
+
+/**
+ * 3px colored rule indicating tool-call / approval status. Composed by
+ * tool-call-card, plan/reasoning blocks, pending-approval-card, approval-footer.
+ *
+ * Per atom · tool-status-rule spec:
+ * - Status variants: running · done · pending · error · neutral.
+ * - Orientation: vertical (default, self-stretch + min-h-24) or horizontal
+ * (full-width × 3px tall, for approval-footer top edge).
+ * - `running` + `pending` carry a soft glow via shadowColor — kept inline
+ * because RN custom-colored shadows have no NativeWind utility equivalent.
+ *
+ * Decorative — `aria-hidden`. Status meaning MUST also be conveyed by the
+ * parent card via text + icon.
+ */
+export function ToolStatusRule({
+ variant,
+ orientation,
+ className,
+ style,
+ ...props
+}: ToolStatusRuleProps) {
+ const resolvedVariant: ToolStatusRuleVariant = variant ?? "running";
+ const resolvedOrientation = orientation ?? "vertical";
+ const glow = glowStyleByVariant[resolvedVariant];
+ return (
+
+ );
+}
diff --git a/apps/mobile/components/ToolStatusRule/index.ts b/apps/mobile/components/ToolStatusRule/index.ts
new file mode 100644
index 00000000000..efe3e7b06d7
--- /dev/null
+++ b/apps/mobile/components/ToolStatusRule/index.ts
@@ -0,0 +1,2 @@
+export type { ToolStatusRuleProps } from "./ToolStatusRule";
+export { ToolStatusRule } from "./ToolStatusRule";
diff --git a/apps/mobile/components/UserMessageBubble/UserMessageBubble.stories.tsx b/apps/mobile/components/UserMessageBubble/UserMessageBubble.stories.tsx
new file mode 100644
index 00000000000..3cbe8eee746
--- /dev/null
+++ b/apps/mobile/components/UserMessageBubble/UserMessageBubble.stories.tsx
@@ -0,0 +1,70 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import { View } from "react-native";
+import { UserMessageBubble } from "./UserMessageBubble";
+
+const meta: Meta = {
+ title: "Molecules/UserMessageBubble",
+ component: UserMessageBubble,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "User's outgoing message — right-aligned bubble with styled surface. 3 variants — default · accent (@mentions ember) · pending (optimistic 60% opacity). failed=true swaps timestamp for 'Failed to send' + Retry Button. Long-press opens copy/share via onLongPress. Composes Pressable + Button + Text.",
+ },
+ },
+ layout: "fullscreen",
+ },
+ args: {
+ message: "The relay tunnel is reconnecting on every Wi-Fi flap.",
+ timestamp: "12:42 PM",
+ variant: "default",
+ failed: false,
+ },
+ argTypes: {
+ message: { control: "text" },
+ timestamp: { control: "text" },
+ variant: {
+ control: { type: "select" },
+ options: ["default", "accent", "pending"],
+ },
+ failed: { control: "boolean" },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const Accent: Story = {
+ args: { variant: "accent", message: "@assistant please check this out" },
+};
+
+export const Pending: Story = {
+ args: { variant: "pending", timestamp: undefined },
+};
+
+export const Failed: Story = {
+ args: { failed: true, timestamp: undefined },
+};
+
+export const InThread: Story = {
+ render: () => (
+
+
+
+
+
+ ),
+};
diff --git a/apps/mobile/components/UserMessageBubble/UserMessageBubble.tsx b/apps/mobile/components/UserMessageBubble/UserMessageBubble.tsx
new file mode 100644
index 00000000000..8e69192cbf4
--- /dev/null
+++ b/apps/mobile/components/UserMessageBubble/UserMessageBubble.tsx
@@ -0,0 +1,88 @@
+import { cva, type VariantProps } from "class-variance-authority";
+import { Pressable, View, type ViewProps } from "react-native";
+import { Button } from "@/components/ui/button";
+import { Text } from "@/components/ui/text";
+import { cn } from "@/lib/utils";
+
+const bubbleVariants = cva(
+ "max-w-[85%] self-end rounded-2xl px-4 py-2.5 active:opacity-80",
+ {
+ variants: {
+ variant: {
+ default: "bg-card",
+ accent: "bg-primary/15",
+ pending: "bg-card opacity-60",
+ },
+ },
+ defaultVariants: { variant: "default" },
+ },
+);
+
+export type UserMessageBubbleProps = ViewProps &
+ VariantProps & {
+ message: string;
+ timestamp?: string;
+ /** Show "Failed to send" + Retry button under the bubble. */
+ failed?: boolean;
+ onLongPress?: () => void;
+ onRetry?: () => void;
+ };
+
+/**
+ * User's outgoing message in chat (UC-RENDER-01). Right-aligned bubble with
+ * styled surface; long-press triggers the copy/share context menu (host wires
+ * onLongPress to a native ActionSheet).
+ *
+ * Per mol-user-message-bubble spec:
+ * - 3 variants: default · accent (ember @mentions) · pending (optimistic, 60% opacity)
+ * - failed=true → meta swap to "Failed to send" + inline Retry button
+ * - long-press affordance via `aria-haspopup="menu"` semantics
+ *
+ * Composes Pressable + vendor Button + Text.
+ */
+export function UserMessageBubble({
+ message,
+ timestamp,
+ failed,
+ variant,
+ onLongPress,
+ onRetry,
+ className,
+ ...props
+}: UserMessageBubbleProps) {
+ return (
+
+
+ {message}
+
+
+ {failed ? (
+ <>
+ Failed to send
+
+ Retry
+
+ >
+ ) : timestamp ? (
+ {timestamp}
+ ) : null}
+
+
+ );
+}
diff --git a/apps/mobile/components/UserMessageBubble/index.ts b/apps/mobile/components/UserMessageBubble/index.ts
new file mode 100644
index 00000000000..5c53c483adf
--- /dev/null
+++ b/apps/mobile/components/UserMessageBubble/index.ts
@@ -0,0 +1,4 @@
+export {
+ UserMessageBubble,
+ type UserMessageBubbleProps,
+} from "./UserMessageBubble";
diff --git a/apps/mobile/components/ui/AUDIT.md b/apps/mobile/components/ui/AUDIT.md
new file mode 100644
index 00000000000..2c532fe5755
--- /dev/null
+++ b/apps/mobile/components/ui/AUDIT.md
@@ -0,0 +1,99 @@
+# Vendor Primitive Token-Bypass Audit
+
+**Generated:** 2026-05-22
+**Scope:** `apps/mobile/components/ui/*.tsx` (28 files)
+**Rule reference:** `vendor_components_immutable` constraint in `apps/mobile/design/manifest.json`
+
+This audit catalogs **hardcoded color, opacity, and spacing values** in the vendor `react-native-reusables` primitives that bypass the token system in `apps/mobile/global.css`. Under the **vendor immutable** rule, these are NOT edited locally — the resolution path is an upstream PR to `react-native-reusables` (or a documented divergence accepted on our side).
+
+Listed values render correctly under the ember theme today; they just don't *participate* in the token system, so a future token change (e.g. shadow opacity tuning) won't reach them.
+
+---
+
+## Findings
+
+### 1. `shadow-black/5` and `shadow-black/N` — theme-blind shadows
+
+**Files (24+ instances):**
+- `alert-dialog.tsx` (1)
+- `button.tsx` (5 — default / destructive / outline / secondary / link variants)
+- `card.tsx` (1)
+- `checkbox.tsx` (1)
+- `context-menu.tsx` (2)
+- `dialog.tsx` (1)
+- `dropdown-menu.tsx` (2)
+- `hover-card.tsx` (1)
+- `input.tsx` (1)
+- `menubar.tsx` (3)
+- `popover.tsx` (1)
+- `radio-group.tsx` (1)
+- `select.tsx` (2)
+- `switch.tsx` (1)
+- `tabs.tsx` (1)
+- `textarea.tsx` (1)
+- `toggle-group.tsx` (1)
+- `toggle.tsx` (1)
+
+**Issue:** Tailwind's literal `black` color used at 5% opacity (`shadow-black/5`) regardless of theme. Under our warm-neutral ember dark surface (`#151110`), a 5% black shadow is nearly invisible — the shadow blends into the already-dark page. Under light theme, it works as intended.
+
+**User-visible impact:** Low. Drop shadows in dark mode are subtle to invisible — but rn-reusables design intent is *also* subtle. Net visual delta is small.
+
+**Future-proof fix path:** Replace with a token like `shadow-foreground/5` (uses theme foreground color) or introduce explicit `--color-shadow-soft` / `--color-shadow-overlay` tokens. Either way, the change happens upstream (`react-native-reusables`) — we do NOT patch locally.
+
+### 2. `bg-black/50` — backdrop overlay
+
+**Files (2 instances):**
+- `alert-dialog.tsx:32` — modal backdrop
+- `dialog.tsx:34` — modal backdrop
+
+**Issue:** Backdrop dim layer hardcoded to 50% black. Theme-blind by design — black backdrop on light theme dims the page to dark-gray; black backdrop on dark theme dims to near-pure-black (the page is already dark, so the dim is subtle).
+
+**User-visible impact:** Acceptable. A black backdrop is the conventional "modal dim" treatment across iOS/Android/web. Our dark-theme dim is *more* aggressive than a light-theme dim, which arguably matches user expectation (dark modals against dark UI need a stronger dim to feel layered).
+
+**Future-proof fix path:** Optional. If we ever want theme-aware backdrops (rare), introduce `--color-backdrop` token.
+
+### 3. `text-white` in destructive variants
+
+**Files (2 instances):**
+- `button.tsx:76` — `buttonTextVariants.destructive`
+- `badge.tsx:45` — `badgeTextVariants.destructive`
+
+**Issue:** Hardcoded white text on destructive backgrounds. The token `--color-destructive-foreground` is defined in `global.css` as:
+- Light: `hsl(0 0% 100%)` (pure white) — `text-white` is identical
+- Dark: `hsl(0 100% 90%)` (light pink) — `text-white` is slightly cooler than the warm pink token
+
+**User-visible impact:** Negligible. On dark surfaces, `text-white` vs. `hsl(0 100% 90%)` is indistinguishable to most users.
+
+**Future-proof fix path:** Replace literals with `text-destructive-foreground`. Same conclusion — upstream PR, not a local edit.
+
+---
+
+## Why we don't fix these locally
+
+The `vendor_components_immutable` constraint exists because:
+
+1. **rn-reusables CLI overwrites local edits.** If anyone runs `npx @react-native-reusables/cli@latest add button`, every local tweak to `button.tsx` is silently wiped.
+2. **Silent drift defeats the point of vendor-managed components.** The value of using upstream primitives is they stay aligned with upstream. Forking destroys that.
+3. **Hand-rolling wrappers around vendor components creates the same problem at one level of indirection.** A `` that wraps `` to inject `text-destructive-foreground` is just a slower way to fork.
+
+The right fixes for the above findings:
+
+| Finding | Upstream resolution |
+|---|---|
+| `shadow-black/5` | Open issue or PR on `react-native-reusables` proposing theme-aware shadow tokens |
+| `bg-black/50` | Acceptable as-is; only revisit if a designer flags it |
+| `text-white` in destructive | Open PR on `react-native-reusables` replacing with `text-destructive-foreground` |
+
+Until upstream PRs land, these divergences are **acknowledged and accepted** — they don't break ember rendering, they just don't participate in the token system.
+
+---
+
+## Story coverage status
+
+All 28 primitives now have stories under `Components/{Primitive}` (added 2026-05-22) — visually inspectable in Storybook on iOS Simulator + Android Emulator. Use the Storybook walkthrough as the verification surface; flag any visual regression discovered there back into this doc.
+
+---
+
+## Out-of-scope utilities
+
+- **`native-only-animated-view.tsx`** — Utility for native-only animation surfaces. Not a UI primitive; no story written. If it ever becomes user-facing, add a story per workflow rules.
diff --git a/apps/mobile/components/ui/accordion.stories.tsx b/apps/mobile/components/ui/accordion.stories.tsx
new file mode 100644
index 00000000000..eb26458d642
--- /dev/null
+++ b/apps/mobile/components/ui/accordion.stories.tsx
@@ -0,0 +1,101 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+} from "@/components/ui/accordion";
+import { Text } from "@/components/ui/text";
+
+function AccordionShowcase({
+ type,
+ collapsible,
+}: {
+ type: "single" | "multiple";
+ collapsible: boolean;
+}) {
+ if (type === "single") {
+ return (
+
+
+
+ How do I sign in?
+
+
+
+ Open the More tab and tap "Sign in with email" — a magic link will
+ be sent to your inbox.
+
+
+
+
+
+ What is a workspace?
+
+
+
+ A workspace is a branch on a host. Each session is bound to one
+ workspace.
+
+
+
+
+
+ Why is the host offline?
+
+
+
+ The host-service process may not be running. Check that desktop
+ Superset is open and connected.
+
+
+
+
+ );
+ }
+ return (
+
+
+
+ First (independent)
+
+
+ Independently expandable.
+
+
+
+
+ Second (independent)
+
+
+ Also independent.
+
+
+
+ );
+}
+
+const meta: Meta = {
+ title: "Components/Accordion",
+ component: AccordionShowcase,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Show/hide content groups. Used for FAQ-style content in Help / About screens. Choose `single` for radio behavior, `multiple` for independent toggles.",
+ },
+ },
+ },
+ args: { type: "single", collapsible: true },
+ argTypes: {
+ type: { control: { type: "select" }, options: ["single", "multiple"] },
+ collapsible: { control: "boolean" },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const SingleCollapsible: Story = {};
+export const Multiple: Story = { args: { type: "multiple" } };
diff --git a/apps/mobile/components/ui/alert-dialog.stories.tsx b/apps/mobile/components/ui/alert-dialog.stories.tsx
new file mode 100644
index 00000000000..ddc9794ff70
--- /dev/null
+++ b/apps/mobile/components/ui/alert-dialog.stories.tsx
@@ -0,0 +1,95 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "@/components/ui/alert-dialog";
+import { Button } from "@/components/ui/button";
+import { Text } from "@/components/ui/text";
+
+function AlertDialogShowcase({
+ triggerLabel,
+ title,
+ description,
+ confirmLabel,
+ cancelLabel,
+}: {
+ triggerLabel: string;
+ title: string;
+ description: string;
+ confirmLabel: string;
+ cancelLabel: string;
+}) {
+ return (
+
+
+
+ {triggerLabel}
+
+
+
+
+ {title}
+ {description}
+
+
+
+ {cancelLabel}
+
+
+ {confirmLabel}
+
+
+
+
+ );
+}
+
+const meta: Meta = {
+ title: "Components/AlertDialog",
+ component: AlertDialogShowcase,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Confirmation modal for destructive actions. Used by Delete session dialog (UC-SESS-05). Renders via portal — PortalHost is wired in preview decorator.",
+ },
+ },
+ },
+ args: {
+ triggerLabel: "Delete session",
+ title: "Delete this session?",
+ description:
+ "This will permanently remove the session and its messages. This cannot be undone.",
+ confirmLabel: "Delete",
+ cancelLabel: "Cancel",
+ },
+ argTypes: {
+ triggerLabel: { control: "text" },
+ title: { control: "text" },
+ description: { control: "text" },
+ confirmLabel: { control: "text" },
+ cancelLabel: { control: "text" },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const DeleteSession: Story = {};
+
+export const SignOut: Story = {
+ args: {
+ triggerLabel: "Sign out",
+ title: "Sign out of Superset?",
+ description: "You'll need to sign in again to access your sessions.",
+ confirmLabel: "Sign out",
+ },
+};
diff --git a/apps/mobile/components/ui/alert.stories.tsx b/apps/mobile/components/ui/alert.stories.tsx
new file mode 100644
index 00000000000..f3960eb081c
--- /dev/null
+++ b/apps/mobile/components/ui/alert.stories.tsx
@@ -0,0 +1,97 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import {
+ AlertTriangle,
+ Info as InfoIcon,
+ type LucideIcon,
+ WifiOff,
+} from "lucide-react-native";
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+
+const ICON_MAP: Record = {
+ Info: InfoIcon,
+ WifiOff,
+ AlertTriangle,
+};
+
+function AlertShowcase({
+ variant,
+ icon,
+ title,
+ description,
+}: {
+ variant: "default" | "destructive";
+ icon: keyof typeof ICON_MAP;
+ title: string;
+ description: string;
+}) {
+ return (
+
+ {title}
+ {description}
+
+ );
+}
+
+const meta: Meta = {
+ title: "Components/Alert",
+ component: AlertShowcase,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Inline informational/destructive alert with leading icon. Used for host-offline banner, permission-denied banner, dispatch-outcome variants (UC-PLATF-03).",
+ },
+ },
+ },
+ args: {
+ variant: "default",
+ icon: "Info",
+ title: "Host reconnected",
+ description: "Streaming has resumed.",
+ },
+ argTypes: {
+ variant: {
+ control: { type: "select" },
+ options: ["default", "destructive"],
+ },
+ icon: {
+ control: { type: "select" },
+ options: ["Info", "WifiOff", "AlertTriangle"],
+ },
+ title: { control: "text" },
+ description: { control: "text" },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const InfoBanner: Story = {};
+
+export const HostOffline: Story = {
+ args: {
+ variant: "destructive",
+ icon: "WifiOff",
+ title: "Host offline",
+ description: "Tap to retry connecting.",
+ },
+};
+
+export const PlanUpgrade: Story = {
+ args: {
+ variant: "destructive",
+ icon: "AlertTriangle",
+ title: "Plan upgrade required",
+ description: "Your host requires a paid plan to dispatch.",
+ },
+};
+
+export const DispatchFailed: Story = {
+ args: {
+ variant: "destructive",
+ icon: "AlertTriangle",
+ title: "Host dispatch failed",
+ description: "Tap retry, or open another session.",
+ },
+};
diff --git a/apps/mobile/components/ui/aspect-ratio.stories.tsx b/apps/mobile/components/ui/aspect-ratio.stories.tsx
new file mode 100644
index 00000000000..535f9c54765
--- /dev/null
+++ b/apps/mobile/components/ui/aspect-ratio.stories.tsx
@@ -0,0 +1,47 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import { View } from "react-native";
+import { AspectRatio } from "@/components/ui/aspect-ratio";
+import { Text } from "@/components/ui/text";
+
+function AspectRatioShowcase({ ratio }: { ratio: number }) {
+ return (
+
+
+
+
+ {ratio.toFixed(3)} ratio
+
+
+
+
+ );
+}
+
+const meta: Meta = {
+ title: "Components/AspectRatio",
+ component: AspectRatioShowcase,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Constrain a container to a fixed width:height ratio. Used for image placeholders, banners, and any layout that needs a stable proportional box.",
+ },
+ },
+ },
+ args: { ratio: 16 / 9 },
+ argTypes: {
+ ratio: {
+ control: { type: "select" },
+ options: [16 / 9, 4 / 3, 1, 3 / 4, 9 / 16],
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Widescreen: Story = { args: { ratio: 16 / 9 } };
+export const Standard: Story = { args: { ratio: 4 / 3 } };
+export const Square: Story = { args: { ratio: 1 } };
+export const Portrait: Story = { args: { ratio: 3 / 4 } };
diff --git a/apps/mobile/components/ui/avatar.stories.tsx b/apps/mobile/components/ui/avatar.stories.tsx
new file mode 100644
index 00000000000..d073bdfe639
--- /dev/null
+++ b/apps/mobile/components/ui/avatar.stories.tsx
@@ -0,0 +1,93 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import { View } from "react-native";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { Text } from "@/components/ui/text";
+
+function AvatarShowcase({
+ src,
+ fallback,
+ size,
+}: {
+ src?: string;
+ fallback: string;
+ size: "sm" | "md" | "lg" | "xl";
+}) {
+ const sizeClass = {
+ sm: "size-6",
+ md: "size-8",
+ lg: "size-12",
+ xl: "size-16",
+ }[size];
+
+ return (
+
+
+ {src ? : null}
+
+
+ {fallback}
+
+
+
+
+ {size}
+
+
+ );
+}
+
+const meta: Meta = {
+ title: "Components/Avatar",
+ component: AvatarShowcase,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'Round avatar with optional image + fallback initial(s). Used for assistant message head ("A") and any user/identity surface. Sized variants supported via className.',
+ },
+ },
+ },
+ args: {
+ fallback: "A",
+ size: "md",
+ },
+ argTypes: {
+ src: { control: "text" },
+ fallback: { control: "text" },
+ size: {
+ control: { type: "select" },
+ options: ["sm", "md", "lg", "xl"],
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const FallbackOnly: Story = {
+ args: { fallback: "A" },
+};
+
+export const WithImage: Story = {
+ args: {
+ src: "https://i.pravatar.cc/100?img=12",
+ fallback: "JR",
+ },
+};
+
+export const Small: Story = { args: { size: "sm", fallback: "S" } };
+export const Large: Story = { args: { size: "lg", fallback: "L" } };
+export const ExtraLarge: Story = { args: { size: "xl", fallback: "XL" } };
+
+export const AssistantHead: Story = {
+ args: { fallback: "A", size: "md" },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "Canonical chat usage — left-side head on assistant message (UC-RENDER-01).",
+ },
+ },
+ },
+};
diff --git a/apps/mobile/components/ui/badge.stories.tsx b/apps/mobile/components/ui/badge.stories.tsx
new file mode 100644
index 00000000000..4144dcf61a9
--- /dev/null
+++ b/apps/mobile/components/ui/badge.stories.tsx
@@ -0,0 +1,108 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import { View } from "react-native";
+import { Badge } from "@/components/ui/badge";
+import { Text } from "@/components/ui/text";
+
+type Variant = "default" | "secondary" | "destructive" | "outline";
+
+function BadgeShowcase({
+ variant,
+ label,
+}: {
+ variant: Variant;
+ label: string;
+}) {
+ return (
+
+ {label}
+
+ );
+}
+
+const meta: Meta = {
+ title: "Components/Badge",
+ component: BadgeShowcase,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'Compact rounded label. Used for "new" tags on model options, `·N` filter count, "1 of N" approval counter. Pill component (chat-domain) handles different semantics — see Components/Pill.',
+ },
+ },
+ },
+ args: {
+ variant: "default",
+ label: "new",
+ },
+ argTypes: {
+ variant: {
+ control: { type: "select" },
+ options: ["default", "secondary", "destructive", "outline"],
+ },
+ label: { control: "text" },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const NewModelTag: Story = {
+ args: { label: "new" },
+ parameters: {
+ docs: {
+ description: {
+ story: "On Opus 4.7 row in the model picker popover (UC-COMP-04 §A).",
+ },
+ },
+ },
+};
+
+export const FilterCount: Story = {
+ args: { variant: "secondary", label: "3" },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "`·N` filter count on FilterButton when activeFilters.length ≥ 1.",
+ },
+ },
+ },
+};
+
+export const ApprovalCounter: Story = {
+ args: { variant: "outline", label: "1 of 3" },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "Multi-approval counter in PendingApprovalFooter (UC-PAUSE-01 §A).",
+ },
+ },
+ },
+};
+
+export const Destructive: Story = {
+ args: { variant: "destructive", label: "failed" },
+};
+
+export const AllVariants: Story = {
+ render: () => (
+
+
+ default
+
+
+ secondary
+
+
+ destructive
+
+
+ outline
+
+
+ ),
+};
diff --git a/apps/mobile/components/ui/button.stories.tsx b/apps/mobile/components/ui/button.stories.tsx
new file mode 100644
index 00000000000..7a251415c4d
--- /dev/null
+++ b/apps/mobile/components/ui/button.stories.tsx
@@ -0,0 +1,161 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import { Send } from "lucide-react-native";
+import { View } from "react-native";
+import { Button } from "@/components/ui/button";
+import { Icon } from "@/components/ui/icon";
+import { Text } from "@/components/ui/text";
+
+type Variant =
+ | "default"
+ | "destructive"
+ | "outline"
+ | "secondary"
+ | "ghost"
+ | "link";
+type Size = "default" | "sm" | "lg" | "icon";
+
+function ButtonShowcase({
+ variant,
+ size,
+ disabled,
+ label,
+ leadingIcon,
+}: {
+ variant: Variant;
+ size: Size;
+ disabled: boolean;
+ label: string;
+ leadingIcon: boolean;
+}) {
+ if (size === "icon") {
+ return (
+
+
+
+ );
+ }
+ return (
+
+ {leadingIcon ? : null}
+ {label}
+
+ );
+}
+
+const meta: Meta = {
+ title: "Components/Button",
+ component: ButtonShowcase,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Primary tappable action. 6 variants (default/destructive/outline/secondary/ghost/link) × 4 sizes (default/sm/lg/icon). Default fills with ember (--color-primary). Pressable from RN — long-press supported via consumer onLongPress.",
+ },
+ },
+ },
+ args: {
+ variant: "default",
+ size: "default",
+ disabled: false,
+ label: "Send",
+ leadingIcon: false,
+ },
+ argTypes: {
+ variant: {
+ control: { type: "select" },
+ options: [
+ "default",
+ "destructive",
+ "outline",
+ "secondary",
+ "ghost",
+ "link",
+ ],
+ },
+ size: {
+ control: { type: "select" },
+ options: ["default", "sm", "lg", "icon"],
+ },
+ disabled: { control: "boolean" },
+ label: { control: "text" },
+ leadingIcon: { control: "boolean" },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const Destructive: Story = {
+ args: { variant: "destructive", label: "Delete session" },
+};
+
+export const Outline: Story = {
+ args: { variant: "outline", label: "Cancel" },
+};
+
+export const Secondary: Story = {
+ args: { variant: "secondary", label: "Reject" },
+};
+
+export const Ghost: Story = {
+ args: { variant: "ghost", label: "Skip" },
+};
+
+export const Link: Story = {
+ args: { variant: "link", label: "Open in settings" },
+};
+
+export const WithLeadingIcon: Story = {
+ args: { leadingIcon: true, label: "Send" },
+};
+
+export const IconOnly: Story = {
+ args: { size: "icon" },
+ parameters: {
+ docs: {
+ description: {
+ story: "Square button for icon-only actions (Send/Stop/Close).",
+ },
+ },
+ },
+};
+
+export const SmallApprove: Story = {
+ args: { size: "sm", label: "Approve" },
+};
+
+export const LargePrimary: Story = {
+ args: { size: "lg", label: "Enable notifications" },
+};
+
+export const Disabled: Story = {
+ args: { disabled: true, label: "Send" },
+};
+
+export const AllVariants: Story = {
+ render: () => (
+
+
+ Default (ember)
+
+
+ Destructive
+
+
+ Outline
+
+
+ Secondary
+
+
+ Ghost
+
+
+ Link
+
+
+ ),
+};
diff --git a/apps/mobile/components/ui/card.stories.tsx b/apps/mobile/components/ui/card.stories.tsx
new file mode 100644
index 00000000000..730f2294068
--- /dev/null
+++ b/apps/mobile/components/ui/card.stories.tsx
@@ -0,0 +1,98 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import { View } from "react-native";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { Text } from "@/components/ui/text";
+
+function CardShowcase({
+ title,
+ description,
+ body,
+ showFooter,
+}: {
+ title: string;
+ description: string;
+ body: string;
+ showFooter: boolean;
+}) {
+ return (
+
+
+ {title}
+ {description}
+
+
+ {body}
+
+ {showFooter ? (
+
+
+
+ Cancel
+
+
+ Save
+
+
+
+ ) : null}
+
+ );
+}
+
+const meta: Meta = {
+ title: "Components/Card",
+ component: CardShowcase,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Elevated content container. Base for tool-call cards, pending-approval cards, info panels. Composes Header/Title/Description/Content/Footer sub-components.",
+ },
+ },
+ },
+ args: {
+ title: "Tool call",
+ description: "ReadFile · src/handlers/chat.ts",
+ body: "Reading file contents to determine the right insertion point...",
+ showFooter: false,
+ },
+ argTypes: {
+ title: { control: "text" },
+ description: { control: "text" },
+ body: { control: "text" },
+ showFooter: { control: "boolean" },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const WithFooter: Story = { args: { showFooter: true } };
+
+export const ToolCall: Story = {
+ args: {
+ title: "ReadFile",
+ description: "src/handlers/chat.ts · 1.2KB",
+ body: "Read 47 lines · returned 0 results",
+ showFooter: false,
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "Tool-call card base composition (UC-RENDER-04). Full ToolCallBlock molecule adds status rule + status icon.",
+ },
+ },
+ },
+};
diff --git a/apps/mobile/components/ui/checkbox.stories.tsx b/apps/mobile/components/ui/checkbox.stories.tsx
new file mode 100644
index 00000000000..72e0c9c735c
--- /dev/null
+++ b/apps/mobile/components/ui/checkbox.stories.tsx
@@ -0,0 +1,60 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import { useState } from "react";
+import { View } from "react-native";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Label } from "@/components/ui/label";
+
+function CheckboxShowcase({
+ initialChecked,
+ disabled,
+ label,
+}: {
+ initialChecked: boolean;
+ disabled: boolean;
+ label: string;
+}) {
+ const [checked, setChecked] = useState(initialChecked);
+ return (
+
+
+
+
+ );
+}
+
+const meta: Meta = {
+ title: "Components/Checkbox",
+ component: CheckboxShowcase,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Multi-select box. Used in workspace filter rows (UC-NAV-08) and inline markdown task lists.",
+ },
+ },
+ },
+ args: {
+ initialChecked: false,
+ disabled: false,
+ label: "main · macbook-pro",
+ },
+ argTypes: {
+ initialChecked: { control: "boolean" },
+ disabled: { control: "boolean" },
+ label: { control: "text" },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Unchecked: Story = {};
+export const Checked: Story = { args: { initialChecked: true } };
+export const Disabled: Story = {
+ args: { disabled: true, label: "main · macbook-pro (offline host)" },
+};
diff --git a/apps/mobile/components/ui/collapsible.stories.tsx b/apps/mobile/components/ui/collapsible.stories.tsx
new file mode 100644
index 00000000000..00f35fe51db
--- /dev/null
+++ b/apps/mobile/components/ui/collapsible.stories.tsx
@@ -0,0 +1,81 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import { ChevronDown, ChevronRight } from "lucide-react-native";
+import { useState } from "react";
+import { Pressable, View } from "react-native";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible";
+import { Icon } from "@/components/ui/icon";
+import { Text } from "@/components/ui/text";
+
+function CollapsibleShowcase({
+ initialOpen,
+ header,
+ body,
+}: {
+ initialOpen: boolean;
+ header: string;
+ body: string;
+}) {
+ const [open, setOpen] = useState(initialOpen);
+ return (
+
+
+
+
+ {header}
+
+
+
+
+
+ {body}
+
+
+
+
+ );
+}
+
+const meta: Meta = {
+ title: "Components/Collapsible",
+ component: CollapsibleShowcase,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Show/hide a content region with a header. Used by PlanBlock + ReasoningBlock (UC-RENDER-05), tool-call argument preview, expandable feedback in PlanReviewScreen.",
+ },
+ },
+ },
+ args: {
+ initialOpen: false,
+ header: "📦 Plan",
+ body: "1. Investigate the failing test\n2. Add a fix\n3. Re-run the suite",
+ },
+ argTypes: {
+ initialOpen: { control: "boolean" },
+ header: { control: "text" },
+ body: { control: "text" },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Collapsed: Story = {};
+export const Expanded: Story = { args: { initialOpen: true } };
+
+export const ReasoningBlock: Story = {
+ args: {
+ initialOpen: true,
+ header: "💭 Reasoning",
+ body: "The user mentioned the slash command popover keeps closing on focus loss. The issue is likely the BottomSheet capture phase swallowing the focus event...",
+ },
+};
diff --git a/apps/mobile/components/ui/context-menu.stories.tsx b/apps/mobile/components/ui/context-menu.stories.tsx
new file mode 100644
index 00000000000..5f161f71299
--- /dev/null
+++ b/apps/mobile/components/ui/context-menu.stories.tsx
@@ -0,0 +1,53 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import { View } from "react-native";
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuSeparator,
+ ContextMenuTrigger,
+} from "@/components/ui/context-menu";
+import { Text } from "@/components/ui/text";
+
+function ContextMenuShowcase() {
+ return (
+
+
+
+ Long-press this card
+
+
+
+
+ Rename
+
+
+ End session
+
+
+
+ Delete
+
+
+
+ );
+}
+
+const meta: Meta = {
+ title: "Components/ContextMenu",
+ component: ContextMenuShowcase,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Long-press action menu. Used for session-row long-press (Rename/End/Delete). Mobile uses native long-press; menu renders via portal.",
+ },
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const SessionRowActions: Story = {};
diff --git a/apps/mobile/components/ui/dialog.stories.tsx b/apps/mobile/components/ui/dialog.stories.tsx
new file mode 100644
index 00000000000..81ac3c1e33b
--- /dev/null
+++ b/apps/mobile/components/ui/dialog.stories.tsx
@@ -0,0 +1,80 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Text } from "@/components/ui/text";
+
+function DialogShowcase({
+ triggerLabel,
+ title,
+ description,
+ confirmLabel,
+}: {
+ triggerLabel: string;
+ title: string;
+ description: string;
+ confirmLabel: string;
+}) {
+ return (
+
+ );
+}
+
+const meta: Meta = {
+ title: "Components/Dialog",
+ component: DialogShowcase,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Non-destructive modal. Used for Rename session, settings forms. Has built-in X close affordance top-right (44pt hitSlop). Renders via portal.",
+ },
+ },
+ },
+ args: {
+ triggerLabel: "Rename session",
+ title: "Rename session",
+ description: "Choose a clear title that helps you find this session later.",
+ confirmLabel: "Save",
+ },
+ argTypes: {
+ triggerLabel: { control: "text" },
+ title: { control: "text" },
+ description: { control: "text" },
+ confirmLabel: { control: "text" },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const RenameSession: Story = {};
diff --git a/apps/mobile/components/ui/dropdown-menu.stories.tsx b/apps/mobile/components/ui/dropdown-menu.stories.tsx
new file mode 100644
index 00000000000..b23542bf724
--- /dev/null
+++ b/apps/mobile/components/ui/dropdown-menu.stories.tsx
@@ -0,0 +1,62 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import { MoreVertical } from "lucide-react-native";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Icon } from "@/components/ui/icon";
+import { Text } from "@/components/ui/text";
+
+function DropdownMenuShowcase() {
+ return (
+
+
+
+
+
+
+
+
+
+ Session actions
+
+
+
+
+ Rename
+
+
+ End session
+
+
+
+ Delete
+
+
+
+ );
+}
+
+const meta: Meta = {
+ title: "Components/DropdownMenu",
+ component: DropdownMenuShowcase,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Trigger-anchored menu. Used by session overflow `···` (UC-SESS-04 §A). Renders via portal.",
+ },
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const SessionOverflow: Story = {};
diff --git a/apps/mobile/components/ui/hover-card.stories.tsx b/apps/mobile/components/ui/hover-card.stories.tsx
new file mode 100644
index 00000000000..8d6f93c3808
--- /dev/null
+++ b/apps/mobile/components/ui/hover-card.stories.tsx
@@ -0,0 +1,45 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import { View } from "react-native";
+import {
+ HoverCard,
+ HoverCardContent,
+ HoverCardTrigger,
+} from "@/components/ui/hover-card";
+import { Text } from "@/components/ui/text";
+
+function HoverCardShowcase() {
+ return (
+
+
+ @justin
+
+
+
+ Justin Rich
+
+ Joined March 2025 · 12 active sessions
+
+
+
+
+ );
+}
+
+const meta: Meta = {
+ title: "Components/HoverCard",
+ component: HoverCardShowcase,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Hover/long-press preview card. On mobile, triggers via long-press (no hover). Used for mention preview, link preview. Renders via portal.",
+ },
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
diff --git a/apps/mobile/components/ui/icon.stories.tsx b/apps/mobile/components/ui/icon.stories.tsx
new file mode 100644
index 00000000000..9029d44fcde
--- /dev/null
+++ b/apps/mobile/components/ui/icon.stories.tsx
@@ -0,0 +1,134 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import {
+ AlertTriangle,
+ ArrowDown,
+ ArrowLeft,
+ ArrowUpRight,
+ Bell,
+ Check,
+ ChevronDown,
+ ChevronRight,
+ Circle,
+ Copy,
+ GitBranch,
+ Laptop,
+ type LucideIcon,
+ MoreVertical,
+ Package,
+ Send,
+ Settings,
+ Shield,
+ Sparkles,
+ Square,
+ WifiOff,
+ X,
+ Zap,
+} from "lucide-react-native";
+import { ScrollView, View } from "react-native";
+import { Icon } from "@/components/ui/icon";
+import { Text } from "@/components/ui/text";
+
+const CATALOG: { name: string; icon: LucideIcon; usage: string }[] = [
+ { name: "Send", icon: Send, usage: "Composer Send button" },
+ { name: "Square", icon: Square, usage: "Composer Stop button" },
+ { name: "X", icon: X, usage: "Close affordances" },
+ { name: "ArrowLeft", icon: ArrowLeft, usage: "Back navigation" },
+ { name: "MoreVertical", icon: MoreVertical, usage: "Session overflow ···" },
+ { name: "Copy", icon: Copy, usage: "Copy code block / message" },
+ { name: "Check", icon: Check, usage: "Approve / selected state" },
+ { name: "Circle", icon: Circle, usage: "Status dot base" },
+ {
+ name: "ChevronDown",
+ icon: ChevronDown,
+ usage: "Picker triggers · expanded",
+ },
+ { name: "ChevronRight", icon: ChevronRight, usage: "Collapsed sections" },
+ { name: "ArrowDown", icon: ArrowDown, usage: "Scroll-back FAB" },
+ { name: "ArrowUpRight", icon: ArrowUpRight, usage: "External link" },
+ { name: "Package", icon: Package, usage: "📦 Plan block" },
+ { name: "GitBranch", icon: GitBranch, usage: "🌿 Workspace branch" },
+ { name: "Laptop", icon: Laptop, usage: "💻 Host (desktop)" },
+ { name: "Bell", icon: Bell, usage: "🔔 Push notification prompt" },
+ { name: "Shield", icon: Shield, usage: "🔐 Permission mode" },
+ { name: "Zap", icon: Zap, usage: "⚡ Thinking level" },
+ { name: "Settings", icon: Settings, usage: "⚙ Filter button" },
+ { name: "WifiOff", icon: WifiOff, usage: "Host offline banner" },
+ { name: "AlertTriangle", icon: AlertTriangle, usage: "Warning banners" },
+ { name: "Sparkles", icon: Sparkles, usage: "AI / generated content" },
+];
+
+function IconCatalogShowcase({
+ colorClass,
+ sizeClass,
+}: {
+ colorClass: string;
+ sizeClass: string;
+}) {
+ return (
+
+
+ Click an icon name in the catalog to inspect. All icons are
+ lucide-react-native, themed via the Icon wrapper (size + color
+ className).
+
+
+ {CATALOG.map(({ name, icon, usage }) => (
+
+
+
+
+
+ {name}
+
+ {usage}
+
+
+
+ ))}
+
+
+ );
+}
+
+const meta: Meta = {
+ title: "Components/Icon",
+ component: IconCatalogShowcase,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Themed wrapper around lucide-react-native via withUniwind. Catalog covers chat-view icon usage. Add new icons by importing from lucide-react-native and passing via `as` prop — no per-icon wrappers needed.",
+ },
+ },
+ },
+ args: {
+ colorClass: "text-foreground",
+ sizeClass: "size-5",
+ },
+ argTypes: {
+ colorClass: {
+ control: { type: "select" },
+ options: [
+ "text-foreground",
+ "text-muted-foreground",
+ "text-primary",
+ "text-destructive",
+ "text-state-live-fg",
+ "text-state-warning-fg",
+ ],
+ },
+ sizeClass: {
+ control: { type: "select" },
+ options: ["size-3", "size-4", "size-5", "size-6", "size-8"],
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Catalog: Story = {};
+export const Ember: Story = { args: { colorClass: "text-primary" } };
+export const Large: Story = { args: { sizeClass: "size-8" } };
+export const Live: Story = { args: { colorClass: "text-state-live-fg" } };
diff --git a/apps/mobile/components/ui/input.stories.tsx b/apps/mobile/components/ui/input.stories.tsx
new file mode 100644
index 00000000000..d1d3293f9ae
--- /dev/null
+++ b/apps/mobile/components/ui/input.stories.tsx
@@ -0,0 +1,86 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import { useState } from "react";
+import { View } from "react-native";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+
+function InputShowcase({
+ placeholder,
+ editable,
+ initialValue,
+ showLabel,
+}: {
+ placeholder: string;
+ editable: boolean;
+ initialValue: string;
+ showLabel: boolean;
+}) {
+ const [value, setValue] = useState(initialValue);
+ return (
+
+ {showLabel ? : null}
+
+
+ );
+}
+
+const meta: Meta = {
+ title: "Components/Input",
+ component: InputShowcase,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Single-line text input. Used by SessionSearchBar (UC-NAV-07), plan-review feedback field (UC-PAUSE-03).",
+ },
+ },
+ },
+ args: {
+ placeholder: "Search this project's sessions",
+ editable: true,
+ initialValue: "",
+ showLabel: false,
+ },
+ argTypes: {
+ placeholder: { control: "text" },
+ editable: { control: "boolean" },
+ initialValue: { control: "text" },
+ showLabel: { control: "boolean" },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Empty: Story = {};
+
+export const WithValue: Story = {
+ args: { initialValue: "host-service refactor" },
+};
+
+export const WithLabel: Story = { args: { showLabel: true } };
+
+export const Disabled: Story = {
+ args: { editable: false, initialValue: "Cannot edit" },
+};
+
+export const FeedbackField: Story = {
+ args: {
+ showLabel: true,
+ placeholder: "Add feedback...",
+ initialValue: "",
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: "Plan-review feedback input (UC-PAUSE-03).",
+ },
+ },
+ },
+};
diff --git a/apps/mobile/components/ui/label.stories.tsx b/apps/mobile/components/ui/label.stories.tsx
new file mode 100644
index 00000000000..37b16aabfd1
--- /dev/null
+++ b/apps/mobile/components/ui/label.stories.tsx
@@ -0,0 +1,47 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import { View } from "react-native";
+import { Label } from "@/components/ui/label";
+
+function LabelShowcase({
+ text,
+ disabled,
+}: {
+ text: string;
+ disabled: boolean;
+}) {
+ return (
+
+
+
+ );
+}
+
+const meta: Meta = {
+ title: "Components/Label",
+ component: LabelShowcase,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Form-control label — typically pairs with an Input, Switch, RadioGroup, or Checkbox. Reads --color-foreground with disabled opacity treatment.",
+ },
+ },
+ },
+ args: {
+ text: "Workspace name",
+ disabled: false,
+ },
+ argTypes: {
+ text: { control: "text" },
+ disabled: { control: "boolean" },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
+export const Disabled: Story = {
+ args: { disabled: true, text: "Workspace (locked)" },
+};
diff --git a/apps/mobile/components/ui/menubar.stories.tsx b/apps/mobile/components/ui/menubar.stories.tsx
new file mode 100644
index 00000000000..3720da0a8d2
--- /dev/null
+++ b/apps/mobile/components/ui/menubar.stories.tsx
@@ -0,0 +1,68 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import { useState } from "react";
+import {
+ Menubar,
+ MenubarContent,
+ MenubarItem,
+ MenubarMenu,
+ MenubarSeparator,
+ MenubarTrigger,
+} from "@/components/ui/menubar";
+import { Text } from "@/components/ui/text";
+
+function MenubarShowcase() {
+ const [open, setOpen] = useState(undefined);
+ return (
+
+
+
+ File
+
+
+
+ New session
+
+
+ Open recent
+
+
+
+ Sign out
+
+
+
+
+
+ Edit
+
+
+
+ Undo
+
+
+ Redo
+
+
+
+
+ );
+}
+
+const meta: Meta = {
+ title: "Components/Menubar",
+ component: MenubarShowcase,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Multi-trigger menu bar. Primarily a desktop pattern — included for parity with shadcn ecosystem; mobile chat does not currently use this surface.",
+ },
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
diff --git a/apps/mobile/components/ui/popover.stories.tsx b/apps/mobile/components/ui/popover.stories.tsx
new file mode 100644
index 00000000000..039eb78f10e
--- /dev/null
+++ b/apps/mobile/components/ui/popover.stories.tsx
@@ -0,0 +1,64 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import { View } from "react-native";
+import { Button } from "@/components/ui/button";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { Text } from "@/components/ui/text";
+
+function PopoverShowcase({
+ triggerLabel,
+ side,
+}: {
+ triggerLabel: string;
+ side: "top" | "bottom";
+}) {
+ return (
+
+
+
+ {triggerLabel}
+
+
+
+
+ Model picker
+
+ Opus 4.7 · Sonnet 4.6 · Haiku 4.5 · GPT-5.5
+
+
+
+
+ );
+}
+
+const meta: Meta = {
+ title: "Components/Popover",
+ component: PopoverShowcase,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Anchored floating panel. Base for slash-command-popover (UC-COMP-01 §C), model picker (UC-COMP-04), thinking-level picker (UC-COMP-05). Renders via portal.",
+ },
+ },
+ },
+ args: { triggerLabel: "Sonnet 4.6 ▾", side: "top" },
+ argTypes: {
+ triggerLabel: { control: "text" },
+ side: {
+ control: { type: "select" },
+ options: ["top", "bottom"],
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
+export const Above: Story = { args: { side: "top" } };
+export const Below: Story = { args: { side: "bottom" } };
diff --git a/apps/mobile/components/ui/progress.stories.tsx b/apps/mobile/components/ui/progress.stories.tsx
new file mode 100644
index 00000000000..b469fcedff3
--- /dev/null
+++ b/apps/mobile/components/ui/progress.stories.tsx
@@ -0,0 +1,71 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import { useEffect, useState } from "react";
+import { View } from "react-native";
+import { Progress } from "@/components/ui/progress";
+import { Text } from "@/components/ui/text";
+
+function ProgressShowcase({
+ value,
+ animated,
+}: {
+ value: number;
+ animated: boolean;
+}) {
+ const [v, setV] = useState(value);
+ useEffect(() => {
+ if (!animated) {
+ setV(value);
+ return;
+ }
+ setV(0);
+ const id = setInterval(() => {
+ setV((current) => {
+ if (current >= 100) return 0;
+ return current + 5;
+ });
+ }, 200);
+ return () => clearInterval(id);
+ }, [animated, value]);
+
+ return (
+
+
+
+ {Math.round(v)}%
+
+
+ );
+}
+
+const meta: Meta = {
+ title: "Components/Progress",
+ component: ProgressShowcase,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Linear progress bar. Used during slash-command preview loading (UC-COMP-01 §C), long-running tool calls. Spring-animated on native.",
+ },
+ },
+ },
+ args: {
+ value: 35,
+ animated: false,
+ },
+ argTypes: {
+ value: { control: { type: "range", min: 0, max: 100, step: 5 } },
+ animated: {
+ control: "boolean",
+ description: "Auto-cycles 0→100 on a 4s loop for animation inspection",
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Static: Story = {};
+export const Half: Story = { args: { value: 50 } };
+export const Full: Story = { args: { value: 100 } };
+export const Animating: Story = { args: { animated: true } };
diff --git a/apps/mobile/components/ui/radio-group.stories.tsx b/apps/mobile/components/ui/radio-group.stories.tsx
new file mode 100644
index 00000000000..12f75673b76
--- /dev/null
+++ b/apps/mobile/components/ui/radio-group.stories.tsx
@@ -0,0 +1,83 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import { useState } from "react";
+import { View } from "react-native";
+import { Label } from "@/components/ui/label";
+import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
+import { Text } from "@/components/ui/text";
+
+function RadioGroupShowcase({
+ options,
+ initialValue,
+}: {
+ options: { value: string; label: string; meta?: string }[];
+ initialValue: string;
+}) {
+ const [value, setValue] = useState(initialValue);
+ return (
+
+ {options.map((opt) => (
+
+
+
+
+ {opt.meta ? (
+
+ {opt.meta}
+
+ ) : null}
+
+
+ ))}
+
+ );
+}
+
+const meta: Meta = {
+ title: "Components/RadioGroup",
+ component: RadioGroupShowcase,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Single-select picker. Used for model picker rows (UC-COMP-04), thinking-level rows (UC-COMP-05), permission-mode rows.",
+ },
+ },
+ },
+ args: {
+ options: [
+ {
+ value: "low",
+ label: "Low",
+ meta: "~1K tokens · fastest, less reasoning depth",
+ },
+ {
+ value: "medium",
+ label: "Medium",
+ meta: "~4K tokens · balanced",
+ },
+ {
+ value: "high",
+ label: "High",
+ meta: "~12K tokens · deep reasoning, slower",
+ },
+ {
+ value: "xhigh",
+ label: "X-High",
+ meta: "~32K tokens · maximum reasoning",
+ },
+ ],
+ initialValue: "medium",
+ },
+ argTypes: {
+ initialValue: {
+ control: { type: "select" },
+ options: ["low", "medium", "high", "xhigh"],
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const ThinkingLevels: Story = {};
diff --git a/apps/mobile/components/ui/select.stories.tsx b/apps/mobile/components/ui/select.stories.tsx
new file mode 100644
index 00000000000..988661d82e0
--- /dev/null
+++ b/apps/mobile/components/ui/select.stories.tsx
@@ -0,0 +1,59 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+
+function SelectShowcase() {
+ return (
+
+ );
+}
+
+const meta: Meta = {
+ title: "Components/Select",
+ component: SelectShowcase,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Native-feeling select dropdown. Used for inline form selects in settings. Composer model picker uses Popover + radio rows for visual richness instead — but Select is appropriate for simple key-value choices.",
+ },
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const ModelPicker: Story = {};
diff --git a/apps/mobile/components/ui/separator.stories.tsx b/apps/mobile/components/ui/separator.stories.tsx
new file mode 100644
index 00000000000..d56b60700ab
--- /dev/null
+++ b/apps/mobile/components/ui/separator.stories.tsx
@@ -0,0 +1,58 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import { View } from "react-native";
+import { Separator } from "@/components/ui/separator";
+import { Text } from "@/components/ui/text";
+
+function SeparatorShowcase({
+ orientation,
+}: {
+ orientation: "horizontal" | "vertical";
+}) {
+ if (orientation === "vertical") {
+ return (
+
+ Left
+
+ Right
+
+ );
+ }
+ return (
+
+ Above the line
+
+
+ Below the line
+
+
+ );
+}
+
+const meta: Meta = {
+ title: "Components/Separator",
+ component: SeparatorShowcase,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Hairline divider — 1px line using --color-border. Horizontal (default) or vertical. Used in popovers, message gaps, sheet sections.",
+ },
+ },
+ },
+ args: {
+ orientation: "horizontal",
+ },
+ argTypes: {
+ orientation: {
+ control: { type: "select" },
+ options: ["horizontal", "vertical"],
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Horizontal: Story = {};
+export const Vertical: Story = { args: { orientation: "vertical" } };
diff --git a/apps/mobile/components/ui/skeleton.stories.tsx b/apps/mobile/components/ui/skeleton.stories.tsx
new file mode 100644
index 00000000000..820d969d0dc
--- /dev/null
+++ b/apps/mobile/components/ui/skeleton.stories.tsx
@@ -0,0 +1,83 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import { View } from "react-native";
+import { Skeleton } from "@/components/ui/skeleton";
+
+function SkeletonShowcase({
+ pattern,
+}: {
+ pattern: "single" | "message-list" | "row";
+}) {
+ if (pattern === "single") {
+ return ;
+ }
+ if (pattern === "row") {
+ return (
+
+
+
+
+
+
+
+ );
+ }
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+const meta: Meta = {
+ title: "Components/Skeleton",
+ component: SkeletonShowcase,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Loading placeholder with pulsing opacity (Reanimated). Used during chat history fetch (UC-SESS-02 §A), avatar load, and any deferred content.",
+ },
+ },
+ },
+ args: {
+ pattern: "single",
+ },
+ argTypes: {
+ pattern: {
+ control: { type: "select" },
+ options: ["single", "row", "message-list"],
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const SingleBar: Story = { args: { pattern: "single" } };
+export const RowWithAvatar: Story = { args: { pattern: "row" } };
+export const ChatHistoryLoading: Story = {
+ args: { pattern: "message-list" },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "Skeleton variant for UC-SESS-02 §A — alternating bubble shapes mimicking user/assistant pattern.",
+ },
+ },
+ },
+};
diff --git a/apps/mobile/components/ui/switch.stories.tsx b/apps/mobile/components/ui/switch.stories.tsx
new file mode 100644
index 00000000000..220655a49d1
--- /dev/null
+++ b/apps/mobile/components/ui/switch.stories.tsx
@@ -0,0 +1,63 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import { useState } from "react";
+import { View } from "react-native";
+import { Label } from "@/components/ui/label";
+import { Switch } from "@/components/ui/switch";
+
+function SwitchShowcase({
+ initialChecked,
+ disabled,
+ label,
+}: {
+ initialChecked: boolean;
+ disabled: boolean;
+ label: string;
+}) {
+ const [checked, setChecked] = useState(initialChecked);
+ return (
+
+
+
+
+ );
+}
+
+const meta: Meta = {
+ title: "Components/Switch",
+ component: SwitchShowcase,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Boolean toggle. Used in settings rows (Re-enable in Settings, notification preferences). Thumb translates 14px on check; bg flips to --color-primary (ember) when on.",
+ },
+ },
+ },
+ args: {
+ initialChecked: false,
+ disabled: false,
+ label: "Push notifications",
+ },
+ argTypes: {
+ initialChecked: { control: "boolean" },
+ disabled: { control: "boolean" },
+ label: { control: "text" },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Off: Story = {};
+export const On: Story = { args: { initialChecked: true } };
+export const DisabledOff: Story = {
+ args: { disabled: true, label: "Notifications (locked by admin)" },
+};
+export const DisabledOn: Story = {
+ args: { disabled: true, initialChecked: true, label: "Always pinned" },
+};
diff --git a/apps/mobile/components/ui/tabs.stories.tsx b/apps/mobile/components/ui/tabs.stories.tsx
new file mode 100644
index 00000000000..680b772e5e8
--- /dev/null
+++ b/apps/mobile/components/ui/tabs.stories.tsx
@@ -0,0 +1,64 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import { useState } from "react";
+import { View } from "react-native";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Text } from "@/components/ui/text";
+
+function TabsShowcase() {
+ const [value, setValue] = useState("streaming");
+ return (
+
+
+
+ Streaming
+
+
+ Pause pending
+
+
+ Idle
+
+
+
+
+
+ 3 sessions currently streaming.
+
+
+
+
+
+
+ 1 session waiting for input.
+
+
+
+
+
+
+ 12 idle sessions.
+
+
+
+
+ );
+}
+
+const meta: Meta = {
+ title: "Components/Tabs",
+ component: TabsShowcase,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Tab segment + content. Used for inline status filtering, settings categories.",
+ },
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const SessionStatusFilter: Story = {};
diff --git a/apps/mobile/components/ui/text.stories.tsx b/apps/mobile/components/ui/text.stories.tsx
new file mode 100644
index 00000000000..456cb22874a
--- /dev/null
+++ b/apps/mobile/components/ui/text.stories.tsx
@@ -0,0 +1,96 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import { View } from "react-native";
+import { Text } from "@/components/ui/text";
+
+type Variant =
+ | "default"
+ | "h1"
+ | "h2"
+ | "h3"
+ | "h4"
+ | "p"
+ | "blockquote"
+ | "code"
+ | "lead"
+ | "large"
+ | "small"
+ | "muted";
+
+function TextShowcase({
+ variant,
+ children,
+}: {
+ variant: Variant;
+ children: string;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+const meta: Meta = {
+ title: "Components/Text",
+ component: TextShowcase,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Typed text primitive with 12 variants (default, h1-h4, p, blockquote, code, lead, large, small, muted). The catalog view of all variants is in Design System/Typography — this story is for per-variant inspection.",
+ },
+ },
+ },
+ args: {
+ variant: "default",
+ children: "The quick brown fox jumps over the lazy dog.",
+ },
+ argTypes: {
+ variant: {
+ control: { type: "select" },
+ options: [
+ "default",
+ "h1",
+ "h2",
+ "h3",
+ "h4",
+ "p",
+ "blockquote",
+ "code",
+ "lead",
+ "large",
+ "small",
+ "muted",
+ ],
+ },
+ children: { control: "text" },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
+export const H1: Story = { args: { variant: "h1", children: "Page title" } };
+export const H2: Story = { args: { variant: "h2", children: "Section" } };
+export const H3: Story = { args: { variant: "h3", children: "Sub-section" } };
+export const Paragraph: Story = {
+ args: {
+ variant: "p",
+ children:
+ "Multi-sentence paragraph with leading-7 spacing. Used inside markdown rendering and any prose region.",
+ },
+};
+export const Code: Story = {
+ args: { variant: "code", children: "const ember = '#e07850';" },
+};
+export const Muted: Story = {
+ args: { variant: "muted", children: "Secondary metadata text" },
+};
+export const Blockquote: Story = {
+ args: {
+ variant: "blockquote",
+ children: "Italic blockquote with left border.",
+ },
+};
diff --git a/apps/mobile/components/ui/textarea.stories.tsx b/apps/mobile/components/ui/textarea.stories.tsx
new file mode 100644
index 00000000000..b21cb18cb47
--- /dev/null
+++ b/apps/mobile/components/ui/textarea.stories.tsx
@@ -0,0 +1,88 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import { useState } from "react";
+import { View } from "react-native";
+import { Textarea } from "@/components/ui/textarea";
+
+function TextareaShowcase({
+ placeholder,
+ editable,
+ initialValue,
+ numberOfLines,
+}: {
+ placeholder: string;
+ editable: boolean;
+ initialValue: string;
+ numberOfLines: number;
+}) {
+ const [value, setValue] = useState(initialValue);
+ return (
+
+
+
+ );
+}
+
+const meta: Meta = {
+ title: "Components/Textarea",
+ component: TextareaShowcase,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Multi-line text input — composer body, ask_user BottomSheetTextInput, plan-review feedback. Autogrows up to numberOfLines (max height) on native.",
+ },
+ },
+ },
+ args: {
+ placeholder: "Send a message...",
+ editable: true,
+ initialValue: "",
+ numberOfLines: 8,
+ },
+ argTypes: {
+ placeholder: { control: "text" },
+ editable: { control: "boolean" },
+ initialValue: { control: "text" },
+ numberOfLines: { control: { type: "number", min: 1, max: 20 } },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Empty: Story = {};
+
+export const WithValue: Story = {
+ args: {
+ initialValue:
+ "Please review the chat-mobile-sprint-1 worktree and confirm the ember tokens are wired through correctly. Pay particular attention to dark-mode contrast on the assistant message bubble.",
+ },
+};
+
+export const Composer: Story = {
+ args: { placeholder: "Send a message..." },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "Empty composer body (UC-COMP-01 §A). Real composer composes Textarea + SlashCommandNode + FileMentionNode via Tiptap WebView.",
+ },
+ },
+ },
+};
+
+export const Disabled: Story = {
+ args: { editable: false, initialValue: "Streaming — input disabled" },
+ parameters: {
+ docs: {
+ description: { story: "Disabled state during streaming (UC-COMP-03)." },
+ },
+ },
+};
diff --git a/apps/mobile/components/ui/toggle-group.stories.tsx b/apps/mobile/components/ui/toggle-group.stories.tsx
new file mode 100644
index 00000000000..466b1ef8a24
--- /dev/null
+++ b/apps/mobile/components/ui/toggle-group.stories.tsx
@@ -0,0 +1,61 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import { useState } from "react";
+import { Text } from "@/components/ui/text";
+import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
+
+function ToggleGroupShowcase({ type }: { type: "single" | "multiple" }) {
+ const [single, setSingle] = useState("medium");
+ const [multi, setMulti] = useState(["streaming"]);
+ if (type === "single") {
+ return (
+
+
+ Low
+
+
+ Medium
+
+
+ High
+
+
+ );
+ }
+ return (
+
+
+ Streaming
+
+
+ Pause
+
+
+ Idle
+
+
+ );
+}
+
+const meta: Meta = {
+ title: "Components/ToggleGroup",
+ component: ToggleGroupShowcase,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Segmented control. Single (radio-style) or multiple (checkbox-style). Status filter in SessionFilterSheet uses this pattern.",
+ },
+ },
+ },
+ args: { type: "single" },
+ argTypes: {
+ type: { control: { type: "select" }, options: ["single", "multiple"] },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Single: Story = {};
+export const Multiple: Story = { args: { type: "multiple" } };
diff --git a/apps/mobile/components/ui/toggle.stories.tsx b/apps/mobile/components/ui/toggle.stories.tsx
new file mode 100644
index 00000000000..85fd7688561
--- /dev/null
+++ b/apps/mobile/components/ui/toggle.stories.tsx
@@ -0,0 +1,62 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import { Bold } from "lucide-react-native";
+import { useState } from "react";
+import { Text } from "@/components/ui/text";
+import { Toggle, ToggleIcon } from "@/components/ui/toggle";
+
+function ToggleShowcase({
+ variant,
+ withIcon,
+ label,
+ initialPressed,
+}: {
+ variant: "default" | "outline";
+ withIcon: boolean;
+ label: string;
+ initialPressed: boolean;
+}) {
+ const [pressed, setPressed] = useState(initialPressed);
+ return (
+
+ {withIcon ? : null}
+ {label}
+
+ );
+}
+
+const meta: Meta = {
+ title: "Components/Toggle",
+ component: ToggleShowcase,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Single on/off button. Single-cell variant of ToggleGroup. Useful for icon-style boolean controls (favorite, pin, etc.).",
+ },
+ },
+ },
+ args: {
+ variant: "default",
+ withIcon: false,
+ label: "Bold",
+ initialPressed: false,
+ },
+ argTypes: {
+ variant: {
+ control: { type: "select" },
+ options: ["default", "outline"],
+ },
+ withIcon: { control: "boolean" },
+ label: { control: "text" },
+ initialPressed: { control: "boolean" },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
+export const Pressed: Story = { args: { initialPressed: true } };
+export const WithIcon: Story = { args: { withIcon: true } };
+export const Outline: Story = { args: { variant: "outline" } };
diff --git a/apps/mobile/components/ui/tooltip.stories.tsx b/apps/mobile/components/ui/tooltip.stories.tsx
new file mode 100644
index 00000000000..451a44dea22
--- /dev/null
+++ b/apps/mobile/components/ui/tooltip.stories.tsx
@@ -0,0 +1,55 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import { Info } from "lucide-react-native";
+import { Icon } from "@/components/ui/icon";
+import { Text } from "@/components/ui/text";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+
+function TooltipShowcase({
+ side,
+ content,
+}: {
+ side: "top" | "bottom";
+ content: string;
+}) {
+ return (
+
+
+
+
+
+ {content}
+
+
+ );
+}
+
+const meta: Meta = {
+ title: "Components/Tooltip",
+ component: TooltipShowcase,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Long-press hint on mobile. Used sparingly — chat view's long-press affordances mostly use ContextMenu (system menu) instead. Renders via portal.",
+ },
+ },
+ },
+ args: {
+ side: "top",
+ content: "Sessions are scoped to the selected project.",
+ },
+ argTypes: {
+ side: { control: { type: "select" }, options: ["top", "bottom"] },
+ content: { control: "text" },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js
index beb8c9486c1..7a56f770a2b 100644
--- a/apps/mobile/metro.config.js
+++ b/apps/mobile/metro.config.js
@@ -1,5 +1,6 @@
const { getDefaultConfig } = require("expo/metro-config");
const { withUniwindConfig } = require("uniwind/metro");
+const withStorybook = require("@storybook/react-native/metro/withStorybook");
const path = require("node:path");
const projectRoot = __dirname;
@@ -7,24 +8,25 @@ const monorepoRoot = path.resolve(projectRoot, "../..");
const config = getDefaultConfig(projectRoot);
-// Watch all files in the monorepo
config.watchFolders = [monorepoRoot];
-// Let Metro find modules from the monorepo root
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, "node_modules"),
path.resolve(monorepoRoot, "node_modules"),
];
-// Enable package exports for better-auth
config.resolver.unstable_enablePackageExports = true;
-// Resolve local Expo Modules (modules/ dir)
config.resolver.extraNodeModules = {
"@superset/tab-bar": path.resolve(projectRoot, "modules/tab-bar"),
};
-module.exports = withUniwindConfig(config, {
+const uniwindConfig = withUniwindConfig(config, {
cssEntryFile: "./global.css",
dtsFile: "./uniwind-types.d.ts",
});
+
+module.exports = withStorybook(uniwindConfig, {
+ configPath: path.resolve(projectRoot, ".rnstorybook"),
+ enabled: process.env.EXPO_PUBLIC_STORYBOOK === "true",
+});
diff --git a/apps/mobile/package.json b/apps/mobile/package.json
index 9f30ca4c65f..feac258e719 100644
--- a/apps/mobile/package.json
+++ b/apps/mobile/package.json
@@ -7,6 +7,8 @@
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
+ "storybook": "sb-rn-get-stories --config-path .rnstorybook && EXPO_PUBLIC_STORYBOOK=true expo start",
+ "storybook-generate": "sb-rn-get-stories --config-path .rnstorybook",
"typecheck": "tsc --noEmit",
"lint": "biome check .",
"lint:fix": "biome check --write ."
@@ -14,6 +16,8 @@
"dependencies": {
"@better-auth/expo": "1.6.5",
"@electric-sql/client": "1.5.15",
+ "@expo-google-fonts/geist": "0.4.2",
+ "@expo-google-fonts/geist-mono": "0.4.2",
"@expo/ui": "56.0.12",
"@expo/vector-icons": "15.1.1",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -61,13 +65,16 @@
"expo-dev-client": "56.0.14",
"expo-device": "56.0.4",
"expo-file-system": "56.0.7",
+ "expo-font": "56.0.0",
"expo-glass-effect": "56.0.4",
"expo-image": "56.0.8",
+ "expo-linear-gradient": "56.0.4",
"expo-linking": "56.0.11",
"expo-localization": "56.0.6",
"expo-network": "56.0.4",
"expo-router": "56.2.5",
"expo-secure-store": "56.0.4",
+ "expo-splash-screen": "56.0.0",
"expo-status-bar": "56.0.4",
"expo-system-ui": "56.0.5",
"expo-web-browser": "56.0.5",
@@ -90,10 +97,14 @@
"zod": "4.3.6"
},
"devDependencies": {
+ "@storybook/addon-ondevice-actions": "^9",
+ "@storybook/addon-ondevice-controls": "^9",
+ "@storybook/react-native": "^9",
"@types/node": "24.12.0",
"@types/react": "19.2.14",
"babel-preset-expo": "56.0.11",
"expo-mcp": "0.2.4",
+ "storybook": "^9",
"typescript": "5.9.3"
},
"private": true
diff --git a/apps/mobile/screens/(auth)/sign-in/components/DevSignInButton/DevSignInButton.stories.tsx b/apps/mobile/screens/(auth)/sign-in/components/DevSignInButton/DevSignInButton.stories.tsx
new file mode 100644
index 00000000000..d62d7c43316
--- /dev/null
+++ b/apps/mobile/screens/(auth)/sign-in/components/DevSignInButton/DevSignInButton.stories.tsx
@@ -0,0 +1,21 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import { DevSignInButton } from "./DevSignInButton";
+
+const meta: Meta = {
+ title: "Components/DevSignInButton",
+ component: DevSignInButton,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Dev-only sign-in shortcut on the sign-in screen — bypasses OAuth and signs in as Local Admin (admin@local.test) for local development. Real auth call happens on press; in Storybook the button still calls into auth-client which will error harmlessly without a configured backend. Token-clean (uses Button outline variant + `text-destructive` for error). Per screens/AUDIT.md.",
+ },
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
diff --git a/apps/mobile/screens/(auth)/sign-in/components/SocialButton/SocialButton.stories.tsx b/apps/mobile/screens/(auth)/sign-in/components/SocialButton/SocialButton.stories.tsx
new file mode 100644
index 00000000000..7172475bef0
--- /dev/null
+++ b/apps/mobile/screens/(auth)/sign-in/components/SocialButton/SocialButton.stories.tsx
@@ -0,0 +1,52 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import { View } from "react-native";
+import { SocialButton } from "./SocialButton";
+
+const meta: Meta = {
+ title: "Components/SocialButton",
+ component: SocialButton,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "OAuth provider button. Outline button + brand-colored SVG icon. GitHub icon uses `theme.foreground` (token-driven, fixed in audit 2026-05-22 — was previously hardcoded white/black via useColorScheme). Google icon uses official brand hex colors per Google branding requirements — documented exception in screens/AUDIT.md.",
+ },
+ },
+ },
+ args: {
+ provider: "github",
+ disabled: false,
+ },
+ argTypes: {
+ provider: {
+ control: { type: "select" },
+ options: ["github", "google"],
+ },
+ disabled: { control: "boolean" },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const GitHub: Story = { args: { provider: "github" } };
+export const Google: Story = { args: { provider: "google" } };
+export const Disabled: Story = { args: { disabled: true } };
+
+export const BothProviders: Story = {
+ render: () => (
+
+
+
+
+ ),
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "Canonical sign-in screen composition — both providers stacked vertically.",
+ },
+ },
+ },
+};
diff --git a/apps/mobile/screens/(auth)/sign-in/components/SocialButton/SocialButton.tsx b/apps/mobile/screens/(auth)/sign-in/components/SocialButton/SocialButton.tsx
index 6d9f02a5bbf..a05a7ee8a64 100644
--- a/apps/mobile/screens/(auth)/sign-in/components/SocialButton/SocialButton.tsx
+++ b/apps/mobile/screens/(auth)/sign-in/components/SocialButton/SocialButton.tsx
@@ -1,8 +1,8 @@
-import { useColorScheme } from "react-native";
import Svg, { Path } from "react-native-svg";
import type { ButtonProps } from "@/components/ui/button";
import { Button } from "@/components/ui/button";
import { Text } from "@/components/ui/text";
+import { useTheme } from "@/hooks/useTheme";
import { cn } from "@/lib/utils";
export type SocialProvider = "github" | "google";
@@ -55,8 +55,10 @@ export function SocialButton({
className,
...props
}: SocialButtonProps) {
- const colorScheme = useColorScheme();
- const iconColor = colorScheme === "dark" ? "white" : "black";
+ // Use the active app theme (not system colorScheme) so the icon color tracks
+ // our Uniwind theme rather than the OS appearance setting.
+ const theme = useTheme();
+ const iconColor = theme.foreground;
return (
`
+ * placement, which does not render outside of an expo-router navigator context.
+ *
+ * This story renders the *inner content* of the toolbar button using the same
+ * primitives and tokens — `OrganizationAvatar` + Text + `ChevronsUpDown`. The
+ * inner layout is the audit surface; the toolbar wrapper is a positional
+ * affordance that lives in the real navigation host.
+ */
+function OrganizationHeaderButtonPreview({
+ name,
+ logo,
+ onPress,
+}: {
+ name?: string;
+ logo?: string | null;
+ onPress: () => void;
+}) {
+ const theme = useTheme();
+ return (
+
+
+
+
+ {name ?? "Organization"}
+
+
+
+
+ );
+}
+
+const meta: Meta = {
+ title: "Components/OrganizationHeaderButton",
+ component: OrganizationHeaderButtonPreview,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Header pressable for the workspaces screen — opens the organization switcher sheet. **Renders inside expo-router Stack.Toolbar in the real app**; this story renders the inner content only. Fixed in audit 2026-05-22 — chevron color was previously hardcoded `hsl(240 5% 64.9%)` (old cool-neutral); now resolves via `theme.mutedForeground`.",
+ },
+ },
+ },
+ args: {
+ name: "Superset",
+ logo: null,
+ onPress: () => undefined,
+ },
+ argTypes: {
+ name: { control: "text" },
+ logo: { control: "text" },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
+export const LongName: Story = {
+ args: { name: "Acme Long Organization Name LLC" },
+};
+export const WithLogo: Story = {
+ args: { name: "Vercel", logo: "https://logo.clearbit.com/vercel.com" },
+};
+export const NoName: Story = { args: { name: undefined } };
diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/OrganizationHeaderButton/OrganizationHeaderButton.tsx b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/OrganizationHeaderButton/OrganizationHeaderButton.tsx
index 8e200db8965..a97e4079e17 100644
--- a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/OrganizationHeaderButton/OrganizationHeaderButton.tsx
+++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/OrganizationHeaderButton/OrganizationHeaderButton.tsx
@@ -2,6 +2,7 @@ import { Stack } from "expo-router";
import { ChevronsUpDown } from "lucide-react-native";
import { Pressable } from "react-native";
import { Text } from "@/components/ui/text";
+import { useTheme } from "@/hooks/useTheme";
import { OrganizationAvatar } from "../OrganizationSwitcherSheet/components/OrganizationAvatar";
export function OrganizationHeaderButton({
@@ -13,6 +14,7 @@ export function OrganizationHeaderButton({
logo?: string | null;
onPress: () => void;
}) {
+ const theme = useTheme();
return (
<>
@@ -22,7 +24,7 @@ export function OrganizationHeaderButton({
{name ?? "Organization"}
-
+
diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/OrganizationSwitcherSheet/OrganizationSwitcherSheet.stories.tsx b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/OrganizationSwitcherSheet/OrganizationSwitcherSheet.stories.tsx
new file mode 100644
index 00000000000..4f6536061be
--- /dev/null
+++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/OrganizationSwitcherSheet/OrganizationSwitcherSheet.stories.tsx
@@ -0,0 +1,164 @@
+import Ionicons from "@expo/vector-icons/Ionicons";
+import type { Meta, StoryObj } from "@storybook/react-native";
+import { Platform, Pressable, View } from "react-native";
+import { Text } from "@/components/ui/text";
+import { useTheme } from "@/hooks/useTheme";
+import { OrganizationAvatar } from "./components/OrganizationAvatar";
+import { OrganizationSwitcherSheet } from "./OrganizationSwitcherSheet";
+
+const MOCK_ORGS = [
+ {
+ id: "org_1",
+ name: "Superset",
+ slug: "superset",
+ logo: null,
+ },
+ {
+ id: "org_2",
+ name: "Acme Corp",
+ slug: "acme",
+ logo: null,
+ },
+ {
+ id: "org_3",
+ name: "Vercel",
+ slug: "vercel",
+ logo: "https://logo.clearbit.com/vercel.com",
+ },
+];
+
+/**
+ * The real `OrganizationSwitcherSheet` uses `@expo/ui/swift-ui` — an iOS-only
+ * SwiftUI bridge that does not render on Android Emulator inside Storybook.
+ *
+ * On iOS, the actual sheet appears as a native bottom sheet over the rest of
+ * the UI. On Android (and in static previews), this story renders the *inner
+ * content layout* of the sheet using the same tokens.
+ */
+function OrganizationSwitcherSheetInner({
+ organizations,
+ activeOrganizationId,
+}: {
+ organizations: typeof MOCK_ORGS;
+ activeOrganizationId: string;
+}) {
+ const theme = useTheme();
+ return (
+
+
+ Organizations
+
+ {organizations.map((organization) => {
+ const isActive = organization.id === activeOrganizationId;
+ return (
+
+
+
+
+ {organization.name}
+
+ {organization.slug ? (
+
+ superset.sh/{organization.slug}
+
+ ) : null}
+
+ {isActive ? (
+
+ ) : null}
+
+ );
+ })}
+
+ );
+}
+
+const meta: Meta = {
+ title: "Components/OrganizationSwitcherSheet",
+ component: OrganizationSwitcherSheetInner,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Bottom sheet for switching organizations. **iOS-only in production** — uses `@expo/ui/swift-ui` (Host/BottomSheet/Group/RNHostView). Inner layout shown here in Storybook; real sheet appears as a native SwiftUI bottom sheet over the app. The sheet forces `colorScheme: dark` (documented exception in screens/AUDIT.md).",
+ },
+ },
+ },
+ args: {
+ organizations: MOCK_ORGS,
+ activeOrganizationId: "org_1",
+ },
+ argTypes: {
+ activeOrganizationId: {
+ control: { type: "select" },
+ options: MOCK_ORGS.map((o) => o.id),
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const ThreeOrgs: Story = {};
+export const SuperLongList: Story = {
+ args: {
+ organizations: [
+ ...MOCK_ORGS,
+ { id: "org_4", name: "Initech", slug: "initech", logo: null },
+ { id: "org_5", name: "Cyberdyne", slug: "cyberdyne", logo: null },
+ { id: "org_6", name: "Stark Industries", slug: "stark", logo: null },
+ { id: "org_7", name: "Wayne Enterprises", slug: "wayne", logo: null },
+ ],
+ },
+};
+export const Active2nd: Story = { args: { activeOrganizationId: "org_2" } };
+
+/**
+ * Real-component story — only renders meaningfully on iOS. Renders nothing on
+ * Android/web; use the InnerLayout stories above for visual inspection.
+ */
+export const NativeSheet: Story = {
+ render: () => {
+ if (Platform.OS !== "ios") {
+ return (
+
+
+ iOS-only — switch to iOS Simulator to render the real SwiftUI
+ BottomSheet.
+
+
+ );
+ }
+ return (
+ undefined}
+ organizations={MOCK_ORGS}
+ activeOrganizationId="org_1"
+ onSwitchOrganization={() => undefined}
+ width={390}
+ />
+ );
+ },
+};
diff --git a/apps/mobile/screens/(authenticated)/(home)/workspaces/components/OrganizationSwitcherSheet/components/OrganizationAvatar/OrganizationAvatar.stories.tsx b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/OrganizationSwitcherSheet/components/OrganizationAvatar/OrganizationAvatar.stories.tsx
new file mode 100644
index 00000000000..1778bc1b871
--- /dev/null
+++ b/apps/mobile/screens/(authenticated)/(home)/workspaces/components/OrganizationSwitcherSheet/components/OrganizationAvatar/OrganizationAvatar.stories.tsx
@@ -0,0 +1,61 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import { View } from "react-native";
+import { Text } from "@/components/ui/text";
+import { OrganizationAvatar } from "./OrganizationAvatar";
+
+const meta: Meta = {
+ title: "Components/OrganizationAvatar",
+ component: OrganizationAvatar,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Organization avatar — renders the org logo if present, otherwise a token-themed initial fallback (`theme.muted` bg, `theme.mutedForeground` text). Used in tab-bar accessory, header button, switcher sheet rows. Token-compliant per screens/AUDIT.md.",
+ },
+ },
+ },
+ args: {
+ name: "Superset",
+ logo: null,
+ size: 32,
+ },
+ argTypes: {
+ name: { control: "text" },
+ logo: { control: "text" },
+ size: { control: { type: "range", min: 16, max: 96, step: 4 } },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const FallbackSmall: Story = { args: { size: 24 } };
+export const FallbackMedium: Story = { args: { size: 32 } };
+export const FallbackLarge: Story = { args: { size: 64 } };
+export const FallbackTwoWordName: Story = { args: { name: "Acme Corp" } };
+export const FallbackNoName: Story = { args: { name: undefined } };
+
+export const WithLogo: Story = {
+ args: {
+ name: "Vercel",
+ logo: "https://logo.clearbit.com/vercel.com",
+ },
+};
+
+export const SizeMatrix: Story = {
+ render: () => (
+
+
+
+
+
+
+
+
+
+ Sizes: 20 · 28 · 36 · 48 · 64 px
+
+
+ ),
+};
diff --git a/apps/mobile/screens/(authenticated)/components/AuthenticatedTabBar/AuthenticatedTabBar.stories.tsx b/apps/mobile/screens/(authenticated)/components/AuthenticatedTabBar/AuthenticatedTabBar.stories.tsx
new file mode 100644
index 00000000000..e10a659e429
--- /dev/null
+++ b/apps/mobile/screens/(authenticated)/components/AuthenticatedTabBar/AuthenticatedTabBar.stories.tsx
@@ -0,0 +1,76 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import { Platform, View } from "react-native";
+import { Text } from "@/components/ui/text";
+
+/**
+ * The real `AuthenticatedTabBar` mounts a `TabBarView` from `@superset/tab-bar`
+ * — a SwiftUI-bridged native tab bar — driven by `expo-router/ui` hooks
+ * (`useTabTrigger`, `useRouter`) and the `useOrganizations` collection query.
+ * None of those render in Storybook isolation:
+ *
+ * - `TabBarView` is a native bridge that needs the live navigation host
+ * - `useTabTrigger` requires an active expo-router Tabs context
+ * - `useOrganizations` queries the Electric collection provider
+ *
+ * This story is a **dependency-acknowledgement placeholder** — it documents
+ * the integration surface and points to the running app for visual verification.
+ * The audit target is purely layout (StyleSheet.create with `position: absolute`
+ * + `height: 96`); no token consumption locally. Per screens/AUDIT.md (CLEAN).
+ */
+function AuthenticatedTabBarPlaceholder() {
+ return (
+
+ AuthenticatedTabBar
+
+ Native tab bar — verified in running app, not in Storybook isolation.
+
+
+
+
+ Dependencies that prevent isolation rendering:
+
+
+ · `@superset/tab-bar` TabBarView (SwiftUI bridge)
+
+
+ · `useTabTrigger` from expo-router/ui (requires Tabs context)
+
+
+ · `useOrganizations` (Electric collection query)
+
+
+
+
+ Local audit surface (StyleSheet):
+
+ · `position: 'absolute'` · `height: 96` · no color literals
+
+
+ Token-clean per screens/AUDIT.md — no remediation needed.
+
+
+
+ Platform.OS = {Platform.OS}
+
+
+ );
+}
+
+const meta: Meta = {
+ title: "Components/AuthenticatedTabBar",
+ component: AuthenticatedTabBarPlaceholder,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Authenticated bottom tab bar wrapping `@superset/tab-bar` TabBarView (SwiftUI bridge). **Not renderable in Storybook isolation** — the native tab bar and expo-router context can only be exercised in the running app. This placeholder documents the integration surface; token audit (CLEAN) is in screens/AUDIT.md.",
+ },
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Placeholder: Story = {};
diff --git a/apps/mobile/screens/(authenticated)/components/OrgDropdown/OrgDropdown.stories.tsx b/apps/mobile/screens/(authenticated)/components/OrgDropdown/OrgDropdown.stories.tsx
new file mode 100644
index 00000000000..96f9d671b1e
--- /dev/null
+++ b/apps/mobile/screens/(authenticated)/components/OrgDropdown/OrgDropdown.stories.tsx
@@ -0,0 +1,122 @@
+import type { Meta, StoryObj } from "@storybook/react-native";
+import {
+ ArrowLeftRight,
+ ChevronDown,
+ LogOut,
+ Settings,
+ UserPlus,
+} from "lucide-react-native";
+import { View } from "react-native";
+import { Avatar, AvatarFallback } from "@/components/ui/avatar";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Icon } from "@/components/ui/icon";
+import { Text } from "@/components/ui/text";
+
+/**
+ * The real `OrgDropdown` depends on the full auth + Electric collections stack
+ * (authClient.useSession, useLiveQuery on collections.organizations,
+ * useSignOut, useCollections). Wiring those in Storybook would require a
+ * fully-mocked QueryClient + collection provider — out of scope for a story.
+ *
+ * This story renders the *visual surface* using the same primitives and tokens
+ * as the real component, with mock org data. The token audit is on this
+ * layout. The data integration is verified in the running app.
+ */
+function OrgDropdownPreview({
+ activeOrgName,
+ switchableOrgs,
+ disabled,
+}: {
+ activeOrgName: string;
+ switchableOrgs: string[];
+ disabled: boolean;
+}) {
+ const orgInitial = activeOrgName.charAt(0).toUpperCase();
+ return (
+
+
+
+
+
+ {orgInitial}
+
+
+
+
+ {activeOrgName}
+
+
+
+
+
+
+
+
+ Settings
+
+
+
+ Invite members
+
+ {switchableOrgs.length > 0 ? : null}
+ {switchableOrgs.map((name) => (
+
+
+ Switch to {name}
+
+ ))}
+ {switchableOrgs.length > 0 ? : null}
+
+
+ Log out
+
+
+
+ );
+}
+
+const meta: Meta = {
+ title: "Components/OrgDropdown",
+ component: OrgDropdownPreview,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Authenticated header dropdown — shows active org, Settings, Invite members, switchable orgs, Log out. Real component reads via `authClient.useSession()` + `useLiveQuery(collections.organizations)`; this Storybook surface uses mock data for visual audit. Token-clean (Tailwind classes only; no hardcoded values). Per screens/AUDIT.md.",
+ },
+ },
+ },
+ args: {
+ activeOrgName: "Superset",
+ switchableOrgs: ["Acme Corp", "Vercel"],
+ disabled: false,
+ },
+ argTypes: {
+ activeOrgName: { control: "text" },
+ disabled: { control: "boolean" },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
+export const SingleOrgNoSwitch: Story = { args: { switchableOrgs: [] } };
+export const ManySwitchableOrgs: Story = {
+ args: {
+ switchableOrgs: ["Acme Corp", "Vercel", "Initech", "Cyberdyne", "Stark"],
+ },
+};
+export const SwitchingInProgress: Story = { args: { disabled: true } };
+export const LongOrgName: Story = {
+ args: {
+ activeOrgName: "Very Long Organization Name That Truncates",
+ },
+};
diff --git a/apps/mobile/screens/(authenticated)/components/TabBarAccessory/TabBarAccessory.stories.tsx b/apps/mobile/screens/(authenticated)/components/TabBarAccessory/TabBarAccessory.stories.tsx
new file mode 100644
index 00000000000..a962069fa71
--- /dev/null
+++ b/apps/mobile/screens/(authenticated)/components/TabBarAccessory/TabBarAccessory.stories.tsx
@@ -0,0 +1,84 @@
+import Ionicons from "@expo/vector-icons/Ionicons";
+import type { Meta, StoryObj } from "@storybook/react-native";
+import { ChevronsUpDown } from "lucide-react-native";
+import { Pressable, View } from "react-native";
+import { Text } from "@/components/ui/text";
+import { useTheme } from "@/hooks/useTheme";
+import { OrganizationAvatar } from "@/screens/(authenticated)/(home)/workspaces/components/OrganizationSwitcherSheet/components/OrganizationAvatar";
+
+/**
+ * The real `TabBarAccessory` depends on the `useOrganizations` hook (Electric
+ * collection query) + `useRouter` (expo-router) + `useWindowDimensions`. The
+ * latter two are infrastructure that don't render in Storybook; the former
+ * requires a mounted collection provider.
+ *
+ * This story renders the *inner row layout* using the same primitives and
+ * tokens, with mock organization data. The audit target is the row; the
+ * `OrganizationSwitcherSheet` it opens is a separate story.
+ */
+function TabBarAccessoryPreview({
+ orgName,
+ orgLogo,
+}: {
+ orgName: string;
+ orgLogo: string | null;
+}) {
+ const theme = useTheme();
+ return (
+
+
+
+
+ {orgName}
+
+
+
+
+
+
+
+ );
+}
+
+const meta: Meta = {
+ title: "Components/TabBarAccessory",
+ component: TabBarAccessoryPreview,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Tab-bar accessory row — org switcher trigger (left) + settings gear (right). Lives above the tab bar in authenticated routes. Real component pulls org from `useOrganizations` and opens `OrganizationSwitcherSheet` on org tap. Token-clean: colors via `useTheme()`, layout via Tailwind. Per screens/AUDIT.md.",
+ },
+ },
+ },
+ args: {
+ orgName: "Superset",
+ orgLogo: null,
+ },
+ argTypes: {
+ orgName: { control: "text" },
+ orgLogo: { control: "text" },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
+export const WithLogo: Story = {
+ args: {
+ orgName: "Vercel",
+ orgLogo: "https://logo.clearbit.com/vercel.com",
+ },
+};
+export const LongOrgName: Story = {
+ args: { orgName: "Acme Corporation International" },
+};
diff --git a/apps/mobile/screens/AUDIT.md b/apps/mobile/screens/AUDIT.md
new file mode 100644
index 00000000000..b4be728bd38
--- /dev/null
+++ b/apps/mobile/screens/AUDIT.md
@@ -0,0 +1,99 @@
+# App Component Token-Compliance Audit
+
+**Generated:** 2026-05-22 (Path A ember migration verification)
+**Scope:** First-party app components in `apps/mobile/screens/**/components/` and `apps/mobile/screens/*/components/`
+**Companion doc:** `apps/mobile/components/ui/AUDIT.md` (vendor primitives — separate immutability rule)
+
+This audit covers the 8 live app components used by the mobile app's authenticated and unauthenticated routes. Unlike the vendor `components/ui/*` primitives, **these are first-party code** — token-bypass findings here are fixed in place rather than escalated to upstream.
+
+---
+
+## Audit pass — 2026-05-22
+
+### ✅ Clean (6/8) — no remediation needed
+
+| Component | Reason |
+|---|---|
+| `AuthenticatedTabBar.tsx` | Pure layout via `StyleSheet.create` (position + height); colors flow through `@superset/tab-bar`. No tokens consumed locally. |
+| `TabBarAccessory.tsx` | All colors via `useTheme()` (`theme.foreground`, `theme.mutedForeground`); Tailwind classes (`px-4`, `text-sm`) are token-driven. |
+| `OrgDropdown.tsx` | Pure Tailwind composition. Reads `text-muted-foreground`, `text-foreground`, `text-destructive`, `size-9` — all theme-routed. |
+| `DevSignInButton.tsx` | Composes vendor `Button` + `Text`. `text-destructive` is the only token reference, correctly used. |
+| `OrganizationAvatar.tsx` | All colors via `useTheme()` (`theme.muted`, `theme.mutedForeground`). |
+| `OrganizationSwitcherSheet.tsx` | Most colors via `useTheme()`. Has one documented exception below. |
+
+### 🛠 Fixed in this audit (2/8)
+
+#### `OrganizationHeaderButton.tsx` — CRITICAL fix
+
+**Before (line 25):**
+```tsx
+
+```
+
+The hex/HSL value `hsl(240 5% 64.9%)` references the OLD cool-neutral palette (HSL 240 hue family) that was removed under Path A. Under the new warm ember theme this rendered as a cool-gray chevron sitting on warm-neutral surfaces — visually wrong even at first paint.
+
+**After:**
+```tsx
+const theme = useTheme();
+// ...
+
+```
+
+Now resolves to the warm `hsl(15 4% 65%)` from `lib/theme.ts` THEME.dark.mutedForeground. Tracks the theme automatically.
+
+#### `SocialButton.tsx` — MEDIUM fix
+
+**Before:**
+```tsx
+const colorScheme = useColorScheme();
+const iconColor = colorScheme === "dark" ? "white" : "black";
+```
+
+Two problems:
+1. **Hardcoded `white`/`black` literals** — bypass the token system entirely.
+2. **`useColorScheme()` reads the OS appearance setting**, not our Uniwind theme. Since `Uniwind.setTheme("dark")` is called unconditionally in `screens/RootLayout/RootLayout.tsx`, the SocialButton icon could disagree with the active theme if a user has light-mode iOS but the app is forced dark — the icon would render black-on-dark, illegible.
+
+**After:**
+```tsx
+const theme = useTheme();
+const iconColor = theme.foreground;
+```
+
+Now `iconColor` tracks the active Uniwind theme via the THEME object. Tokenized.
+
+### 📋 Documented exceptions (kept as-is)
+
+#### `SocialButton.tsx` Google icon brand colors
+
+```tsx
+ {/* Google blue */}
+ {/* Google green */}
+ {/* Google yellow */}
+ {/* Google red */}
+```
+
+**Decision:** keep. These are official Google brand colors per [Google's branding guidelines](https://about.google/brand-resource-center/) — recoloring would violate brand requirements. Pinned hex literals are the correct treatment for branded assets.
+
+#### `OrganizationSwitcherSheet.tsx` forced dark colorScheme
+
+```tsx
+modifiers={[
+ environment("colorScheme", "dark"),
+ presentationDragIndicator("visible"),
+ background(theme.background),
+]}
+```
+
+**Decision:** keep. The SwiftUI `BottomSheet` is wrapped in `environment("colorScheme", "dark")`, forcing dark appearance regardless of system color scheme. This is intentional — the sheet uses the ember dark surface tokens (`theme.background = hsl(13 16% 7%)`) for all themes since `Uniwind.setTheme("dark")` is the app default. Flagging here so it's visible at audit time; revisit if/when the app supports system-driven light/dark switching.
+
+#### Storybook caveat for `OrganizationSwitcherSheet`
+
+The sheet uses `@expo/ui/swift-ui` (`Host`, `BottomSheet`, `Group`, `RNHostView`) — these are **iOS-only SwiftUI bridges**. On Android Emulator inside Storybook, this component will not render meaningfully (the SwiftUI bridge is a no-op). Its story acknowledges this and renders an alternate stub on non-iOS.
+
+---
+
+## Verification surface
+
+All 8 components have `Components/{Name}` Storybook stories at sidecar paths (e.g., `screens/(authenticated)/components/OrgDropdown/OrgDropdown.stories.tsx`). The Storybook glob in `.rnstorybook/main.js` was extended to include `../screens/**/*.stories.?(ts|tsx|js|jsx)` so these stories are picked up.
+
+Use the Storybook walkthrough on iOS Simulator + Android Emulator to verify every component renders correctly under ember.
diff --git a/bun.lock b/bun.lock
index b13c71e1135..77150b660f0 100644
--- a/bun.lock
+++ b/bun.lock
@@ -111,7 +111,7 @@
},
"apps/desktop": {
"name": "@superset/desktop",
- "version": "1.10.3",
+ "version": "1.11.1",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.36",
@@ -463,6 +463,8 @@
"dependencies": {
"@better-auth/expo": "1.6.5",
"@electric-sql/client": "1.5.15",
+ "@expo-google-fonts/geist": "0.4.2",
+ "@expo-google-fonts/geist-mono": "0.4.2",
"@expo/ui": "56.0.12",
"@expo/vector-icons": "15.1.1",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -510,13 +512,16 @@
"expo-dev-client": "56.0.14",
"expo-device": "56.0.4",
"expo-file-system": "56.0.7",
+ "expo-font": "56.0.0",
"expo-glass-effect": "56.0.4",
"expo-image": "56.0.8",
+ "expo-linear-gradient": "56.0.4",
"expo-linking": "56.0.11",
"expo-localization": "56.0.6",
"expo-network": "56.0.4",
"expo-router": "56.2.5",
"expo-secure-store": "56.0.4",
+ "expo-splash-screen": "56.0.0",
"expo-status-bar": "56.0.4",
"expo-system-ui": "56.0.5",
"expo-web-browser": "56.0.5",
@@ -539,10 +544,14 @@
"zod": "4.3.6",
},
"devDependencies": {
+ "@storybook/addon-ondevice-actions": "^9",
+ "@storybook/addon-ondevice-controls": "^9",
+ "@storybook/react-native": "^9",
"@types/node": "24.12.0",
"@types/react": "19.2.14",
"babel-preset-expo": "56.0.11",
"expo-mcp": "0.2.4",
+ "storybook": "^9",
"typescript": "5.9.3",
},
},
@@ -769,7 +778,7 @@
},
"packages/host-service": {
"name": "@superset/host-service",
- "version": "0.8.11",
+ "version": "0.8.13",
"dependencies": {
"@hono/node-server": "1.19.13",
"@hono/node-ws": "1.3.0",
@@ -1607,6 +1616,10 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
+ "@expo-google-fonts/geist": ["@expo-google-fonts/geist@0.4.2", "", {}, "sha512-cTsqJRQrvK5EniTVwH7UDK18RFf9Bc6yxciAuM936aViQ+eR6j3mLEI2rQkJXz5JP9kwtn8tHJviDyZ0ulH6oA=="],
+
+ "@expo-google-fonts/geist-mono": ["@expo-google-fonts/geist-mono@0.4.2", "", {}, "sha512-Ei2dBHXBgCL0pNi1RSuWIroOsvaNBeyT+2oE/q9vQBzZyqWuKYZ5yrybTeIeDl7MEWTgxBVUjyrws3JVbVac8Q=="],
+
"@expo-google-fonts/material-symbols": ["@expo-google-fonts/material-symbols@0.4.27", "", {}, "sha512-cnb3DZnWUWpezGFkJ8y4MT5f/lw6FcgDzeJzic+T+vpQHLHG1cg3SC3i1w1i8Bk4xKR4HPY3t9iIRNvtr5ml8A=="],
"@expo/cli": ["@expo/cli@56.1.10", "", { "dependencies": { "@expo/code-signing-certificates": "^0.0.6", "@expo/config": "~56.0.8", "@expo/config-plugins": "~56.0.7", "@expo/devcert": "^1.2.1", "@expo/env": "~2.3.0", "@expo/image-utils": "^0.10.0", "@expo/inline-modules": "^0.0.9", "@expo/json-file": "^10.2.0", "@expo/log-box": "^56.0.12", "@expo/metro": "~56.0.0", "@expo/metro-config": "~56.0.11", "@expo/metro-file-map": "^56.0.3", "@expo/osascript": "^2.6.0", "@expo/package-manager": "^1.12.0", "@expo/plist": "^0.7.0", "@expo/prebuild-config": "^56.0.12", "@expo/require-utils": "^56.1.2", "@expo/router-server": "^56.0.11", "@expo/schema-utils": "^56.0.0", "@expo/spawn-async": "^1.8.0", "@expo/ws-tunnel": "^1.0.1", "@expo/xcpretty": "^4.4.4", "@react-native/dev-middleware": "0.85.3", "accepts": "^1.3.8", "arg": "^5.0.2", "bplist-creator": "0.1.0", "bplist-parser": "^0.3.1", "chalk": "^4.0.0", "ci-info": "^3.3.0", "compression": "^1.7.4", "connect": "^3.7.0", "debug": "^4.3.4", "dnssd-advertise": "^1.1.4", "expo-server": "^56.0.4", "fetch-nodeshim": "^0.4.10", "getenv": "^2.0.0", "glob": "^13.0.0", "lan-network": "^0.2.1", "multitars": "^1.0.0", "node-forge": "^1.3.3", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "picomatch": "^4.0.4", "pretty-format": "^29.7.0", "progress": "^2.0.3", "prompts": "^2.3.2", "resolve-from": "^5.0.0", "semver": "^7.6.0", "send": "^0.19.0", "slugify": "^1.3.4", "stacktrace-parser": "^0.1.10", "structured-headers": "^0.4.1", "terminal-link": "^2.1.1", "toqr": "^0.1.1", "wrap-ansi": "^7.0.0", "ws": "^8.12.1", "zod": "^3.25.76" }, "peerDependencies": { "expo": "*", "expo-router": "*", "react-native": "*" }, "optionalPeers": ["expo-router", "react-native"], "bin": { "expo-internal": "main.js" } }, "sha512-OOmtxvNHfH1W+dbOlY6WcjQBdf3crmn8XCDy1/yZ7bIrXJq/HxBPxxpn6Gh7gpRby1ov7uZueN0XROiVq3tVxw=="],
@@ -1631,7 +1644,7 @@
"@expo/fingerprint": ["@expo/fingerprint@0.19.1", "", { "dependencies": { "@expo/env": "^2.3.0", "@expo/spawn-async": "^1.8.0", "arg": "^5.0.2", "chalk": "^4.1.2", "debug": "^4.3.4", "getenv": "^2.0.0", "glob": "^13.0.0", "ignore": "^5.3.1", "minimatch": "^10.2.2", "resolve-from": "^5.0.0", "semver": "^7.6.0" }, "bin": { "fingerprint": "bin/cli.js" } }, "sha512-dD6wcETUQrcmPeYvmZDFRWiyRKnCZv4HwLjVTGE45yLEYLnsci1W9NydYtFyZKdT3sS5rmgYl2yPCk2iiBdhjw=="],
- "@expo/image-utils": ["@expo/image-utils@0.8.12", "", { "dependencies": { "@expo/spawn-async": "^1.7.2", "chalk": "^4.0.0", "getenv": "^2.0.0", "jimp-compact": "0.16.1", "parse-png": "^2.1.0", "resolve-from": "^5.0.0", "semver": "^7.6.0" } }, "sha512-3KguH7kyKqq7pNwLb9j6BBdD/bjmNwXZG/HPWT6GWIXbwrvAJt2JNyYTP5agWJ8jbbuys1yuCzmkX+TU6rmI7A=="],
+ "@expo/image-utils": ["@expo/image-utils@0.9.0", "", { "dependencies": { "@expo/require-utils": "56.0.0", "@expo/spawn-async": "^1.7.2", "chalk": "^4.0.0", "getenv": "^2.0.0", "jimp-compact": "0.16.1", "parse-png": "^2.1.0", "semver": "^7.6.0" } }, "sha512-cDOBFjVYmo9EqpDp3weFKp9TOdn0C6xE4ON5DNrT80Tl3OI1OiTSTh/XdCvo8IVv7rtCUqkCzLWSynBa0jCSQw=="],
"@expo/inline-modules": ["@expo/inline-modules@0.0.9", "", { "dependencies": { "@expo/config-plugins": "~56.0.7" } }, "sha512-otMUXI4mOjytbe9OQ3i5X4SV0LP1GpzqLdr9+rdxUc1b0FjdvbTM/GkcbrwY4pU0fGSK0qFqX+jgSieyi+XbUA=="],
@@ -1697,6 +1710,10 @@
"@google/genai": ["@google/genai@1.49.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-hO69Zl0H3x+L0KL4stl1pLYgnqnwHoLqtKy6MRlNnW8TAxjqMdOUVafomKd4z1BePkzoxJWbYILny9a2Zk43VQ=="],
+ "@gorhom/bottom-sheet": ["@gorhom/bottom-sheet@5.2.14", "", { "dependencies": { "@gorhom/portal": "1.0.14", "invariant": "^2.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-native": "*", "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.16.1", "react-native-reanimated": ">=3.16.0 || >=4.0.0-" }, "optionalPeers": ["@types/react", "@types/react-native"] }, "sha512-uLQFlDjp9z+jrOFcMSEldPqL5JdaXL3vXOh+juhwoNvXgTsEorJLjHTugXu+YccAG/0KJnShzKCrb71MHBsvJg=="],
+
+ "@gorhom/portal": ["@gorhom/portal@1.0.14", "", { "dependencies": { "nanoid": "^3.3.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A=="],
+
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
"@headless-tree/core": ["@headless-tree/core@1.6.3", "", {}, "sha512-en0EOaZfiCRF2B8DEnhGhSaUf3hVr9Bauye8G8aswPbHOKSyhJiN4bsczz1GvqF4Xb7Ga3LP0vLA6Zih7YLoyw=="],
@@ -2329,6 +2346,10 @@
"@react-native-async-storage/async-storage": ["@react-native-async-storage/async-storage@2.2.0", "", { "dependencies": { "merge-options": "^3.0.4" }, "peerDependencies": { "react-native": "^0.0.0-0 || >=0.65 <1.0" } }, "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw=="],
+ "@react-native-community/datetimepicker": ["@react-native-community/datetimepicker@9.1.0", "", { "dependencies": { "invariant": "^2.2.4" }, "peerDependencies": { "expo": ">=52.0.0", "react": "*", "react-native": "*", "react-native-windows": "*" }, "optionalPeers": ["expo", "react-native-windows"] }, "sha512-eadbnk+I2vxvW30iTAsm/qlCnMMAadkifIMYNEB2lzhxN/SvlKc7S2V4k5DyrwjdCbqdcMk3t9K6fnUMcAV34w=="],
+
+ "@react-native-community/slider": ["@react-native-community/slider@5.2.0", "", {}, "sha512-484sH8aWEaSjxaZ7HT3YZ8CKDcNes2synko1vdEz5DFEdvKAduxKJTj22L/qBMD7rtIkfbX69DMzWDAGbOAV6w=="],
+
"@react-native-masked-view/masked-view": ["@react-native-masked-view/masked-view@0.3.2", "", { "peerDependencies": { "react": ">=16", "react-native": ">=0.57" } }, "sha512-XwuQoW7/GEgWRMovOQtX3A4PrXhyaZm0lVUiY8qJDvdngjLms9Cpdck6SmGAUNqQwcj2EadHC1HwL0bEyoa/SQ=="],
"@react-native/assets-registry": ["@react-native/assets-registry@0.85.3", "", {}, "sha512-u9ZiYP23vA2IFtdFQFmetzSmk6SM0xgKIoiOsr1hXNHjHaLhOm+/Ph1ud57wX6+Dbwdzx8coJgnzSKL3W21PCg=="],
@@ -2601,6 +2622,24 @@
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
+ "@storybook/addon-ondevice-actions": ["@storybook/addon-ondevice-actions@9.1.4", "", { "dependencies": { "@storybook/global": "^5.0.0", "fast-deep-equal": "^2.0.1" }, "peerDependencies": { "react": "*", "react-native": "*", "storybook": ">=9" } }, "sha512-W68LZu/M+7LpFmAuqkrYAW6UyfczvucbH6b0jNORz8jno63qLO1R8hsTLJnnz9FFLWoAR6njuUX4gPgSy1Xtkw=="],
+
+ "@storybook/addon-ondevice-controls": ["@storybook/addon-ondevice-controls@9.1.4", "", { "dependencies": { "@storybook/react-native-theming": "^9.1.4", "@storybook/react-native-ui-common": "^9.1.4", "deep-equal": "^1.0.1", "prop-types": "^15.7.2", "react-native-modal-datetime-picker": "^18.0.0", "tinycolor2": "^1.4.1" }, "peerDependencies": { "@gorhom/bottom-sheet": ">=4", "@react-native-community/datetimepicker": "*", "@react-native-community/slider": "*", "react": "*", "react-native": "*", "storybook": ">=9" }, "optionalPeers": ["@gorhom/bottom-sheet"] }, "sha512-oj+Ga41RSYMUOwgrMPaLG5fTI2puGmrTuxHGNN+Up1Lxqs5mr2fzWDO10TEspZtAg/ETVZqtNKhW166wQoCBTA=="],
+
+ "@storybook/global": ["@storybook/global@5.0.0", "", {}, "sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ=="],
+
+ "@storybook/react": ["@storybook/react@9.1.20", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/react-dom-shim": "9.1.20" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", "storybook": "^9.1.20", "typescript": ">= 4.9.x" }, "optionalPeers": ["typescript"] }, "sha512-TJhqzggs7HCvLhTXKfx8HodnVq9YizsB2J31s9v6olU0UCxbCY+FYaCF+XdE8qUCyefGRZgHKzGBIczJ/q9e2g=="],
+
+ "@storybook/react-dom-shim": ["@storybook/react-dom-shim@9.1.20", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", "storybook": "^9.1.20" } }, "sha512-UYdZavfPwHEqCKMqPssUOlyFVZiJExLxnSHwkICSZBmw3gxXJcp1aXWs7PvoZdWz2K4ztl3IcKErXXHeiY6w+A=="],
+
+ "@storybook/react-native": ["@storybook/react-native@9.1.4", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/react": "^9.1.8", "@storybook/react-native-theming": "^9.1.4", "@storybook/react-native-ui": "^9.1.4", "@storybook/react-native-ui-common": "^9.1.4", "commander": "^8.2.0", "dedent": "^1.5.1", "deepmerge": "^4.3.0", "react-native-url-polyfill": "^2.0.0", "setimmediate": "^1.0.5", "type-fest": "~2.19", "util": "^0.12.4", "ws": "^8.18.0" }, "peerDependencies": { "@gorhom/bottom-sheet": ">=4", "react": "*", "react-native": ">=0.72.0", "react-native-gesture-handler": ">=2", "react-native-reanimated": ">=2", "react-native-safe-area-context": "*", "storybook": ">=9" }, "optionalPeers": ["@gorhom/bottom-sheet", "react-native-gesture-handler", "react-native-reanimated", "react-native-safe-area-context"], "bin": { "sb-rn-get-stories": "./bin/get-stories.js" } }, "sha512-OjR/1rPQZ2SMBU4+tlgAsF2ooPI1rVadf8DfCH3kC0K4dSfEI+ocqTVzv7p4N3W2ZyTaxgJKJoAxA/Ohw+cSbA=="],
+
+ "@storybook/react-native-theming": ["@storybook/react-native-theming@9.1.4", "", { "dependencies": { "polished": "^4.3.1" }, "peerDependencies": { "react": "*", "react-native": ">=0.57.0" } }, "sha512-gUo9ziv5YIYlgU5b9BpkUcMHyV4uJ/gmb8LWZpROx150HBavmX2IVd8IwBiJkPNuWrTFlTMPW9ytU740rp+GPQ=="],
+
+ "@storybook/react-native-ui": ["@storybook/react-native-ui@9.1.4", "", { "dependencies": { "@storybook/react": "^9.1.8", "@storybook/react-native-theming": "^9.1.4", "@storybook/react-native-ui-common": "^9.1.4", "es-toolkit": "^1.38.0", "fuse.js": "^7.0.0", "memoizerific": "^1.11.3", "polished": "^4.3.1", "store2": "^2.14.3" }, "peerDependencies": { "@gorhom/bottom-sheet": ">=4", "react": "*", "react-native": ">=0.57.0", "react-native-gesture-handler": ">=2", "react-native-reanimated": ">=3", "react-native-safe-area-context": "*", "react-native-svg": ">=14", "storybook": ">=9" } }, "sha512-fBCDfSLkEqgXpAzVdmJMdAxMCkgAhDVVvxvJrtegjOFLE8o9d3PxfFjJkVbZntKmWVU7XlVn0NFuzC0e+5hHIQ=="],
+
+ "@storybook/react-native-ui-common": ["@storybook/react-native-ui-common@9.1.4", "", { "dependencies": { "@storybook/react": "^9.1.8", "@storybook/react-native-theming": "^9.1.4", "es-toolkit": "^1.38.0", "fuse.js": "^7.0.0", "memoizerific": "^1.11.3", "polished": "^4.3.1", "store2": "^2.14.3", "ts-dedent": "^2.2.0" }, "peerDependencies": { "react": "*", "react-native": ">=0.57.0", "storybook": ">=9" } }, "sha512-BfLxoGrVoWZkaeZrqf1cXjnXVZup1knguGNBHFKtWRQLAZqmxEGEYM6l1hzODXvhnsD+2eX+KHQ5DU5CnMiPiw=="],
+
"@streamdown/mermaid": ["@streamdown/mermaid@1.0.2", "", { "dependencies": { "mermaid": "^11.12.2" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-Fr/4sBWnAeSnxM3PcrV/+DiZe5oPMq9gOkUIAH7ZauJeuwrZ/DVzD4g0zlav6AH0axh2m/sOfrfLtY5aLT7niw=="],
"@superset/admin": ["@superset/admin@workspace:apps/admin"],
@@ -2903,6 +2942,8 @@
"@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="],
+ "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
+
"@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="],
"@types/cors": ["@types/cors@2.8.19", "", { "dependencies": { "@types/node": "*" } }, "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg=="],
@@ -2973,6 +3014,8 @@
"@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
+ "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
+
"@types/draco3d": ["@types/draco3d@1.4.10", "", {}, "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw=="],
"@types/eslint": ["@types/eslint@9.6.1", "", { "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag=="],
@@ -3143,6 +3186,16 @@
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="],
+ "@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
+
+ "@vitest/mocker": ["@vitest/mocker@3.2.4", "", { "dependencies": { "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ=="],
+
+ "@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
+
+ "@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="],
+
+ "@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
+
"@vue/compiler-core": ["@vue/compiler-core@3.5.31", "", { "dependencies": { "@babel/parser": "^7.29.2", "@vue/shared": "3.5.31", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-k/ueL14aNIEy5Onf0OVzR8kiqF/WThgLdFhxwa4e/KF/0qe38IwIdofoSWBTvvxQOesaz6riAFAUaYjoF9fLLQ=="],
"@vue/compiler-dom": ["@vue/compiler-dom@3.5.31", "", { "dependencies": { "@vue/compiler-core": "3.5.31", "@vue/shared": "3.5.31" } }, "sha512-BMY/ozS/xxjYqRFL+tKdRpATJYDTTgWSo0+AJvJNg4ig+Hgb0dOsHPXvloHQ5hmlivUqw1Yt2pPIqp4e0v1GUw=="],
@@ -3303,6 +3356,8 @@
"assert-plus": ["assert-plus@1.0.0", "", {}, "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw=="],
+ "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
+
"ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="],
"astral-regex": ["astral-regex@2.0.0", "", {}, "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ=="],
@@ -3331,6 +3386,8 @@
"ava": ["ava@5.3.1", "", { "dependencies": { "acorn": "^8.8.2", "acorn-walk": "^8.2.0", "ansi-styles": "^6.2.1", "arrgv": "^1.0.2", "arrify": "^3.0.0", "callsites": "^4.0.0", "cbor": "^8.1.0", "chalk": "^5.2.0", "chokidar": "^3.5.3", "chunkd": "^2.0.1", "ci-info": "^3.8.0", "ci-parallel-vars": "^1.0.1", "clean-yaml-object": "^0.1.0", "cli-truncate": "^3.1.0", "code-excerpt": "^4.0.0", "common-path-prefix": "^3.0.0", "concordance": "^5.0.4", "currently-unhandled": "^0.4.1", "debug": "^4.3.4", "emittery": "^1.0.1", "figures": "^5.0.0", "globby": "^13.1.4", "ignore-by-default": "^2.1.0", "indent-string": "^5.0.0", "is-error": "^2.2.2", "is-plain-object": "^5.0.0", "is-promise": "^4.0.0", "matcher": "^5.0.0", "mem": "^9.0.2", "ms": "^2.1.3", "p-event": "^5.0.1", "p-map": "^5.5.0", "picomatch": "^2.3.1", "pkg-conf": "^4.0.0", "plur": "^5.1.0", "pretty-ms": "^8.0.0", "resolve-cwd": "^3.0.0", "stack-utils": "^2.0.6", "strip-ansi": "^7.0.1", "supertap": "^3.0.1", "temp-dir": "^3.0.0", "write-file-atomic": "^5.0.1", "yargs": "^17.7.2" }, "peerDependencies": { "@ava/typescript": "*" }, "optionalPeers": ["@ava/typescript"], "bin": { "ava": "entrypoints/cli.mjs" } }, "sha512-Scv9a4gMOXB6+ni4toLuhAm9KYWEjsgBglJl+kMGI5+IVDt120CCDZyB5HNU9DjmLI2t4I0GbnxGLmmRfGTJGg=="],
+ "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
+
"aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="],
"axios": ["axios@1.14.0", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ=="],
@@ -3387,6 +3444,8 @@
"better-call": ["better-call@1.3.5", "", { "dependencies": { "@better-auth/utils": "^0.4.0", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-kOFJkBP7utAQLEYrobZm3vkTH8mXq5GNgvjc5/XEST1ilVHaxXUXfeDeFlqoETMtyqS4+3/h4ONX2i++ebZrvA=="],
+ "better-opn": ["better-opn@3.0.2", "", { "dependencies": { "open": "^8.0.4" } }, "sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ=="],
+
"better-sqlite3": ["better-sqlite3@12.6.2", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA=="],
"bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
@@ -3451,6 +3510,8 @@
"cacheable-request": ["cacheable-request@7.0.4", "", { "dependencies": { "clone-response": "^1.0.2", "get-stream": "^5.1.0", "http-cache-semantics": "^4.0.0", "keyv": "^4.0.0", "lowercase-keys": "^2.0.0", "normalize-url": "^6.0.1", "responselike": "^2.0.0" } }, "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg=="],
+ "call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="],
+
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
@@ -3467,6 +3528,8 @@
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
+ "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
+
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
@@ -3479,6 +3542,8 @@
"chat": ["chat@4.24.0", "", { "dependencies": { "@workflow/serde": "4.1.0-beta.2", "mdast-util-to-string": "^4.0.0", "remark-gfm": "^4.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "remend": "^1.2.1", "unified": "^11.0.5" } }, "sha512-0TxglwtGRMGlqERuHVZZ27Z4YBeZH3oRXCqHZYuI41L7xcSHF5C3wEHTMdVqHp3p8ZKQcKYQPOwYWvaeFVa4+g=="],
+ "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="],
+
"cheerio": ["cheerio@1.2.0", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "encoding-sniffer": "^0.2.1", "htmlparser2": "^10.1.0", "parse5": "^7.3.0", "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", "undici": "^7.19.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg=="],
"cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="],
@@ -3765,6 +3830,12 @@
"decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
+ "dedent": ["dedent@1.7.2", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA=="],
+
+ "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="],
+
+ "deep-equal": ["deep-equal@1.1.2", "", { "dependencies": { "is-arguments": "^1.1.1", "is-date-object": "^1.0.5", "is-regex": "^1.1.4", "object-is": "^1.1.5", "object-keys": "^1.1.1", "regexp.prototype.flags": "^1.5.1" } }, "sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg=="],
+
"deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="],
"deep-rename-keys": ["deep-rename-keys@0.2.1", "", { "dependencies": { "kind-of": "^3.0.2", "rename-keys": "^1.1.2" } }, "sha512-RHd9ABw4Fvk+gYDWqwOftG849x0bYOySl/RgX0tLI9i27ZIeSO91mLZJEp7oPHOMFqHvpgu21YptmDt0FYD/0A=="],
@@ -3781,6 +3852,8 @@
"define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
+ "define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="],
+
"define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="],
"defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="],
@@ -4031,7 +4104,7 @@
"expo-file-system": ["expo-file-system@56.0.7", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-dcKzo8ShPloM7jgfnMcJStgQebhP8owVjCkNI/aX6NMFV1CYB8bxKGMdnzJ3mXk5nfaiW+F/lSKr2UIJ02WAUA=="],
- "expo-font": ["expo-font@55.0.4", "", { "dependencies": { "fontfaceobserver": "^2.1.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-ZKeGTFffPygvY5dM/9ATM2p7QDkhsaHopH7wFAWgP2lKzqUMS9B/RxCvw5CaObr9Ro7x9YptyeRKX2HmgmMfrg=="],
+ "expo-font": ["expo-font@56.0.0", "", { "dependencies": { "fontfaceobserver": "^2.1.0" }, "peerDependencies": { "expo": "56.0.0-preview.0", "react": "*", "react-native": "*" } }, "sha512-zav71114PjD4n0NS5AfE6U0WbkO3VDHxVDelZJ8cENG0saF+VniNKZisdPcnhdQpQXsg7rx1Q8UGS6uyIMlSwg=="],
"expo-glass-effect": ["expo-glass-effect@56.0.4", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-xI9rXtDwi7RW82uAlfyaXO6+k21ApWJ2tHAWYqPr/FjfmZbKsgNJ4Q0iZzGPCwboqjTGxaRZ61SZxBl8hDt5iA=="],
@@ -4041,6 +4114,8 @@
"expo-keep-awake": ["expo-keep-awake@56.0.3", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-CLMJXtEiMKknD3Rpm8CRwE6ZJUzu2yCEmRk1sgfHAJ1zIbuEWY3dpPDubtsnuzWm+2k6Sru+yaFbYsvPWmTiBA=="],
+ "expo-linear-gradient": ["expo-linear-gradient@56.0.4", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-KUp1dNSRtuMyiExhf6FJf5YUtmw2cRaPytl10HQi7isj5Yac38udmD55T2tglNYTZlvgT5+oflpyFoH15hmOcw=="],
+
"expo-linking": ["expo-linking@56.0.11", "", { "dependencies": { "expo-constants": "~56.0.14", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-MEPgML2mqm2Y8rP6zTleOpCmYiFyfQfNSOBpDIb7CYpbDQleStugvceKsEsL4v8C0Dl5u7e8KkkrbqmgpOOIBw=="],
"expo-localization": ["expo-localization@56.0.6", "", { "dependencies": { "rtl-detect": "^1.0.2" }, "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-zzBVoUFHCVNBywcxGsspoZeIXebihOo/AnmQYE4jMv8gHCSKlLNFT+ft+0+mWcZCMs9necvUs8S8TDonAu/xBA=="],
@@ -4063,6 +4138,8 @@
"expo-server": ["expo-server@56.0.4", "", {}, "sha512-4dJ57KuAwDl7eQGD6aG9kTzBIftWAfHH1+6Zxy7NcPCBrKYis3/H5enGUz1asH8HHhONXfJ5BdJqfEWAEAgWxA=="],
+ "expo-splash-screen": ["expo-splash-screen@56.0.0", "", { "dependencies": { "@expo/config-plugins": "56.0.0", "@expo/image-utils": "0.9.0", "xml2js": "0.6.0" }, "peerDependencies": { "expo": "56.0.0-preview.0" } }, "sha512-Mue5mdczeanNwUlJq9A4joYGIyAh42S1z2J/Unfhm9hkYSC/l56QdUld8gdyPAkFstNJaZacb2mXAGuFC+jerQ=="],
+
"expo-status-bar": ["expo-status-bar@56.0.4", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-IGs/fDfkHXofy2ZQrGiXayhFK04HB85FZXorhcEhDZEcqASKgSqpak+HwUtAaR0MeTJwWyHNF7I6VmVbbp8EcA=="],
"expo-symbols": ["expo-symbols@56.0.5", "", { "dependencies": { "@expo-google-fonts/material-symbols": "^0.4.1", "sf-symbols-typescript": "^2.0.0" }, "peerDependencies": { "expo": "*", "expo-font": "*", "react": "*", "react-native": "*" } }, "sha512-RIukH0Xo80C7RU8qreipL2SPy2Py+Km8JFPbCmbPQpHkM3DW9Znlmg6VfhzbtUOlO5EuNSF0lAJ3l2VJi6qYrw=="],
@@ -4159,6 +4236,8 @@
"fontfaceobserver": ["fontfaceobserver@2.3.0", "", {}, "sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg=="],
+ "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
+
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
@@ -4201,6 +4280,8 @@
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
+ "functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="],
+
"fuse.js": ["fuse.js@7.1.0", "", {}, "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ=="],
"gaxios": ["gaxios@7.1.4", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA=="],
@@ -4211,6 +4292,8 @@
"geist": ["geist@1.7.0", "", { "peerDependencies": { "next": ">=13.2.0" } }, "sha512-ZaoiZwkSf0DwwB1ncdLKp+ggAldqxl5L1+SXaNIBGkPAqcu+xjVJLxlf3/S8vLt9UHx1xu5fz3lbzKCj5iOVdQ=="],
+ "generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="],
+
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
@@ -4415,14 +4498,20 @@
"is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
+ "is-arguments": ["is-arguments@1.2.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA=="],
+
"is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="],
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
"is-buffer": ["is-buffer@2.0.5", "", {}, "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ=="],
+ "is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="],
+
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
+ "is-date-object": ["is-date-object@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg=="],
+
"is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
"is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
@@ -4439,6 +4528,8 @@
"is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
+ "is-generator-function": ["is-generator-function@1.1.2", "", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="],
+
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
@@ -4461,10 +4552,14 @@
"is-reference": ["is-reference@1.2.1", "", { "dependencies": { "@types/estree": "*" } }, "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ=="],
+ "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="],
+
"is-standalone-pwa": ["is-standalone-pwa@0.1.1", "", {}, "sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g=="],
"is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
+ "is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="],
+
"is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="],
"is-what": ["is-what@5.5.0", "", {}, "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw=="],
@@ -4657,6 +4752,8 @@
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
+ "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="],
+
"lowdb": ["lowdb@7.0.1", "", { "dependencies": { "steno": "^4.0.2" } }, "sha512-neJAj8GwF0e8EpycYIDFqEPcx9Qz4GUho20jWFR7YiFeXzF1YMLdxB36PypcTSPMA+4+LvgyMacYhlr18Zlymw=="],
"lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="],
@@ -4683,6 +4780,8 @@
"map-age-cleaner": ["map-age-cleaner@0.1.3", "", { "dependencies": { "p-defer": "^1.0.0" } }, "sha512-bJzx6nMoP6PDLPBFmg7+xRKeFZvFboMrGlxmNj9ClvX53KrmvM5bXFXEWjbz4cz1AFn+jWJ9z/DJSz7hrs0w3w=="],
+ "map-or-similar": ["map-or-similar@1.5.0", "", {}, "sha512-0aF7ZmVon1igznGI4VS30yugpduQW3y3GkcgGJOp7d8x8QrizhigUxjI/m2UojsXXto+jLAH3KSz+xOJTiORjg=="],
+
"markdown-extensions": ["markdown-extensions@2.0.0", "", {}, "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q=="],
"markdown-it": ["markdown-it@14.1.1", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA=="],
@@ -4747,6 +4846,8 @@
"memoize-one": ["memoize-one@5.2.1", "", {}, "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="],
+ "memoizerific": ["memoizerific@1.11.3", "", { "dependencies": { "map-or-similar": "^1.5.0" } }, "sha512-/EuHYwAPdLtXwAwSZkh/Gutery6pD2KYd44oQLhAvQp/50mpyduZh8Q7PYHXTCJ+wuXxt7oij2LXyIJOOYFPog=="],
+
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
"merge-options": ["merge-options@3.0.4", "", { "dependencies": { "is-plain-obj": "^2.1.0" } }, "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ=="],
@@ -5003,6 +5104,8 @@
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
+ "object-is": ["object-is@1.1.6", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1" } }, "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q=="],
+
"object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="],
"ollama-ai-provider-v2": ["ollama-ai-provider-v2@1.5.5", "", { "dependencies": { "@ai-sdk/provider": "^2.0.0", "@ai-sdk/provider-utils": "^3.0.17" }, "peerDependencies": { "zod": "^4.0.16" } }, "sha512-1YwTFdPjhPNHny/DrOHO+s8oVGGIE5Jib61/KnnjPRNWQhVVimrJJdaAX3e6nNRRDXrY5zbb9cfm2+yVvgsrqw=="],
@@ -5025,7 +5128,7 @@
"onnxruntime-node": ["onnxruntime-node@1.21.0", "", { "dependencies": { "global-agent": "^3.0.0", "onnxruntime-common": "1.21.0", "tar": "^7.0.1" }, "os": [ "linux", "win32", "darwin", ] }, "sha512-NeaCX6WW2L8cRCSqy3bInlo5ojjQqu2fD3D+9W5qb5irwxhEyWKXeH2vZ8W9r6VxaMPUan+4/7NDwZMtouZxEw=="],
- "open": ["open@7.4.2", "", { "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" } }, "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q=="],
+ "open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="],
"openai": ["openai@4.104.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" }, "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA=="],
@@ -5113,6 +5216,8 @@
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
+ "pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="],
+
"pe-library": ["pe-library@0.4.1", "", {}, "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw=="],
"peberminta": ["peberminta@0.9.0", "", {}, "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ=="],
@@ -5171,8 +5276,12 @@
"points-on-path": ["points-on-path@0.2.1", "", { "dependencies": { "path-data-parser": "0.1.0", "points-on-curve": "0.2.0" } }, "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g=="],
+ "polished": ["polished@4.3.1", "", { "dependencies": { "@babel/runtime": "^7.17.8" } }, "sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA=="],
+
"portfinder": ["portfinder@1.0.38", "", { "dependencies": { "async": "^3.2.6", "debug": "^4.3.6" } }, "sha512-rEwq/ZHlJIKw++XtLAO8PPuOQA/zaPJOZJ37BVuN97nLpMJeuDVLVGRwbFoBgLudgdTMP2hdRJP++H+8QOA3vg=="],
+ "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
+
"postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="],
"postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="],
@@ -5365,6 +5474,8 @@
"react-native-is-edge-to-edge": ["react-native-is-edge-to-edge@1.3.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA=="],
+ "react-native-modal-datetime-picker": ["react-native-modal-datetime-picker@18.0.0", "", { "dependencies": { "prop-types": "^15.7.2" }, "peerDependencies": { "@react-native-community/datetimepicker": ">=6.7.0", "react-native": ">=0.65.0" } }, "sha512-0jdvhhraZQlRACwr7pM6vmZ2kxgzJ4CpnmV6J3TVA6MrXMXK6Zo/upRBKkRp0+fTOiKuNblzesA2U59rYo6SGA=="],
+
"react-native-reanimated": ["react-native-reanimated@4.3.1", "", { "dependencies": { "react-native-is-edge-to-edge": "^1.3.1", "semver": "^7.7.3" }, "peerDependencies": { "react": "*", "react-native": "0.81 - 0.85", "react-native-worklets": "0.8.x" } }, "sha512-KhGsS0YkCA+gusgyzlf9hnqzVPIR398KTpqXyqq/+yYJJPAvyEEPKcxlB0xtOOXSMrR2A9uRKVARVQhZwrOh+Q=="],
"react-native-safe-area-context": ["react-native-safe-area-context@5.8.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-t+ZsAVzY/wWzzx34vqGbo3/as9EEESJdbyZNL7Yg5EYX+toYMtMqFoDDCvqZUi35eeGVsXc6pAaEk4edMwbuCQ=="],
@@ -5373,6 +5484,8 @@
"react-native-svg": ["react-native-svg@15.15.5", "", { "dependencies": { "css-select": "^5.1.0", "css-tree": "^1.1.3" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-L4go5jA+GWutdJ/JucuN20cjAbMg1HmMtAP+wZ+3JLCf6Jd0bhXQHxciRP/AQm/FlrIEZwkMcHNZP+FXAiic0w=="],
+ "react-native-url-polyfill": ["react-native-url-polyfill@2.0.0", "", { "dependencies": { "whatwg-url-without-unicode": "8.0.0-3" }, "peerDependencies": { "react-native": "*" } }, "sha512-My330Do7/DvKnEvwQc0WdcBnFPploYKp9CYlefDXzIdEaA+PAhDYllkvGeEroEzvc4Kzzj2O4yVdz8v6fjRvhA=="],
+
"react-native-worklets": ["react-native-worklets@0.8.3", "", { "dependencies": { "@babel/plugin-transform-arrow-functions": "^7.27.1", "@babel/plugin-transform-class-properties": "^7.27.1", "@babel/plugin-transform-classes": "^7.28.4", "@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1", "@babel/plugin-transform-optional-chaining": "^7.27.1", "@babel/plugin-transform-shorthand-properties": "^7.27.1", "@babel/plugin-transform-template-literals": "^7.27.1", "@babel/plugin-transform-unicode-regex": "^7.27.1", "@babel/preset-typescript": "^7.27.1", "convert-source-map": "^2.0.0", "semver": "^7.7.3" }, "peerDependencies": { "@babel/core": "*", "@react-native/metro-config": "*", "react": "*", "react-native": "0.81 - 0.85" } }, "sha512-oCBJROyLU7yG/1R8s0INMflygTH71bx+5XcYkH0CM938TlhSoVbiunE1WVW5FZa51vwYqfLie/IXMX2s1Kh3eg=="],
"react-promise-suspense": ["react-promise-suspense@0.3.4", "", { "dependencies": { "fast-deep-equal": "^2.0.1" } }, "sha512-I42jl7L3Ze6kZaq+7zXWSunBa3b1on5yfvUW6Eo/3fFOj6dZ5Bqmcd264nJbTK/gn1HjjILAjSwnZbV4RpSaNQ=="],
@@ -5439,6 +5552,8 @@
"regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="],
+ "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
+
"regexpu-core": ["regexpu-core@6.4.0", "", { "dependencies": { "regenerate": "^1.4.2", "regenerate-unicode-properties": "^10.2.2", "regjsgen": "^0.8.0", "regjsparser": "^0.13.0", "unicode-match-property-ecmascript": "^2.0.0", "unicode-match-property-value-ecmascript": "^2.2.1" } }, "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA=="],
"regjsgen": ["regjsgen@0.8.0", "", {}, "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q=="],
@@ -5559,6 +5674,8 @@
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
+ "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
+
"safe-regex2": ["safe-regex2@5.1.0", "", { "dependencies": { "ret": "~0.5.0" }, "bin": { "safe-regex2": "bin/safe-regex2.js" } }, "sha512-pNHAuBW7TrcleFHsxBr5QMi/Iyp0ENjUKz7GCcX1UO7cMh+NmVK6HxQckNL1tJp1XAJVjG6B8OKIPqodqj9rtw=="],
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
@@ -5601,6 +5718,10 @@
"set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="],
+ "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
+
+ "set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="],
+
"setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="],
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
@@ -5731,6 +5852,10 @@
"steno": ["steno@4.0.2", "", {}, "sha512-yhPIQXjrlt1xv7dyPQg2P17URmXbuM5pdGkpiMB3RenprfiBlvK415Lctfe0eshk90oA7/tNq7WEiMK8RSP39A=="],
+ "store2": ["store2@2.14.4", "", {}, "sha512-srTItn1GOvyvOycgxjAnPA63FZNwy0PTyUBFMHRM+hVFltAeoh0LmNBz9SZqUS9mMqGk8rfyWyXn3GH5ReJ8Zw=="],
+
+ "storybook": ["storybook@9.1.20", "", { "dependencies": { "@storybook/global": "^5.0.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/user-event": "^14.6.1", "@vitest/expect": "3.2.4", "@vitest/mocker": "3.2.4", "@vitest/spy": "3.2.4", "better-opn": "^3.0.2", "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0", "esbuild-register": "^3.5.0", "recast": "^0.23.5", "semver": "^7.6.2", "ws": "^8.18.0" }, "peerDependencies": { "prettier": "^2 || ^3" }, "optionalPeers": ["prettier"], "bin": "./bin/index.cjs" }, "sha512-6rME2tww6PFhm96iG2Xx44yzwLDWBiDWy+kJ2ub6x90werSTOiuo+tZJ94BgCfFutR0tEfLRIq59s+Zg6YyChA=="],
+
"stream-buffers": ["stream-buffers@2.2.0", "", {}, "sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg=="],
"stream-parser": ["stream-parser@0.3.1", "", { "dependencies": { "debug": "2" } }, "sha512-bJ/HgKq41nlKvlhccD5kaCr/P+Hu0wPNKPJOH7en+YrJu/9EgqUF+88w5Jb6KNcjOFMhfX4B2asfeAtIGuHObQ=="],
@@ -5869,10 +5994,16 @@
"tiny-typed-emitter": ["tiny-typed-emitter@2.1.0", "", {}, "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA=="],
+ "tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="],
+
"tinyexec": ["tinyexec@1.0.4", "", {}, "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
+ "tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="],
+
+ "tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="],
+
"tippy.js": ["tippy.js@6.3.7", "", { "dependencies": { "@popperjs/core": "^2.9.0" } }, "sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ=="],
"tiptap-markdown": ["tiptap-markdown@0.9.0", "", { "dependencies": { "@types/markdown-it": "^13.0.7", "markdown-it": "^14.1.0", "markdown-it-task-lists": "^2.1.1", "prosemirror-markdown": "^1.11.1" }, "peerDependencies": { "@tiptap/core": "^3.0.1" } }, "sha512-dKLQ9iiuGNgrlGVjrNauF/UBzWu4LYOx5pkD0jNkmQt/GOwfCJsBuzZTsf1jZ204ANHOm572mZ9PYvGh1S7tpQ=="],
@@ -6035,6 +6166,8 @@
"utf8-byte-length": ["utf8-byte-length@1.0.5", "", {}, "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA=="],
+ "util": ["util@0.12.5", "", { "dependencies": { "inherits": "^2.0.3", "is-arguments": "^1.0.4", "is-generator-function": "^1.0.7", "is-typed-array": "^1.1.3", "which-typed-array": "^1.1.2" } }, "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA=="],
+
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"utility-types": ["utility-types@3.11.0", "", {}, "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw=="],
@@ -6105,7 +6238,7 @@
"webgl-sdf-generator": ["webgl-sdf-generator@1.1.1", "", {}, "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA=="],
- "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
+ "webidl-conversions": ["webidl-conversions@5.0.0", "", {}, "sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA=="],
"webpack": ["webpack@5.105.4", "", { "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.16.0", "acorn-import-phases": "^1.0.3", "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", "enhanced-resolve": "^5.20.0", "es-module-lexer": "^2.0.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", "loader-runner": "^4.3.1", "mime-types": "^2.1.27", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", "terser-webpack-plugin": "^5.3.17", "watchpack": "^2.5.1", "webpack-sources": "^3.3.4" }, "bin": { "webpack": "bin/webpack.js" } }, "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw=="],
@@ -6125,10 +6258,14 @@
"whatwg-url-minimum": ["whatwg-url-minimum@0.1.2", "", {}, "sha512-XPEm0XFQWNVG292lII1PrRRJl3sItrs7CettZ4ncYxuDVpLyy+NwlGyut2hXI0JswcJUxeCH+CyOJK0ZzAXD6A=="],
+ "whatwg-url-without-unicode": ["whatwg-url-without-unicode@8.0.0-3", "", { "dependencies": { "buffer": "^5.4.3", "punycode": "^2.1.1", "webidl-conversions": "^5.0.0" } }, "sha512-HoKuzZrUlgpz35YO27XgD28uh/WJH4B0+3ttFqRo//lmq+9T/mIOJ6kqmINI9HpUpz1imRC/nR/lxKpJiv0uig=="],
+
"when-exit": ["when-exit@2.1.5", "", {}, "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
+ "which-typed-array": ["which-typed-array@1.1.20", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg=="],
+
"widest-line": ["widest-line@6.0.0", "", { "dependencies": { "string-width": "^8.1.0" } }, "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA=="],
"workerd": ["workerd@1.20260317.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260317.1", "@cloudflare/workerd-darwin-arm64": "1.20260317.1", "@cloudflare/workerd-linux-64": "1.20260317.1", "@cloudflare/workerd-linux-arm64": "1.20260317.1", "@cloudflare/workerd-windows-64": "1.20260317.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-ZuEq1OdrJBS+NV+L5HMYPCzVn49a2O60slQiiLpG44jqtlOo+S167fWC76kEXteXLLLydeuRrluRel7WdOUa4g=="],
@@ -6381,7 +6518,7 @@
"@expo/fingerprint/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
- "@expo/image-utils/@expo/spawn-async": ["@expo/spawn-async@1.7.2", "", { "dependencies": { "cross-spawn": "^7.0.3" } }, "sha512-QdWi16+CHB9JYP7gma19OVVg0BFkvU8zNj9GjWorYI8Iv8FUxjOCcYRuAmX4s/h91e4e7BPsskc8cSrZYho9Ew=="],
+ "@expo/image-utils/@expo/require-utils": ["@expo/require-utils@56.0.0", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "@babel/core": "^7.25.2", "@babel/plugin-transform-modules-commonjs": "^7.24.8" }, "peerDependencies": { "typescript": "^5.0.0 || ^5.0.0-0 || ^6.0.0" }, "optionalPeers": ["typescript"] }, "sha512-btaotBF3yymrv9c7edNqPBFAo9npSPB1LMvKa0dZWEDxYZQHGAA9xnQfmdImZDe+BIgxEjzpDjDq1T6ZMi43kQ=="],
"@expo/image-utils/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
@@ -6417,6 +6554,8 @@
"@fumadocs/ui/postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="],
+ "@gorhom/portal/nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
+
"@jest/types/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"@langchain/core/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
@@ -6531,6 +6670,8 @@
"@react-native/dev-middleware/chrome-launcher": ["chrome-launcher@0.15.2", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^1.0.0" }, "bin": { "print-chrome-path": "bin/print-chrome-path.js" } }, "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ=="],
+ "@react-native/dev-middleware/open": ["open@7.4.2", "", { "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" } }, "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q=="],
+
"@react-native/dev-middleware/serve-static": ["serve-static@1.16.3", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "~0.19.1" } }, "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA=="],
"@react-native/dev-middleware/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="],
@@ -6595,6 +6736,12 @@
"@slack/web-api/eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
+ "@storybook/addon-ondevice-actions/fast-deep-equal": ["fast-deep-equal@2.0.1", "", {}, "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w=="],
+
+ "@storybook/react-native/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
+
+ "@storybook/react-native/type-fest": ["type-fest@2.19.0", "", {}, "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA=="],
+
"@tailwindcss/node/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
@@ -6633,6 +6780,8 @@
"@upstash/qstash/jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="],
+ "@vitest/mocker/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
+
"@vue/compiler-core/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"@wdio/config/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
@@ -6823,6 +6972,8 @@
"expo/react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="],
+ "expo-asset/@expo/image-utils": ["@expo/image-utils@0.8.12", "", { "dependencies": { "@expo/spawn-async": "^1.7.2", "chalk": "^4.0.0", "getenv": "^2.0.0", "jimp-compact": "0.16.1", "parse-png": "^2.1.0", "resolve-from": "^5.0.0", "semver": "^7.6.0" } }, "sha512-3KguH7kyKqq7pNwLb9j6BBdD/bjmNwXZG/HPWT6GWIXbwrvAJt2JNyYTP5agWJ8jbbuys1yuCzmkX+TU6rmI7A=="],
+
"expo-asset/expo-constants": ["expo-constants@55.0.9", "", { "dependencies": { "@expo/config": "~55.0.10", "@expo/env": "~2.1.1" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-iBiXjZeuU5S/8docQeNzsVvtDy4w0zlmXBpFEi1ypwugceEpdQQab65TVRbusXAcwpNVxCPMpNlDssYp0Pli2g=="],
"expo-device/ua-parser-js": ["ua-parser-js@0.7.41", "", { "bin": { "ua-parser-js": "script/cli.js" } }, "sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg=="],
@@ -6839,6 +6990,10 @@
"expo-router/react-is": ["react-is@19.2.4", "", {}, "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA=="],
+ "expo-splash-screen/@expo/config-plugins": ["@expo/config-plugins@56.0.0", "", { "dependencies": { "@expo/config-types": "56.0.0", "@expo/json-file": "10.1.0", "@expo/plist": "0.6.0", "@expo/sdk-runtime-versions": "^1.0.0", "chalk": "^4.1.2", "debug": "^4.3.5", "getenv": "^2.0.0", "glob": "^13.0.0", "resolve-from": "^5.0.0", "semver": "^7.5.4", "slugify": "^1.6.6", "xcode": "^3.0.1", "xml2js": "0.6.0" } }, "sha512-1Enx36NZorN3cve43GcIW+DkfESG9pcm0PS5K1F5rG/XKlAD2WytyyRJZQu+7pHaJ2RrOWVbT8qOQ1CRSS6Qcw=="],
+
+ "expo-splash-screen/xml2js": ["xml2js@0.6.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w=="],
+
"extract-zip/get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="],
"fastembed/tar": ["tar@6.2.1", "", { "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", "minipass": "^5.0.0", "minizlib": "^2.1.1", "mkdirp": "^1.0.3", "yallist": "^4.0.0" } }, "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A=="],
@@ -7209,6 +7364,10 @@
"whatwg-encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
+ "whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
+
+ "whatwg-url-without-unicode/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
+
"wrangler/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
"wrangler/path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
@@ -7743,6 +7902,10 @@
"engine.io/accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
+ "expo-asset/@expo/image-utils/@expo/spawn-async": ["@expo/spawn-async@1.7.2", "", { "dependencies": { "cross-spawn": "^7.0.3" } }, "sha512-QdWi16+CHB9JYP7gma19OVVg0BFkvU8zNj9GjWorYI8Iv8FUxjOCcYRuAmX4s/h91e4e7BPsskc8cSrZYho9Ew=="],
+
+ "expo-asset/@expo/image-utils/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
+
"expo-asset/expo-constants/@expo/config": ["@expo/config@55.0.11", "", { "dependencies": { "@expo/config-plugins": "~55.0.7", "@expo/config-types": "^55.0.5", "@expo/json-file": "^10.0.12", "@expo/require-utils": "^55.0.3", "deepmerge": "^4.3.1", "getenv": "^2.0.0", "glob": "^13.0.0", "resolve-from": "^5.0.0", "resolve-workspace-root": "^2.0.0", "semver": "^7.6.0", "slugify": "^1.3.4" } }, "sha512-14AkSmR1gOIUhCsPJ0cAo5ZduMNsPQsmFV9jBNZn1xC5Zb3D8x5eqvUie5QzWaUwdcyrq79uYJ2bTCiC6+nD0Q=="],
"expo-asset/expo-constants/@expo/env": ["@expo/env@2.1.1", "", { "dependencies": { "chalk": "^4.0.0", "debug": "^4.3.4", "getenv": "^2.0.0" } }, "sha512-rVvHC4I6xlPcg+mAO09ydUi2Wjv1ZytpLmHOSzvXzBAz9mMrJggqCe4s4dubjJvi/Ino/xQCLhbaLCnTtLpikg=="],
@@ -7751,6 +7914,14 @@
"expo-modules-autolinking/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
+ "expo-splash-screen/@expo/config-plugins/@expo/config-types": ["@expo/config-types@56.0.0", "", {}, "sha512-9jwq4O09Sb9TBQaATCPrmElxkpN3t8LYk7QbYhtapgtNlZvObK9qkAuT02r3Z5Yin87V+dU3k0VmwiwOza8Paw=="],
+
+ "expo-splash-screen/@expo/config-plugins/@expo/json-file": ["@expo/json-file@10.1.0", "", { "dependencies": { "@babel/code-frame": "^7.20.0", "json5": "^2.2.3" } }, "sha512-3UBuXpYqr9DJRYHotRgqO8/5nsS6jJxK9MrLR/YRTHN4U1SKRz/hNEOmaQlrXaSd48cLutl4APDCy1mR52daUw=="],
+
+ "expo-splash-screen/@expo/config-plugins/@expo/plist": ["@expo/plist@0.6.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-Qn+rkyYXb+6B62l9zp8Q/FcCEPMibad51Erpzr0DmwHdS0pozzWV3iktiCoYkqHMOuIc9aIRNCTSs0IFleU0uQ=="],
+
+ "expo-splash-screen/@expo/config-plugins/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
+
"expo/expo-asset/@expo/image-utils": ["@expo/image-utils@0.10.0", "", { "dependencies": { "@expo/require-utils": "^56.1.2", "@expo/spawn-async": "^1.8.0", "chalk": "^4.0.0", "getenv": "^2.0.0", "jimp-compact": "0.16.1", "parse-png": "^2.1.0", "semver": "^7.6.0" } }, "sha512-iV1J+F5KpVqfdYsuot+5b8ZBDH6m/jQN2EzQSoa+qOmHqPNck17AihA4X3sso7ghn7p+AHeOKgftwT64amgmkQ=="],
"fastembed/tar/chownr": ["chownr@2.0.0", "", {}, "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ=="],
@@ -8329,6 +8500,8 @@
"engine.io/accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
+ "expo-asset/@expo/image-utils/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
+
"expo-asset/expo-constants/@expo/config/@expo/config-plugins": ["@expo/config-plugins@55.0.7", "", { "dependencies": { "@expo/config-types": "^55.0.5", "@expo/json-file": "~10.0.12", "@expo/plist": "^0.5.2", "@expo/sdk-runtime-versions": "^1.0.0", "chalk": "^4.1.2", "debug": "^4.3.5", "getenv": "^2.0.0", "glob": "^13.0.0", "resolve-from": "^5.0.0", "semver": "^7.5.4", "slugify": "^1.6.6", "xcode": "^3.0.1", "xml2js": "0.6.0" } }, "sha512-XZUoDWrsHEkH3yasnDSJABM/UxP5a1ixzRwU/M+BToyn/f0nTrSJJe/Ay/FpxkI4JSNz2n0e06I23b2bleXKVA=="],
"expo-asset/expo-constants/@expo/config/@expo/config-types": ["@expo/config-types@55.0.5", "", {}, "sha512-sCmSUZG4mZ/ySXvfyyBdhjivz8Q539X1NondwDdYG7s3SBsk+wsgPJzYsqgAG/P9+l0xWjUD2F+kQ1cAJ6NNLg=="],
@@ -8341,6 +8514,10 @@
"expo-mcp/glob/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="],
+ "expo-splash-screen/@expo/config-plugins/@expo/plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
+
+ "expo-splash-screen/@expo/config-plugins/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
+
"expo/expo-asset/@expo/image-utils/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"fastembed/tar/minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],