+ {isOpen &&
}
+
+
img]:block [&>img]:max-h-[80vh] [&>img]:max-w-full",
+ "[&>img]:h-auto [&>img]:w-auto [&>img]:object-contain [&>img]:select-none",
+ )}
+ />
+ {currentImage && }
+
+
+
+ );
+}
+
+function CloseButton({ onClose }: { onClose: () => void }) {
+ return (
+
+
+
+ );
+}
+
+function Metadata({ image }: { image: LightboxImage }) {
+ const { title, caption, source } = image;
+ const hasTitle = Boolean(title);
+ const hasCaption = Boolean(caption);
+ const hasSource = Boolean(source?.label);
+
+ if (!hasTitle && !hasCaption && !hasSource) {
+ return null;
+ }
+
+ return (
+
+ {hasTitle && (
+
+ {title}
+
+ )}
+ {(hasCaption || hasSource) && (
+
+ {caption}
+ {hasCaption && hasSource && " · "}
+ {hasSource && }
+
+ )}
+
+ );
+}
+
+function SourceLink({
+ source,
+}: {
+ source: NonNullable
;
+}) {
+ const href = resolveSafeNavigationHref(source.url);
+ if (!href) {
+ return <>{source.label}>;
+ }
+
+ return (
+
+ {source.label}
+
+ );
+}
diff --git a/frontend/src/toolui/components/image-gallery/image-gallery.tsx b/frontend/src/toolui/components/image-gallery/image-gallery.tsx
new file mode 100644
index 00000000..a1b9b4d4
--- /dev/null
+++ b/frontend/src/toolui/components/image-gallery/image-gallery.tsx
@@ -0,0 +1,75 @@
+"use client";
+
+import "./styles.css";
+import { cn } from "./_adapter";
+import { ImageGalleryProvider } from "./context";
+import { GalleryGrid } from "./gallery-grid";
+import { GalleryLightbox } from "./gallery-lightbox";
+import type { ImageGalleryProps } from "./schema";
+
+export function ImageGallery({
+ id,
+ images,
+ title,
+ description,
+ className,
+ onImageClick,
+}: ImageGalleryProps) {
+ const handleImageClick = (imageId: string) => {
+ if (!onImageClick) return;
+
+ const image = images.find((img) => img.id === imageId);
+ if (image) {
+ onImageClick(imageId, image);
+ }
+ };
+
+ return (
+
+
+
+ );
+}
+
+interface HeaderProps {
+ title?: string;
+ description?: string;
+}
+
+function Header({ title, description }: HeaderProps) {
+ if (!title && !description) {
+ return null;
+ }
+
+ return (
+
+ {title && (
+
+ {title}
+
+ )}
+ {description && (
+
+ {description}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/toolui/components/image-gallery/index.tsx b/frontend/src/toolui/components/image-gallery/index.tsx
new file mode 100644
index 00000000..54116bc3
--- /dev/null
+++ b/frontend/src/toolui/components/image-gallery/index.tsx
@@ -0,0 +1,6 @@
+export { ImageGallery } from "./image-gallery";
+export type {
+ ImageGalleryProps,
+ ImageGalleryItem,
+ SerializableImageGallery,
+} from "./schema";
diff --git a/frontend/src/toolui/components/image-gallery/schema.ts b/frontend/src/toolui/components/image-gallery/schema.ts
new file mode 100644
index 00000000..f4851383
--- /dev/null
+++ b/frontend/src/toolui/components/image-gallery/schema.ts
@@ -0,0 +1,59 @@
+import { z } from "zod";
+import { defineToolUiContract } from "../shared/contract";
+import {
+ ToolUIIdSchema,
+ ToolUIReceiptSchema,
+ ToolUIRoleSchema,
+} from "../shared/schema";
+
+export const ImageGallerySourceSchema = z.object({
+ label: z.string(),
+ url: z.string().url().optional(),
+});
+
+export type ImageGallerySource = z.infer;
+
+export const ImageGalleryItemSchema = z.object({
+ id: z.string().min(1),
+ src: z.string().url(),
+ alt: z.string().min(1, "Images require alt text for accessibility"),
+ width: z.number().positive(),
+ height: z.number().positive(),
+ title: z.string().optional(),
+ caption: z.string().optional(),
+ source: ImageGallerySourceSchema.optional(),
+});
+
+export type ImageGalleryItem = z.infer;
+
+export const SerializableImageGallerySchema = z.object({
+ id: ToolUIIdSchema,
+ role: ToolUIRoleSchema.optional(),
+ receipt: ToolUIReceiptSchema.optional(),
+ images: z.array(ImageGalleryItemSchema).min(1),
+ title: z.string().optional(),
+ description: z.string().optional(),
+});
+
+export type SerializableImageGallery = z.infer<
+ typeof SerializableImageGallerySchema
+>;
+
+export interface ImageGalleryProps extends SerializableImageGallery {
+ className?: string;
+ onImageClick?: (imageId: string, image: ImageGalleryItem) => void;
+}
+
+const SerializableImageGallerySchemaContract = defineToolUiContract(
+ "ImageGallery",
+ SerializableImageGallerySchema,
+);
+
+export const parseSerializableImageGallery: (
+ input: unknown,
+) => SerializableImageGallery = SerializableImageGallerySchemaContract.parse;
+
+export const safeParseSerializableImageGallery: (
+ input: unknown,
+) => SerializableImageGallery | null =
+ SerializableImageGallerySchemaContract.safeParse;
diff --git a/frontend/src/toolui/components/image-gallery/styles.css b/frontend/src/toolui/components/image-gallery/styles.css
new file mode 100644
index 00000000..5e459035
--- /dev/null
+++ b/frontend/src/toolui/components/image-gallery/styles.css
@@ -0,0 +1,25 @@
+@supports (view-transition-name: none) {
+ ::view-transition-group(active-gallery-image) {
+ animation-duration: 300ms;
+ animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
+ overflow: clip;
+ border-radius: 0.75rem;
+ }
+
+ ::view-transition-image-pair(active-gallery-image) {
+ overflow: clip;
+ border-radius: 0.75rem;
+ }
+
+ ::view-transition-old(active-gallery-image),
+ ::view-transition-new(active-gallery-image) {
+ border-radius: 0.75rem;
+ mix-blend-mode: normal;
+ }
+
+ @media (prefers-reduced-motion: reduce) {
+ ::view-transition-group(active-gallery-image) {
+ animation-duration: 0ms;
+ }
+ }
+}
diff --git a/frontend/src/toolui/components/image/README.md b/frontend/src/toolui/components/image/README.md
new file mode 100644
index 00000000..d690376a
--- /dev/null
+++ b/frontend/src/toolui/components/image/README.md
@@ -0,0 +1,19 @@
+# Image
+
+Implementation for the "image" Tool UI surface.
+
+## Files
+
+- public exports: components/tool-ui/image/index.ts
+- serializable schema + parse helpers: components/tool-ui/image/schema.ts
+
+## Companion assets
+
+- Docs page: app/docs/image/content.mdx
+- Preset payload: lib/presets/image.ts
+
+## Quick check
+
+Run this after edits:
+
+pnpm test
diff --git a/frontend/src/toolui/components/image/_adapter.tsx b/frontend/src/toolui/components/image/_adapter.tsx
new file mode 100644
index 00000000..62da6334
--- /dev/null
+++ b/frontend/src/toolui/components/image/_adapter.tsx
@@ -0,0 +1,11 @@
+/**
+ * Adapter: UI and utility re-exports for copy-standalone portability.
+ *
+ * When copying this component to another project, update these imports
+ * to match your project's paths:
+ *
+ * cn → Your Tailwind merge utility (e.g., "@toolui/lib/utils", "~/lib/cn")
+ */
+"use client";
+
+export { cn } from "@toolui/lib/utils";
diff --git a/frontend/src/toolui/components/image/image.tsx b/frontend/src/toolui/components/image/image.tsx
new file mode 100644
index 00000000..035602ea
--- /dev/null
+++ b/frontend/src/toolui/components/image/image.tsx
@@ -0,0 +1,206 @@
+"use client";
+
+import * as React from "react";
+import { cn } from "./_adapter";
+
+import {
+ RATIO_CLASS_MAP,
+ getFitClass,
+ openSafeNavigationHref,
+ resolveSafeNavigationHref,
+ sanitizeHref,
+} from "../shared/media";
+import type { SerializableImage, Source } from "./schema";
+
+const FALLBACK_LOCALE = "en-US";
+
+export interface ImageProps extends SerializableImage {
+ className?: string;
+ onNavigate?: (href: string, image: SerializableImage) => void;
+}
+
+export function Image(props: ImageProps) {
+ const { className, onNavigate, ...serializable } = props;
+
+ const {
+ id,
+ src,
+ alt,
+ title,
+ href: rawHref,
+ domain,
+ ratio = "auto",
+ fit = "cover",
+ source,
+ locale: providedLocale,
+ } = serializable;
+
+ const locale = providedLocale ?? FALLBACK_LOCALE;
+ const sanitizedHref = sanitizeHref(rawHref);
+ const resolvedSourceUrl = sanitizeHref(source?.url);
+
+ const imageData: SerializableImage = {
+ ...serializable,
+ href: sanitizedHref,
+ source: source ? { ...source, url: resolvedSourceUrl } : undefined,
+ locale,
+ };
+
+ const sourceLabel = source?.label ?? domain;
+ const fallbackInitial = (sourceLabel ?? "").trim().charAt(0).toUpperCase();
+ const hasSource = Boolean(sourceLabel || source?.iconUrl);
+
+ const handleSourceClick = (event: React.MouseEvent) => {
+ event.preventDefault();
+ event.stopPropagation();
+ const targetUrl = resolveSafeNavigationHref(
+ resolvedSourceUrl,
+ source?.url,
+ sanitizedHref,
+ src,
+ );
+ if (!targetUrl) return;
+ if (onNavigate) {
+ onNavigate(targetUrl, imageData);
+ } else {
+ openSafeNavigationHref(targetUrl);
+ }
+ };
+
+ const handleImageClick = () => {
+ if (!sanitizedHref) return;
+ if (onNavigate) {
+ onNavigate(sanitizedHref, imageData);
+ } else {
+ openSafeNavigationHref(sanitizedHref);
+ }
+ };
+
+ const hasMetadata = title || hasSource;
+
+ return (
+
+
+ <>
+
{
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ handleImageClick();
+ }
+ }
+ : undefined
+ }
+ >
+

+
+ {hasMetadata && (
+
+
+
+ )}
+ >
+
+
+ );
+}
+
+interface SourceAttributionProps {
+ source?: Source;
+ sourceLabel?: string;
+ fallbackInitial: string;
+ hasClickableUrl: boolean;
+ onSourceClick: (event: React.MouseEvent) => void;
+ title?: string;
+}
+
+function SourceAttribution({
+ source,
+ sourceLabel,
+ fallbackInitial,
+ hasClickableUrl,
+ onSourceClick,
+ title,
+}: SourceAttributionProps) {
+ const hasSource = Boolean(sourceLabel || source?.iconUrl);
+
+ const content = (
+
+ {source?.iconUrl ? (
+

+ ) : fallbackInitial ? (
+
+ {fallbackInitial}
+
+ ) : null}
+
+ {title && (
+
+ {title}
+
+ )}
+ {sourceLabel && (
+
+ {sourceLabel}
+
+ )}
+
+
+ );
+
+ if (hasClickableUrl && hasSource) {
+ return (
+
+ );
+ }
+
+ return {content}
;
+}
diff --git a/frontend/src/toolui/components/image/index.ts b/frontend/src/toolui/components/image/index.ts
new file mode 100644
index 00000000..ef85f279
--- /dev/null
+++ b/frontend/src/toolui/components/image/index.ts
@@ -0,0 +1,3 @@
+export { Image } from "./image";
+export type { ImageProps } from "./image";
+export type { SerializableImage, Source } from "./schema";
diff --git a/frontend/src/toolui/components/image/schema.ts b/frontend/src/toolui/components/image/schema.ts
new file mode 100644
index 00000000..de490a99
--- /dev/null
+++ b/frontend/src/toolui/components/image/schema.ts
@@ -0,0 +1,50 @@
+import { z } from "zod";
+import { defineToolUiContract } from "../shared/contract";
+import {
+ ToolUIIdSchema,
+ ToolUIReceiptSchema,
+ ToolUIRoleSchema,
+} from "../shared/schema";
+
+import { AspectRatioSchema, MediaFitSchema } from "../shared/media";
+
+export const SourceSchema = z.object({
+ label: z.string(),
+ iconUrl: z.url().optional(),
+ url: z.url().optional(),
+});
+
+export type Source = z.infer;
+
+export const SerializableImageSchema = z.object({
+ id: ToolUIIdSchema,
+ role: ToolUIRoleSchema.optional(),
+ receipt: ToolUIReceiptSchema.optional(),
+ assetId: z.string(),
+ src: z.url(),
+ alt: z.string().min(1, "Images require alt text for accessibility"),
+ title: z.string().optional(),
+ description: z.string().optional(),
+ href: z.url().optional(),
+ domain: z.string().optional(),
+ ratio: AspectRatioSchema.optional(),
+ fit: MediaFitSchema.optional(),
+ fileSizeBytes: z.number().int().positive().optional(),
+ createdAt: z.string().datetime().optional(),
+ locale: z.string().optional(),
+ source: SourceSchema.optional(),
+});
+
+export type SerializableImage = z.infer;
+
+const SerializableImageSchemaContract = defineToolUiContract(
+ "Image",
+ SerializableImageSchema,
+);
+
+export const parseSerializableImage: (input: unknown) => SerializableImage =
+ SerializableImageSchemaContract.parse;
+
+export const safeParseSerializableImage: (
+ input: unknown,
+) => SerializableImage | null = SerializableImageSchemaContract.safeParse;
diff --git a/frontend/src/toolui/components/instagram-post/README.md b/frontend/src/toolui/components/instagram-post/README.md
new file mode 100644
index 00000000..07f8b442
--- /dev/null
+++ b/frontend/src/toolui/components/instagram-post/README.md
@@ -0,0 +1,19 @@
+# Instagram Post
+
+Implementation for the "instagram-post" Tool UI surface.
+
+## Files
+
+- public exports: components/tool-ui/instagram-post/index.ts
+- serializable schema + parse helpers: components/tool-ui/instagram-post/schema.ts
+
+## Companion assets
+
+- Docs page: app/docs/social-post/content.mdx
+- Preset payload: lib/presets/instagram-post.ts
+
+## Quick check
+
+Run this after edits:
+
+pnpm test
diff --git a/frontend/src/toolui/components/instagram-post/_adapter.tsx b/frontend/src/toolui/components/instagram-post/_adapter.tsx
new file mode 100644
index 00000000..c314b96c
--- /dev/null
+++ b/frontend/src/toolui/components/instagram-post/_adapter.tsx
@@ -0,0 +1,19 @@
+/**
+ * Adapter: UI and utility re-exports for copy-standalone portability.
+ *
+ * When copying this component to another project, update these imports
+ * to match your project's paths:
+ *
+ * cn → Your Tailwind merge utility (e.g., "@toolui/lib/utils", "~/lib/cn")
+ * Button → shadcn/ui Button
+ * Tooltip → shadcn/ui Tooltip
+ */
+
+export { cn } from "@toolui/lib/utils";
+export { Button } from "@toolui/ui/button";
+export {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@toolui/ui/tooltip";
diff --git a/frontend/src/toolui/components/instagram-post/index.ts b/frontend/src/toolui/components/instagram-post/index.ts
new file mode 100644
index 00000000..6ee1dace
--- /dev/null
+++ b/frontend/src/toolui/components/instagram-post/index.ts
@@ -0,0 +1,8 @@
+export { InstagramPost } from "./instagram-post";
+export type { InstagramPostProps } from "./instagram-post";
+export type {
+ InstagramPostData,
+ InstagramPostAuthor,
+ InstagramPostMedia,
+ InstagramPostStats,
+} from "./schema";
diff --git a/frontend/src/toolui/components/instagram-post/instagram-post.tsx b/frontend/src/toolui/components/instagram-post/instagram-post.tsx
new file mode 100644
index 00000000..5f543ead
--- /dev/null
+++ b/frontend/src/toolui/components/instagram-post/instagram-post.tsx
@@ -0,0 +1,305 @@
+"use client";
+
+import * as React from "react";
+import { BadgeCheck, Heart, Share } from "lucide-react";
+import {
+ cn,
+ Button,
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "./_adapter";
+import { formatRelativeTime } from "../shared/utils";
+
+import type { InstagramPostData, InstagramPostMedia } from "./schema";
+
+export interface InstagramPostProps {
+ post: InstagramPostData;
+ className?: string;
+ onAction?: (action: string, post: InstagramPostData) => void;
+}
+
+function InstagramLogo({ className }: { className?: string }) {
+ const id = React.useId();
+ const gradientPrimaryId = `ig-primary-${id}`;
+ const gradientSecondaryId = `ig-secondary-${id}`;
+
+ return (
+
+ );
+}
+
+function Header({
+ author,
+ createdAt,
+}: {
+ author: InstagramPostData["author"];
+ createdAt?: string;
+}) {
+ return (
+
+ );
+}
+
+function MediaGrid({
+ media,
+ onOpen,
+}: {
+ media: InstagramPostMedia[];
+ onOpen?: (index: number) => void;
+}) {
+ if (media.length === 0) return null;
+
+ const renderItem = (item: InstagramPostMedia, index: number) => (
+
+ );
+
+ if (media.length === 1) {
+ return (
+
+ {renderItem(media[0], 0)}
+
+ );
+ }
+
+ if (media.length === 2) {
+ return (
+
+ {media.map(renderItem)}
+
+ );
+ }
+
+ if (media.length === 3) {
+ return (
+
+
{renderItem(media[0], 0)}
+
+ {media.slice(1).map((item, i) => (
+
+ {renderItem(item, i + 1)}
+
+ ))}
+
+
+ );
+ }
+
+ return (
+
+ {media.slice(0, 4).map((item, index) => (
+
+ {renderItem(item, index)}
+ {index === 3 && media.length > 4 && (
+
+
+ +{media.length - 4}
+
+
+ )}
+
+ ))}
+
+ );
+}
+
+function PostBody({ text }: { text?: string }) {
+ if (!text) return null;
+ return (
+
+ {text}
+
+ );
+}
+
+function ActionButton({
+ icon: Icon,
+ label,
+ active,
+ hoverColor,
+ activeColor,
+ onClick,
+}: {
+ icon: React.ComponentType<{ className?: string }>;
+ label: string;
+ active?: boolean;
+ hoverColor: string;
+ activeColor?: string;
+ onClick: () => void;
+}) {
+ return (
+
+
+
+
+ {label}
+
+ );
+}
+
+function PostActions({
+ stats,
+ onAction,
+}: {
+ stats?: InstagramPostData["stats"];
+ onAction: (action: string) => void;
+}) {
+ return (
+
+
+
onAction("like")}
+ />
+ onAction("share")}
+ />
+
+
+ );
+}
+
+export function InstagramPost({
+ post,
+ className,
+ onAction,
+}: InstagramPostProps) {
+ return (
+
+
+
+
+ {post.media && post.media.length > 0 && (
+
+ )}
+
+
+
onAction?.(action, post)}
+ />
+ {post.text && (
+
+
+ {post.author.handle}
+ {" "}
+
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/toolui/components/instagram-post/schema.ts b/frontend/src/toolui/components/instagram-post/schema.ts
new file mode 100644
index 00000000..bb8b9202
--- /dev/null
+++ b/frontend/src/toolui/components/instagram-post/schema.ts
@@ -0,0 +1,57 @@
+import { z } from "zod";
+import { defineToolUiContract } from "../shared/contract";
+
+export const InstagramPostAuthorSchema = z.object({
+ name: z.string(),
+ handle: z.string(),
+ avatarUrl: z.string(),
+ verified: z.boolean().optional(),
+});
+
+export const InstagramPostMediaSchema = z.object({
+ type: z.enum(["image", "video"]),
+ url: z.string(),
+ alt: z.string(),
+});
+
+export const InstagramPostStatsSchema = z.object({
+ likes: z.number().optional(),
+ isLiked: z.boolean().optional(),
+});
+
+export interface InstagramPostData {
+ id: string;
+ author: z.infer;
+ text?: string;
+ media?: z.infer[];
+ stats?: z.infer;
+ createdAt?: string;
+}
+
+export const SerializableInstagramPostSchema: z.ZodType =
+ z.object({
+ id: z.string(),
+ author: InstagramPostAuthorSchema,
+ text: z.string().optional(),
+ media: z.array(InstagramPostMediaSchema).optional(),
+ stats: InstagramPostStatsSchema.optional(),
+ createdAt: z.string().optional(),
+ });
+
+export type InstagramPostAuthor = z.infer;
+export type InstagramPostMedia = z.infer;
+export type InstagramPostStats = z.infer;
+
+const SerializableInstagramPostSchemaContract = defineToolUiContract(
+ "InstagramPost",
+ SerializableInstagramPostSchema,
+);
+
+export const parseSerializableInstagramPost: (
+ input: unknown,
+) => InstagramPostData = SerializableInstagramPostSchemaContract.parse;
+
+export const safeParseSerializableInstagramPost: (
+ input: unknown,
+) => InstagramPostData | null =
+ SerializableInstagramPostSchemaContract.safeParse;
diff --git a/frontend/src/toolui/components/item-carousel/README.md b/frontend/src/toolui/components/item-carousel/README.md
new file mode 100644
index 00000000..31c2f314
--- /dev/null
+++ b/frontend/src/toolui/components/item-carousel/README.md
@@ -0,0 +1,19 @@
+# Item Carousel
+
+Implementation for the "item-carousel" Tool UI surface.
+
+## Files
+
+- public exports: components/tool-ui/item-carousel/index.tsx
+- serializable schema + parse helpers: components/tool-ui/item-carousel/schema.ts
+
+## Companion assets
+
+- Docs page: app/docs/item-carousel/content.mdx
+- Preset payload: lib/presets/item-carousel.ts
+
+## Quick check
+
+Run this after edits:
+
+pnpm test
diff --git a/frontend/src/toolui/components/item-carousel/_adapter.tsx b/frontend/src/toolui/components/item-carousel/_adapter.tsx
new file mode 100644
index 00000000..58389306
--- /dev/null
+++ b/frontend/src/toolui/components/item-carousel/_adapter.tsx
@@ -0,0 +1,12 @@
+/**
+ * UI and utility re-exports for copy-standalone portability.
+ *
+ * This file centralizes dependencies so the component can be easily
+ * copied to another project by updating these imports to match the target
+ * project's paths.
+ */
+export { cn } from "@toolui/lib/utils";
+
+export { Button } from "@toolui/ui/button";
+export { Card } from "@toolui/ui/card";
+export { ChevronLeft, ChevronRight } from "lucide-react";
diff --git a/frontend/src/toolui/components/item-carousel/index.tsx b/frontend/src/toolui/components/item-carousel/index.tsx
new file mode 100644
index 00000000..966641cf
--- /dev/null
+++ b/frontend/src/toolui/components/item-carousel/index.tsx
@@ -0,0 +1,8 @@
+export { ItemCarousel } from "./item-carousel";
+export { ItemCard } from "./item-card";
+export type {
+ Item,
+ ItemCarouselProps,
+ SerializableItem,
+ SerializableItemCarousel,
+} from "./schema";
diff --git a/frontend/src/toolui/components/item-carousel/item-card.tsx b/frontend/src/toolui/components/item-carousel/item-card.tsx
new file mode 100644
index 00000000..fe2920e1
--- /dev/null
+++ b/frontend/src/toolui/components/item-carousel/item-card.tsx
@@ -0,0 +1,110 @@
+"use client";
+
+import { cn, Button, Card } from "./_adapter";
+import type { Item } from "./schema";
+
+interface ItemCardProps {
+ item: Item;
+ onItemClick?: (itemId: string) => void;
+ onItemAction?: (itemId: string, actionId: string) => void;
+}
+
+export function ItemCard({ item, onItemClick, onItemAction }: ItemCardProps) {
+ const { id, name, subtitle, image, color, actions } = item;
+ const isCardInteractive = typeof onItemClick === "function";
+
+ const handleCardClick = () => {
+ if (!isCardInteractive) return;
+ onItemClick?.(id);
+ };
+
+ const handleActionClick = (actionId: string) => {
+ onItemAction?.(id, actionId);
+ };
+
+ return (
+
+ {isCardInteractive && (
+
+ )}
+
+
+ {image ? (
+

+ ) : (
+
+ )}
+
+
+
+
+
+ {name}
+
+
+ {subtitle && (
+
+ {subtitle}
+
+ )}
+
+
+ {actions && actions.length > 0 && (
+
+ {actions.map((action) => (
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/toolui/components/item-carousel/item-carousel.tsx b/frontend/src/toolui/components/item-carousel/item-carousel.tsx
new file mode 100644
index 00000000..6c0b601b
--- /dev/null
+++ b/frontend/src/toolui/components/item-carousel/item-carousel.tsx
@@ -0,0 +1,404 @@
+"use client";
+
+import { useRef, useState, useEffect, useCallback } from "react";
+import { cn, Button, Card, ChevronLeft, ChevronRight } from "./_adapter";
+import { ItemCard } from "./item-card";
+import { prefersReducedMotion } from "../shared/utils";
+import type { ItemCarouselProps } from "./schema";
+
+const SCROLL_PADDING_STYLE = { scrollPaddingInline: "1rem" };
+
+const SCROLL_EDGE_THRESHOLD_PX = 8;
+const SNAP_EPSILON_PX = 5;
+const SCROLL_ANIMATION_DURATION_MS = 300;
+const PAGE_SCROLL_RATIO = 0.8;
+const PAGE_SCROLL_BREAKPOINT_PX = 640;
+
+type ScrollDirection = "left" | "right";
+
+interface ScrollAnimationState {
+ target: number;
+ start: number;
+ startTime: number;
+ duration: number;
+ onComplete?: () => void;
+}
+
+function useSmoothScroll() {
+ const animationRef = useRef(null);
+ const frameRef = useRef(null);
+
+ const cancelAnimation = useCallback(() => {
+ if (frameRef.current !== null) {
+ cancelAnimationFrame(frameRef.current);
+ frameRef.current = null;
+ }
+ animationRef.current = null;
+ }, []);
+
+ useEffect(() => cancelAnimation, [cancelAnimation]);
+
+ const scrollTo = useCallback(
+ (
+ element: HTMLElement,
+ target: number,
+ duration = SCROLL_ANIMATION_DURATION_MS,
+ onComplete?: () => void,
+ ) => {
+ if (prefersReducedMotion() || duration <= 0) {
+ element.scrollLeft = target;
+ onComplete?.();
+ return;
+ }
+
+ cancelAnimation();
+
+ animationRef.current = {
+ target,
+ start: element.scrollLeft,
+ startTime: performance.now(),
+ duration,
+ onComplete,
+ };
+
+ element.style.scrollSnapType = "none";
+
+ const step = () => {
+ const anim = animationRef.current;
+ if (!anim) return;
+
+ const elapsed = performance.now() - anim.startTime;
+ const progress = Math.min(elapsed / anim.duration, 1);
+ const eased = 1 - Math.pow(1 - progress, 3);
+
+ element.scrollLeft = anim.start + (anim.target - anim.start) * eased;
+
+ if (progress < 1) {
+ frameRef.current = requestAnimationFrame(step);
+ return;
+ }
+
+ element.scrollLeft = anim.target;
+ const callback = anim.onComplete;
+ cancelAnimation();
+
+ requestAnimationFrame(() => {
+ element.style.scrollSnapType = "";
+ callback?.();
+ });
+ };
+
+ frameRef.current = requestAnimationFrame(step);
+ },
+ [cancelAnimation],
+ );
+
+ const isAnimating = useCallback(
+ () => animationRef.current !== null && frameRef.current !== null,
+ [],
+ );
+
+ return { scrollTo, isAnimating, cancelAnimation };
+}
+
+function useScrollEdgeState(
+ scrollRef: React.RefObject,
+ itemCount: number,
+) {
+ const [canScrollLeft, setCanScrollLeft] = useState(false);
+ const [canScrollRight, setCanScrollRight] = useState(false);
+
+ const updateState = useCallback(() => {
+ const container = scrollRef.current;
+ if (!container) return;
+
+ const scrollLeft = Math.round(container.scrollLeft);
+ const maxScroll = Math.max(
+ 0,
+ Math.round(container.scrollWidth - container.clientWidth),
+ );
+
+ setCanScrollLeft(scrollLeft > SCROLL_EDGE_THRESHOLD_PX);
+ setCanScrollRight(scrollLeft < maxScroll - SCROLL_EDGE_THRESHOLD_PX);
+ }, [scrollRef]);
+
+ useEffect(() => {
+ const container = scrollRef.current;
+ if (!container) return;
+
+ let rafId: number | null = null;
+
+ const scheduleUpdate = () => {
+ if (rafId !== null) cancelAnimationFrame(rafId);
+ rafId = requestAnimationFrame(() => {
+ rafId = null;
+ updateState();
+ });
+ };
+
+ scheduleUpdate();
+
+ container.addEventListener("scroll", scheduleUpdate, { passive: true });
+ const resizeObserver = new ResizeObserver(scheduleUpdate);
+ resizeObserver.observe(container);
+
+ return () => {
+ container.removeEventListener("scroll", scheduleUpdate);
+ resizeObserver.disconnect();
+ if (rafId !== null) cancelAnimationFrame(rafId);
+ };
+ }, [scrollRef, updateState, itemCount]);
+
+ return { canScrollLeft, canScrollRight };
+}
+
+function CarouselNavButton({
+ direction,
+ visible,
+ onClick,
+}: {
+ direction: ScrollDirection;
+ visible: boolean;
+ onClick: () => void;
+}) {
+ const isLeft = direction === "left";
+ const Icon = isLeft ? ChevronLeft : ChevronRight;
+
+ return (
+
+ );
+}
+
+interface ItemCarouselHeaderProps {
+ title?: string;
+ description?: string;
+}
+
+function ItemCarouselHeader({ title, description }: ItemCarouselHeaderProps) {
+ if (!title && !description) return null;
+
+ return (
+
+ {title && (
+
+ {title}
+
+ )}
+ {description && (
+
+ {description}
+
+ )}
+
+ );
+}
+
+interface EmptyStateProps {
+ id: string;
+ className?: string;
+}
+
+function EmptyState({ id, className }: EmptyStateProps) {
+ return (
+
+ No items to display
+
+ );
+}
+
+function ItemCarouselRoot({
+ id,
+ title,
+ description,
+ items,
+ className,
+ onItemClick,
+ onItemAction,
+}: ItemCarouselProps) {
+ const scrollRef = useRef(null);
+ const targetIndexRef = useRef(null);
+
+ const { scrollTo, isAnimating } = useSmoothScroll();
+ const { canScrollLeft, canScrollRight } = useScrollEdgeState(
+ scrollRef,
+ items.length,
+ );
+
+ const scroll = useCallback(
+ (direction: ScrollDirection) => {
+ const container = scrollRef.current;
+ if (!container) return;
+
+ const paddingValue = window.getComputedStyle(container).scrollPaddingLeft;
+ const scrollPaddingLeft = Number.isFinite(Number.parseFloat(paddingValue))
+ ? Number.parseFloat(paddingValue)
+ : 0;
+
+ const itemElements = Array.from(
+ container.querySelectorAll("[data-carousel-item]"),
+ );
+ if (itemElements.length === 0) return;
+
+ const snapPositions = itemElements.map((el) =>
+ Math.max(0, el.offsetLeft - scrollPaddingLeft),
+ );
+
+ const scrollLeft = Math.round(container.scrollLeft);
+ let currentIndex: number;
+ if (isAnimating()) {
+ currentIndex = Math.min(
+ targetIndexRef.current ?? 0,
+ snapPositions.length - 1,
+ );
+ } else {
+ currentIndex = snapPositions.length - 1;
+ for (let i = 0; i < snapPositions.length; i++) {
+ const snap = snapPositions[i];
+ if (Math.abs(snap - scrollLeft) < SNAP_EPSILON_PX) {
+ currentIndex = i;
+ break;
+ }
+ if (snap > scrollLeft) {
+ currentIndex = Math.max(0, i - 1);
+ break;
+ }
+ }
+ }
+
+ const itemStep =
+ itemElements.length > 1
+ ? itemElements[1].offsetLeft - itemElements[0].offsetLeft
+ : 0;
+ const safeStep =
+ itemStep > 0 ? itemStep : itemElements[0].offsetWidth || 1;
+
+ const pageIndexStep =
+ container.clientWidth >= PAGE_SCROLL_BREAKPOINT_PX
+ ? Math.max(
+ 1,
+ Math.floor(
+ (container.clientWidth * PAGE_SCROLL_RATIO) / safeStep,
+ ),
+ )
+ : 1;
+
+ const targetIndex =
+ direction === "right"
+ ? Math.min(currentIndex + pageIndexStep, itemElements.length - 1)
+ : Math.max(currentIndex - pageIndexStep, 0);
+
+ targetIndexRef.current = targetIndex;
+ const targetScrollLeft = snapPositions[targetIndex];
+
+ if (Math.abs(targetScrollLeft - container.scrollLeft) > 1) {
+ scrollTo(
+ container,
+ targetScrollLeft,
+ SCROLL_ANIMATION_DURATION_MS,
+ () => {
+ targetIndexRef.current = null;
+ },
+ );
+ }
+ },
+ [scrollTo, isAnimating],
+ );
+
+ const handleScrollLeft = useCallback(() => scroll("left"), [scroll]);
+ const handleScrollRight = useCallback(() => scroll("right"), [scroll]);
+
+ if (items.length === 0) {
+ return ;
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ {items.map((item) => (
+
+
+
+ ))}
+
+
+
+ );
+}
+
+type ItemCarouselComponent = typeof ItemCarouselRoot & {
+ Root: typeof ItemCarouselRoot;
+ Header: typeof ItemCarouselHeader;
+ EmptyState: typeof EmptyState;
+ NavButton: typeof CarouselNavButton;
+ Card: typeof ItemCard;
+};
+
+export const ItemCarousel = Object.assign(ItemCarouselRoot, {
+ Root: ItemCarouselRoot,
+ Header: ItemCarouselHeader,
+ EmptyState,
+ NavButton: CarouselNavButton,
+ Card: ItemCard,
+}) as ItemCarouselComponent;
diff --git a/frontend/src/toolui/components/item-carousel/schema.ts b/frontend/src/toolui/components/item-carousel/schema.ts
new file mode 100644
index 00000000..d20a72b5
--- /dev/null
+++ b/frontend/src/toolui/components/item-carousel/schema.ts
@@ -0,0 +1,77 @@
+import { z } from "zod";
+import { defineToolUiContract } from "../shared/contract";
+import {
+ ActionSchema,
+ SerializableActionSchema,
+ ToolUIIdSchema,
+} from "../shared/schema";
+
+export const ItemSchema = z.object({
+ id: z.string().min(1),
+ name: z.string().min(1),
+ subtitle: z.string().optional(),
+ image: z.url().optional(),
+ color: z.string().optional(),
+ actions: z.array(ActionSchema).optional(),
+});
+
+export const ItemCarouselPropsSchema = z.object({
+ id: ToolUIIdSchema,
+ title: z.string().optional(),
+ description: z.string().optional(),
+ items: z.array(ItemSchema),
+ className: z.string().optional(),
+});
+
+export type Item = z.infer;
+
+export type ItemCarouselProps = z.infer & {
+ onItemClick?: (itemId: string) => void;
+ onItemAction?: (itemId: string, actionId: string) => void;
+};
+
+export const SerializableItemSchema = ItemSchema.extend({
+ actions: z.array(SerializableActionSchema).optional(),
+});
+
+export const SerializableItemCarouselSchema = ItemCarouselPropsSchema.omit({
+ className: true,
+})
+ .extend({
+ items: z.array(SerializableItemSchema),
+ })
+ .superRefine((payload, ctx) => {
+ const seenItemIds = new Map();
+
+ payload.items.forEach((item, index) => {
+ const firstSeenAt = seenItemIds.get(item.id);
+ if (firstSeenAt !== undefined) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ["items", index, "id"],
+ message: `duplicate item id '${item.id}' (first seen at index ${firstSeenAt})`,
+ });
+ return;
+ }
+ seenItemIds.set(item.id, index);
+ });
+ });
+
+export type SerializableItem = z.infer;
+export type SerializableItemCarousel = z.infer<
+ typeof SerializableItemCarouselSchema
+>;
+
+const SerializableItemCarouselSchemaContract = defineToolUiContract(
+ "ItemCarousel",
+ SerializableItemCarouselSchema,
+);
+
+export const parseSerializableItemCarousel: (
+ input: unknown,
+) => SerializableItemCarousel = SerializableItemCarouselSchemaContract.parse;
+
+export const safeParseSerializableItemCarousel: (
+ input: unknown,
+) => SerializableItemCarousel | null =
+ SerializableItemCarouselSchemaContract.safeParse;
diff --git a/frontend/src/toolui/components/link-preview/README.md b/frontend/src/toolui/components/link-preview/README.md
new file mode 100644
index 00000000..0fab3dbb
--- /dev/null
+++ b/frontend/src/toolui/components/link-preview/README.md
@@ -0,0 +1,19 @@
+# Link Preview
+
+Implementation for the "link-preview" Tool UI surface.
+
+## Files
+
+- public exports: components/tool-ui/link-preview/index.ts
+- serializable schema + parse helpers: components/tool-ui/link-preview/schema.ts
+
+## Companion assets
+
+- Docs page: app/docs/link-preview/content.mdx
+- Preset payload: lib/presets/link-preview.ts
+
+## Quick check
+
+Run this after edits:
+
+pnpm test
diff --git a/frontend/src/toolui/components/link-preview/_adapter.tsx b/frontend/src/toolui/components/link-preview/_adapter.tsx
new file mode 100644
index 00000000..ac928498
--- /dev/null
+++ b/frontend/src/toolui/components/link-preview/_adapter.tsx
@@ -0,0 +1,6 @@
+/**
+ * Adapter: UI and utility re-exports for copy-standalone portability.
+ */
+"use client";
+
+export { cn } from "@toolui/lib/utils";
diff --git a/frontend/src/toolui/components/link-preview/index.ts b/frontend/src/toolui/components/link-preview/index.ts
new file mode 100644
index 00000000..cea9fe77
--- /dev/null
+++ b/frontend/src/toolui/components/link-preview/index.ts
@@ -0,0 +1,3 @@
+export { LinkPreview } from "./link-preview";
+export type { LinkPreviewProps } from "./link-preview";
+export type { SerializableLinkPreview } from "./schema";
diff --git a/frontend/src/toolui/components/link-preview/link-preview.tsx b/frontend/src/toolui/components/link-preview/link-preview.tsx
new file mode 100644
index 00000000..9d2ae161
--- /dev/null
+++ b/frontend/src/toolui/components/link-preview/link-preview.tsx
@@ -0,0 +1,141 @@
+"use client";
+
+import { Globe } from "lucide-react";
+import { cn } from "./_adapter";
+
+import {
+ RATIO_CLASS_MAP,
+ getFitClass,
+ openSafeNavigationHref,
+ sanitizeHref,
+} from "../shared/media";
+import type { SerializableLinkPreview } from "./schema";
+
+const FALLBACK_LOCALE = "en-US";
+const CONTENT_SPACING = "px-5 py-4 gap-2";
+
+export interface LinkPreviewProps extends SerializableLinkPreview {
+ className?: string;
+ onNavigate?: (href: string, preview: SerializableLinkPreview) => void;
+}
+
+export function LinkPreview(props: LinkPreviewProps) {
+ const { className, onNavigate, ...serializable } = props;
+
+ const {
+ id,
+ href: rawHref,
+ title,
+ description,
+ image,
+ domain,
+ favicon,
+ ratio = "16:9",
+ fit = "cover",
+ locale: providedLocale,
+ } = serializable;
+
+ const locale = providedLocale ?? FALLBACK_LOCALE;
+ const sanitizedHref = sanitizeHref(rawHref);
+
+ const previewData: SerializableLinkPreview = {
+ ...serializable,
+ href: sanitizedHref ?? rawHref,
+ locale,
+ };
+
+ const handleClick = () => {
+ if (!sanitizedHref) return;
+ if (onNavigate) {
+ onNavigate(sanitizedHref, previewData);
+ } else {
+ openSafeNavigationHref(sanitizedHref);
+ }
+ };
+
+ return (
+
+ {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ handleClick();
+ }
+ }
+ : undefined
+ }
+ >
+
+ {image && (
+
+

+
+ )}
+
+ {domain && (
+
+ {favicon ? (
+

+ ) : (
+
+
+
+ )}
+
{domain}
+
+ )}
+ {title && (
+
+ {title}
+
+ )}
+ {description && (
+
+ {description}
+
+ )}
+
+
+
+
+ );
+}
diff --git a/frontend/src/toolui/components/link-preview/schema.ts b/frontend/src/toolui/components/link-preview/schema.ts
new file mode 100644
index 00000000..3bde91b5
--- /dev/null
+++ b/frontend/src/toolui/components/link-preview/schema.ts
@@ -0,0 +1,43 @@
+import { z } from "zod";
+import { defineToolUiContract } from "../shared/contract";
+import {
+ ToolUIIdSchema,
+ ToolUIReceiptSchema,
+ ToolUIRoleSchema,
+} from "../shared/schema";
+
+import { AspectRatioSchema, MediaFitSchema } from "../shared/media";
+
+export const SerializableLinkPreviewSchema = z.object({
+ id: ToolUIIdSchema,
+ role: ToolUIRoleSchema.optional(),
+ receipt: ToolUIReceiptSchema.optional(),
+ href: z.url(),
+ title: z.string().optional(),
+ description: z.string().optional(),
+ image: z.url().optional(),
+ domain: z.string().optional(),
+ favicon: z.url().optional(),
+ ratio: AspectRatioSchema.optional(),
+ fit: MediaFitSchema.optional(),
+ createdAt: z.string().datetime().optional(),
+ locale: z.string().optional(),
+});
+
+export type SerializableLinkPreview = z.infer<
+ typeof SerializableLinkPreviewSchema
+>;
+
+const SerializableLinkPreviewSchemaContract = defineToolUiContract(
+ "LinkPreview",
+ SerializableLinkPreviewSchema,
+);
+
+export const parseSerializableLinkPreview: (
+ input: unknown,
+) => SerializableLinkPreview = SerializableLinkPreviewSchemaContract.parse;
+
+export const safeParseSerializableLinkPreview: (
+ input: unknown,
+) => SerializableLinkPreview | null =
+ SerializableLinkPreviewSchemaContract.safeParse;
diff --git a/frontend/src/toolui/components/linkedin-post/README.md b/frontend/src/toolui/components/linkedin-post/README.md
new file mode 100644
index 00000000..1869eff8
--- /dev/null
+++ b/frontend/src/toolui/components/linkedin-post/README.md
@@ -0,0 +1,19 @@
+# Linkedin Post
+
+Implementation for the "linkedin-post" Tool UI surface.
+
+## Files
+
+- public exports: components/tool-ui/linkedin-post/index.ts
+- serializable schema + parse helpers: components/tool-ui/linkedin-post/schema.ts
+
+## Companion assets
+
+- Docs page: app/docs/social-post/content.mdx
+- Preset payload: lib/presets/linkedin-post.ts
+
+## Quick check
+
+Run this after edits:
+
+pnpm test
diff --git a/frontend/src/toolui/components/linkedin-post/_adapter.tsx b/frontend/src/toolui/components/linkedin-post/_adapter.tsx
new file mode 100644
index 00000000..c314b96c
--- /dev/null
+++ b/frontend/src/toolui/components/linkedin-post/_adapter.tsx
@@ -0,0 +1,19 @@
+/**
+ * Adapter: UI and utility re-exports for copy-standalone portability.
+ *
+ * When copying this component to another project, update these imports
+ * to match your project's paths:
+ *
+ * cn → Your Tailwind merge utility (e.g., "@toolui/lib/utils", "~/lib/cn")
+ * Button → shadcn/ui Button
+ * Tooltip → shadcn/ui Tooltip
+ */
+
+export { cn } from "@toolui/lib/utils";
+export { Button } from "@toolui/ui/button";
+export {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@toolui/ui/tooltip";
diff --git a/frontend/src/toolui/components/linkedin-post/index.ts b/frontend/src/toolui/components/linkedin-post/index.ts
new file mode 100644
index 00000000..2fd27172
--- /dev/null
+++ b/frontend/src/toolui/components/linkedin-post/index.ts
@@ -0,0 +1,9 @@
+export { LinkedInPost } from "./linkedin-post";
+export type { LinkedInPostProps } from "./linkedin-post";
+export type {
+ LinkedInPostData,
+ LinkedInPostAuthor,
+ LinkedInPostMedia,
+ LinkedInPostLinkPreview,
+ LinkedInPostStats,
+} from "./schema";
diff --git a/frontend/src/toolui/components/linkedin-post/linkedin-post.tsx b/frontend/src/toolui/components/linkedin-post/linkedin-post.tsx
new file mode 100644
index 00000000..51fcd221
--- /dev/null
+++ b/frontend/src/toolui/components/linkedin-post/linkedin-post.tsx
@@ -0,0 +1,283 @@
+"use client";
+
+import * as React from "react";
+import { ThumbsUp, Share } from "lucide-react";
+import {
+ cn,
+ Button,
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "./_adapter";
+import { formatCount, formatRelativeTime, getDomain } from "../shared/utils";
+
+import { resolveSafeNavigationHref } from "../shared/media";
+import type {
+ LinkedInPostData,
+ LinkedInPostMedia,
+ LinkedInPostLinkPreview,
+} from "./schema";
+
+const TEXT_PREVIEW_LENGTH = 280;
+
+export interface LinkedInPostProps {
+ post: LinkedInPostData;
+ className?: string;
+ onAction?: (action: string, post: LinkedInPostData) => void;
+}
+
+function LinkedInLogo({ className }: { className?: string }) {
+ return (
+
+ );
+}
+
+function Header({
+ author,
+ createdAt,
+}: {
+ author: LinkedInPostData["author"];
+ createdAt?: string;
+}) {
+ return (
+
+ );
+}
+
+function PostBody({ text }: { text?: string }) {
+ const [isExpanded, setIsExpanded] = React.useState(false);
+ const shouldTruncate = text && text.length > TEXT_PREVIEW_LENGTH;
+
+ if (!text) return null;
+
+ return (
+
+ {shouldTruncate && !isExpanded ? (
+ <>
+ {text.slice(0, TEXT_PREVIEW_LENGTH)}
+ ...
+
+ >
+ ) : (
+ text
+ )}
+
+ );
+}
+
+function PostMedia({ media }: { media: LinkedInPostMedia }) {
+ return (
+
+ {media.type === "image" ? (
+

+ ) : (
+
+ )}
+
+ );
+}
+
+function PostLinkPreview({ preview }: { preview: LinkedInPostLinkPreview }) {
+ const href = resolveSafeNavigationHref(preview.url);
+ const domain = preview.domain ?? getDomain(preview.url);
+ const content = (
+ <>
+ {preview.imageUrl && (
+
+ )}
+
+ {preview.title && (
+
+ {preview.title}
+
+ )}
+ {domain && (
+
{domain}
+ )}
+
+ >
+ );
+
+ if (!href) {
+ return (
+ {content}
+ );
+ }
+
+ return (
+
+ {content}
+
+ );
+}
+
+function ActionButton({
+ icon: Icon,
+ label,
+ count,
+ active,
+ hoverColor,
+ activeColor,
+ onClick,
+}: {
+ icon: React.ComponentType<{ className?: string }>;
+ label: string;
+ count?: number;
+ active?: boolean;
+ hoverColor: string;
+ activeColor?: string;
+ onClick: () => void;
+}) {
+ return (
+
+
+
+
+ {label}
+
+ );
+}
+
+function PostActions({
+ stats,
+ onAction,
+}: {
+ stats?: LinkedInPostData["stats"];
+ onAction: (action: string) => void;
+}) {
+ return (
+
+
+
onAction("like")}
+ />
+ onAction("share")}
+ />
+
+
+ );
+}
+
+export function LinkedInPost({ post, className, onAction }: LinkedInPostProps) {
+ return (
+
+
+
+
+
+ {post.media && }
+
+ {post.linkPreview && !post.media && (
+
+ )}
+
+ onAction?.(action, post)}
+ />
+
+
+ );
+}
diff --git a/frontend/src/toolui/components/linkedin-post/schema.ts b/frontend/src/toolui/components/linkedin-post/schema.ts
new file mode 100644
index 00000000..177f5b4c
--- /dev/null
+++ b/frontend/src/toolui/components/linkedin-post/schema.ts
@@ -0,0 +1,59 @@
+import { z } from "zod";
+import { defineToolUiContract } from "../shared/contract";
+
+export const LinkedInPostAuthorSchema = z.object({
+ name: z.string(),
+ avatarUrl: z.string(),
+ headline: z.string().optional(),
+});
+
+export const LinkedInPostMediaSchema = z.object({
+ type: z.enum(["image", "video"]),
+ url: z.string(),
+ alt: z.string(),
+});
+
+export const LinkedInPostLinkPreviewSchema = z.object({
+ url: z.string(),
+ title: z.string().optional(),
+ description: z.string().optional(),
+ imageUrl: z.string().optional(),
+ domain: z.string().optional(),
+});
+
+export const LinkedInPostStatsSchema = z.object({
+ likes: z.number().optional(),
+ isLiked: z.boolean().optional(),
+});
+
+export const SerializableLinkedInPostSchema = z.object({
+ id: z.string(),
+ author: LinkedInPostAuthorSchema,
+ text: z.string().optional(),
+ media: LinkedInPostMediaSchema.optional(),
+ linkPreview: LinkedInPostLinkPreviewSchema.optional(),
+ stats: LinkedInPostStatsSchema.optional(),
+ createdAt: z.string().optional(),
+});
+
+export type LinkedInPostData = z.infer;
+
+export type LinkedInPostAuthor = z.infer;
+export type LinkedInPostMedia = z.infer;
+export type LinkedInPostLinkPreview = z.infer<
+ typeof LinkedInPostLinkPreviewSchema
+>;
+export type LinkedInPostStats = z.infer;
+
+const SerializableLinkedInPostSchemaContract = defineToolUiContract(
+ "LinkedInPost",
+ SerializableLinkedInPostSchema,
+);
+
+export const parseSerializableLinkedInPost: (
+ input: unknown,
+) => LinkedInPostData = SerializableLinkedInPostSchemaContract.parse;
+
+export const safeParseSerializableLinkedInPost: (
+ input: unknown,
+) => LinkedInPostData | null = SerializableLinkedInPostSchemaContract.safeParse;
diff --git a/frontend/src/toolui/components/message-draft/README.md b/frontend/src/toolui/components/message-draft/README.md
new file mode 100644
index 00000000..48ed63e2
--- /dev/null
+++ b/frontend/src/toolui/components/message-draft/README.md
@@ -0,0 +1,19 @@
+# Message Draft
+
+Implementation for the "message-draft" Tool UI surface.
+
+## Files
+
+- public exports: components/tool-ui/message-draft/index.tsx
+- serializable schema + parse helpers: components/tool-ui/message-draft/schema.ts
+
+## Companion assets
+
+- Docs page: app/docs/message-draft/content.mdx
+- Preset payload: lib/presets/message-draft.ts
+
+## Quick check
+
+Run this after edits:
+
+pnpm test
diff --git a/frontend/src/toolui/components/message-draft/_adapter.tsx b/frontend/src/toolui/components/message-draft/_adapter.tsx
new file mode 100644
index 00000000..4d2303fd
--- /dev/null
+++ b/frontend/src/toolui/components/message-draft/_adapter.tsx
@@ -0,0 +1,12 @@
+/**
+ * Adapter: UI and utility re-exports for copy-standalone portability.
+ *
+ * When copying this component to another project, update these imports
+ * to match your project's paths:
+ *
+ * cn → Your Tailwind merge utility (e.g., "@toolui/lib/utils", "~/lib/cn")
+ * Button → shadcn/ui Button
+ */
+
+export { cn } from "@toolui/lib/utils";
+export { Button } from "@toolui/ui/button";
diff --git a/frontend/src/toolui/components/message-draft/index.tsx b/frontend/src/toolui/components/message-draft/index.tsx
new file mode 100644
index 00000000..9970ea34
--- /dev/null
+++ b/frontend/src/toolui/components/message-draft/index.tsx
@@ -0,0 +1,10 @@
+export { MessageDraft } from "./message-draft";
+export {
+ type SerializableMessageDraft,
+ type SerializableEmailDraft,
+ type SerializableSlackDraft,
+ type MessageDraftChannel,
+ type MessageDraftOutcome,
+ type SlackTarget,
+ type MessageDraftProps,
+} from "./schema";
diff --git a/frontend/src/toolui/components/message-draft/message-draft.tsx b/frontend/src/toolui/components/message-draft/message-draft.tsx
new file mode 100644
index 00000000..1753b795
--- /dev/null
+++ b/frontend/src/toolui/components/message-draft/message-draft.tsx
@@ -0,0 +1,511 @@
+"use client";
+
+import * as React from "react";
+import { cn, Button } from "./_adapter";
+import type {
+ MessageDraftProps,
+ SerializableEmailDraft,
+ SerializableSlackDraft,
+} from "./schema";
+import { ActionButtons } from "../shared/action-buttons";
+import type { Action } from "../shared/schema";
+import { Check, ChevronDown } from "lucide-react";
+
+type DraftState = "review" | "sending" | "sent" | "cancelled";
+type DraftOutcome = MessageDraftProps["outcome"];
+
+const DEFAULT_GRACE_PERIOD = 5000;
+const COLLAPSED_BODY_HEIGHT = 280;
+
+interface RecipientRowProps {
+ label: string;
+ recipients: string[];
+ maxVisible?: number;
+ muted?: boolean;
+}
+
+function RecipientRow({
+ label,
+ recipients,
+ maxVisible = 3,
+ muted = false,
+}: RecipientRowProps) {
+ const visibleRecipients = recipients.slice(0, maxVisible);
+ const overflowCount = recipients.length - maxVisible;
+
+ return (
+
+ |
+ {label}
+ |
+
+ {visibleRecipients.join(", ")}
+ {overflowCount > 0 && (
+ +{overflowCount} more
+ )}
+ |
+
+ );
+}
+
+interface SingleFieldRowProps {
+ label: string;
+ value: string;
+}
+
+function SingleFieldRow({ label, value }: SingleFieldRowProps) {
+ return (
+
+ |
+ {label}
+ |
+ {value} |
+
+ );
+}
+
+interface ExpandableBodyProps {
+ body: string;
+ isExpanded: boolean;
+ onNeedsExpansionChange?: (needsExpansion: boolean) => void;
+}
+
+function ExpandableBody({
+ body,
+ isExpanded,
+ onNeedsExpansionChange,
+}: ExpandableBodyProps) {
+ const [needsExpansion, setNeedsExpansion] = React.useState(
+ null,
+ );
+ const contentRef = React.useRef(null);
+
+ React.useLayoutEffect(() => {
+ if (contentRef.current) {
+ const needs = contentRef.current.scrollHeight > COLLAPSED_BODY_HEIGHT;
+ setNeedsExpansion(needs);
+ onNeedsExpansionChange?.(needs);
+ }
+ }, [body, onNeedsExpansionChange]);
+
+ return (
+
+
+ {needsExpansion && (
+
+ )}
+
+ );
+}
+
+interface EmailDraftContentProps {
+ draft: SerializableEmailDraft;
+ titleId: string;
+ isExpanded: boolean;
+ onNeedsExpansionChange?: (needsExpansion: boolean) => void;
+}
+
+function EmailDraftContent({
+ draft,
+ titleId,
+ isExpanded,
+ onNeedsExpansionChange,
+}: EmailDraftContentProps) {
+ return (
+ <>
+
+ {draft.subject}
+
+
+
+
+ {draft.from && }
+
+ {draft.cc && draft.cc.length > 0 && (
+
+ )}
+ {draft.bcc && draft.bcc.length > 0 && (
+
+ )}
+
+
+
+
+
+
+ >
+ );
+}
+
+interface SlackDraftContentProps {
+ draft: SerializableSlackDraft;
+ titleId: string;
+ isExpanded: boolean;
+ onNeedsExpansionChange?: (needsExpansion: boolean) => void;
+}
+
+function SlackLogo({ className }: { className?: string }) {
+ return (
+
+ );
+}
+
+function SlackDraftContent({
+ draft,
+ titleId,
+ isExpanded,
+ onNeedsExpansionChange,
+}: SlackDraftContentProps) {
+ const { target } = draft;
+ const isChannel = target.type === "channel";
+ const targetDisplay = isChannel
+ ? `#${target.name}`
+ : `Message to @${target.name}`;
+ const memberCount = isChannel ? target.memberCount : undefined;
+
+ return (
+ <>
+
+
+ {targetDisplay}
+ {memberCount !== undefined && (
+
+ {memberCount.toLocaleString()} members
+
+ )}
+
+
+
+
+
+ >
+ );
+}
+
+function formatSentTime(date: Date): string {
+ return date.toLocaleTimeString(undefined, {
+ hour: "numeric",
+ minute: "2-digit",
+ });
+}
+
+export function resolveStateFromOutcome(outcome: DraftOutcome): DraftState {
+ if (outcome === "sent") return "sent";
+ if (outcome === "cancelled") return "cancelled";
+ return "review";
+}
+
+export function resolveOutcomeTransition(
+ previousOutcome: DraftOutcome,
+ nextOutcome: DraftOutcome,
+): DraftState | null {
+ if (previousOutcome === nextOutcome) {
+ return null;
+ }
+
+ return resolveStateFromOutcome(nextOutcome);
+}
+
+interface SentConfirmationProps {
+ sentAt: Date;
+}
+
+function SentConfirmation({ sentAt }: SentConfirmationProps) {
+ return (
+
+
+ Sent at {formatSentTime(sentAt)}
+
+
+
+
+
+ );
+}
+
+export function MessageDraft(props: MessageDraftProps) {
+ const {
+ id,
+ className,
+ outcome,
+ undoGracePeriod = DEFAULT_GRACE_PERIOD,
+ onSend,
+ onUndo,
+ onCancel,
+ } = props;
+
+ const [state, setState] = React.useState(() =>
+ resolveStateFromOutcome(outcome),
+ );
+ const [countdown, setCountdown] = React.useState(
+ Math.ceil(undoGracePeriod / 1000),
+ );
+ const [sentAt, setSentAt] = React.useState(() =>
+ outcome === "sent" ? new Date() : null,
+ );
+ const [isExpanded, setIsExpanded] = React.useState(false);
+ const [needsExpansion, setNeedsExpansion] = React.useState(false);
+ const undoButtonRef = React.useRef(null);
+ const timerRef = React.useRef | null>(null);
+ const countdownRef = React.useRef