diff --git a/.cursor/plans/context_menu_ux_redesign_04cf3a1b.plan.md b/.cursor/plans/complete/context_menu_ux_redesign_04cf3a1b.plan.md
similarity index 100%
rename from .cursor/plans/context_menu_ux_redesign_04cf3a1b.plan.md
rename to .cursor/plans/complete/context_menu_ux_redesign_04cf3a1b.plan.md
diff --git a/.cursor/plans/custom-pointer-performance-optimization.plan.md b/.cursor/plans/complete/custom-pointer-performance-optimization.plan.md
similarity index 100%
rename from .cursor/plans/custom-pointer-performance-optimization.plan.md
rename to .cursor/plans/complete/custom-pointer-performance-optimization.plan.md
diff --git a/.cursor/plans/devtools-inspect-opens-panel_e0886321.plan.md b/.cursor/plans/complete/devtools-inspect-opens-panel_e0886321.plan.md
similarity index 67%
rename from .cursor/plans/devtools-inspect-opens-panel_e0886321.plan.md
rename to .cursor/plans/complete/devtools-inspect-opens-panel_e0886321.plan.md
index 1cd49ea..6bc1fd8 100644
--- a/.cursor/plans/devtools-inspect-opens-panel_e0886321.plan.md
+++ b/.cursor/plans/complete/devtools-inspect-opens-panel_e0886321.plan.md
@@ -1,21 +1,3 @@
----
-name: devtools-inspect-opens-panel
-overview: Route the context menu Inspect action to open/focus the Anyclick DevTools panel and stop sending element context payloads elsewhere.
-todos:
- - id: trace-flow
- content: Trace current inspect flow across content/background/devtools
- status: completed
- - id: stop-send
- content: Remove inspect element sending to adapters/queue
- status: completed
- - id: open-panel
- content: Open/focus Anyclick DevTools panel on inspect
- status: completed
- - id: verify
- content: Manual test inspect menu opens Anyclick panel
- status: completed
----
-
# DevTools Inspect Opens Panel
1) Trace current Inspect flow
diff --git a/.cursor/plans/featuressection_interactive_cards_b6f1d608.plan.md b/.cursor/plans/featuressection_interactive_cards_b6f1d608.plan.md
new file mode 100644
index 0000000..eedd0b7
--- /dev/null
+++ b/.cursor/plans/featuressection_interactive_cards_b6f1d608.plan.md
@@ -0,0 +1,762 @@
+# FeaturesSection Interactive Cards + Global Submission UX
+
+## Overview
+
+This plan covers two related improvements:
+
+1. **FeaturesSection Enhancement** - Expandable feature cards with animated workflow visualizations that demonstrate what each Anyclick capability does
+2. **Global Submission Feedback** - Improve the submission UX across all Anyclick interactions with proper loading states, success/error notifications, and status indicators
+
+---
+
+## Part 1: FeaturesSection Interactive Cards
+
+### Goals
+
+- Each feature card expands on click to show detailed visualization
+- Expanded view demonstrates the workflow with animated status progression
+- Auto-advancing 4-step workflow shows what happens during a real interaction
+- Links lead to examples/docs for implementation details
+
+### Component Structure
+
+```
+FeaturesSection (client component)
+├── FeatureCard (×6)
+│ ├── Collapsed State (default)
+│ │ └── Icon, Title, Description, "Learn more" link
+│ └── Expanded State (on click)
+│ ├── Close button
+│ ├── Extended description
+│ ├── WorkflowVisualization
+│ │ └── 4-step animated progression
+│ └── CTA link to docs/examples
+└── No scoped providers needed (uses global provider)
+```
+
+### Expandable Card Design
+
+**Collapsed State:**
+
+```
+┌──────────────────────────────────┐
+│ [Icon] │
+│ Context-Aware Capture │
+│ Right-click any element to... │
+│ │
+│ Learn more → │
+└──────────────────────────────────┘
+```
+
+**Expanded State:**
+
+```
+┌──────────────────────────────────────────────────────────────────┐
+│ [Icon] Context-Aware Capture [×] │
+├──────────────────────────────────────────────────────────────────┤
+│ │
+│ When you right-click an element, Anyclick captures comprehensive │
+│ context about that element and its surroundings. │
+│ │
+│ ┌──────────────────────────────────────────────────────────────┐ │
+│ │ Workflow Visualization │ │
+│ │ │ │
+│ │ ○ ─────── ◉ ─────── ○ ─────── ○ │ │
+│ │ Step 1 Step 2 Step 3 Step 4 │ │
+│ │ │ │
+│ │ ┌─────────────────────────────────────┐ │ │
+│ │ │ [✓] Capturing element context... │ │ │
+│ │ │ CSS selector: button.primary │ │ │
+│ │ │ Attributes: 3 captured │ │ │
+│ │ └─────────────────────────────────────┘ │ │
+│ └──────────────────────────────────────────────────────────────┘ │
+│ │
+│ Try it in the demo → │
+└──────────────────────────────────────────────────────────────────┘
+```
+
+### Workflow Configurations Per Card
+
+Each card has a unique 4-step workflow that auto-advances every ~1.5s:
+
+#### 1. Context-Aware Capture
+
+| Step | Label | Status Detail |
+
+|------|-------|---------------|
+
+| 1 | Detecting element | Finding target under cursor... |
+
+| 2 | Capturing context | CSS selector, attributes, ancestors |
+
+| 3 | Analyzing hierarchy | Parent containers, siblings |
+
+| 4 | Context ready | Full element context captured ✓ |
+
+#### 2. Visual Capture
+
+| Step | Label | Status Detail |
+
+|------|-------|---------------|
+
+| 1 | Preparing capture | Initializing screenshot engine... |
+
+| 2 | Capturing element | Taking element screenshot... |
+
+| 3 | Capturing viewport | Taking full page screenshot... |
+
+| 4 | Screenshots ready | 2 screenshots captured ✓ |
+
+#### 3. Code Source Integration
+
+| Step | Label | Status Detail |
+
+|------|-------|---------------|
+
+| 1 | Gathering context | Element data + screenshots... |
+
+| 2 | Formatting issue | Creating markdown with context... |
+
+| 3 | Creating issue | Sending to GitHub API... |
+
+| 4 | Issue created | Issue #234 created ✓ |
+
+#### 4. AI Agent
+
+| Step | Label | Status Detail |
+
+|------|-------|---------------|
+
+| 1 | Analyzing context | AI processing element... |
+
+| 2 | Understanding intent | Determining action type... |
+
+| 3 | Generating response | Creating suggestions... |
+
+| 4 | Ready | AI response ready ✓ |
+
+#### 5. Framework Agnostic
+
+| Step | Label | Status Detail |
+
+|------|-------|---------------|
+
+| 1 | Detecting framework | Checking for React/Vue/Svelte... |
+
+| 2 | Loading adapter | Initializing React adapter... |
+
+| 3 | Attaching listeners | Context menu ready... |
+
+| 4 | Active | Anyclick enabled ✓ |
+
+#### 6. Zero Config
+
+| Step | Label | Status Detail |
+
+|------|-------|---------------|
+
+| 1 | Installing | npm install @ewjdev/anyclick-react |
+
+| 2 | Wrapping app | Adding AnyclickProvider... |
+
+| 3 | Configuring | Default settings applied... |
+
+| 4 | Ready | Start right-clicking! ✓ |
+
+### Implementation Details
+
+**File: `apps/web/src/components/FeaturesSection.tsx`**
+
+```tsx
+"use client";
+
+import { useState, useEffect, useCallback } from "react";
+import Link from "next/link";
+import { motion, AnimatePresence } from "framer-motion";
+import { ArrowRight, X, Check, Loader2 } from "lucide-react";
+// ... other icon imports
+
+interface WorkflowStep {
+ label: string;
+ detail: string;
+}
+
+interface FeatureConfig {
+ id: string;
+ icon: ReactNode;
+ title: string;
+ description: string;
+ expandedDescription: string;
+ workflow: WorkflowStep[];
+ linkHref: string;
+ linkText: string;
+ color: string; // tailwind color class
+}
+
+// Feature configurations with workflows
+const features: FeatureConfig[] = [
+ {
+ id: "context-capture",
+ icon: ,
+ title: "Context-Aware Capture",
+ description: "Right-click any element to capture its full DOM context...",
+ expandedDescription: "When you right-click an element, Anyclick captures...",
+ workflow: [
+ { label: "Detecting element", detail: "Finding target under cursor..." },
+ { label: "Capturing context", detail: "CSS selector, attributes, ancestors" },
+ { label: "Analyzing hierarchy", detail: "Parent containers, siblings" },
+ { label: "Context ready", detail: "Full element context captured ✓" },
+ ],
+ linkHref: "/examples/basic",
+ linkText: "Try the demo",
+ color: "violet",
+ },
+ // ... other features
+];
+
+function WorkflowVisualization({
+ steps,
+ isActive,
+ color
+}: {
+ steps: WorkflowStep[];
+ isActive: boolean;
+ color: string;
+}) {
+ const [currentStep, setCurrentStep] = useState(0);
+
+ useEffect(() => {
+ if (!isActive) {
+ setCurrentStep(0);
+ return;
+ }
+
+ const interval = setInterval(() => {
+ setCurrentStep((prev) => (prev + 1) % steps.length);
+ }, 1500);
+
+ return () => clearInterval(interval);
+ }, [isActive, steps.length]);
+
+ return (
+
+ {/* Progress dots */}
+
+ {steps.map((_, i) => (
+
+ ))}
+
+
+ {/* Current step display */}
+
+
+ {currentStep === steps.length - 1 ? (
+
+ ) : (
+
+ )}
+ {steps[currentStep].label}
+
+
{steps[currentStep].detail}
+
+
+ );
+}
+
+function FeatureCard({ feature }: { feature: FeatureConfig }) {
+ const [isExpanded, setIsExpanded] = useState(false);
+
+ return (
+ !isExpanded && setIsExpanded(true)}
+ >
+
+ {!isExpanded ? (
+ // Collapsed content
+
+ {/* Icon, title, description, learn more link */}
+
+ ) : (
+ // Expanded content
+
+
+
+ {feature.title}
+ {feature.expandedDescription}
+
+
+
+
+ {feature.linkText}
+
+
+ )}
+
+
+ );
+}
+```
+
+### Link Destinations
+
+| Feature | Link | Target |
+
+|---------|------|--------|
+
+| Context-Aware Capture | `/examples/basic` | Basic example with context capture |
+
+| Visual Capture | `/examples/basic` | Basic example (has screenshot demo) |
+
+| Code Source Integration | `/examples/github-integration` | GitHub integration example |
+
+| AI Agent | `/examples/cursor-local` | Cursor local AI integration |
+
+| Framework Agnostic | `/docs/react` | React integration docs |
+
+| Zero Config | `/docs/getting-started` | Getting started guide |
+
+---
+
+## Part 2: Global Submission Feedback System
+
+### Current Problems
+
+1. **No success notification** - Menu closes, user doesn't know if feedback was received
+2. **No error notification** - Errors are silent unless developer adds `onSubmitError` callback
+3. **Submitting state** - Only shows "Sending..." text, no clear loading indicator
+4. **No persistence** - User loses all feedback if something fails
+
+### Existing Pattern: `showToast` in anyclick-extension
+
+The extension already has a well-established toast pattern in two places:
+
+**`content.ts` (vanilla JS):**
+
+```typescript
+function showToast(
+ message: string,
+ type: "success" | "error" | "warning" | "info" = "info",
+): void {
+ // Creates DOM element, positions bottom-right
+ // Auto-hides after 4000ms
+}
+```
+
+**`PopupApp.tsx` (React):**
+
+```typescript
+const showToast = useCallback((message: string, error = false) => {
+ setToast({ message, error });
+ setTimeout(() => setToast(null), 2500);
+}, []);
+```
+
+**Standard colors across the codebase:**
+
+- success: `#10b981` (emerald)
+- error: `#ef4444` (red)
+- warning: `#f59e0b` (amber)
+- info: `#3b82f6` (blue)
+
+### Proposed Solution: Unified Toast System
+
+Add a toast system to `@ewjdev/anyclick-react` that:
+
+- Follows the existing `showToast(message, type)` API pattern
+- Uses the same 4 types and colors as the extension
+- Auto-dismisses after 4 seconds (matching `content.ts`)
+- Exports a `showToast` function for imperative use
+- Auto-triggers on submission success/error by default
+
+### Implementation Architecture
+
+```
+packages/anyclick-react/src/
+├── Toast/
+│ ├── index.ts
+│ ├── Toast.tsx # Individual toast component
+│ ├── ToastContainer.tsx # Portal container for toasts
+│ ├── toastStore.ts # Zustand store for toast state
+│ ├── types.ts # Toast types
+│ └── styles.ts # Toast styles (using standard colors)
+├── AnyclickProvider.tsx # Add ToastContainer + auto-toast on submit
+└── index.ts # Export showToast + toast utilities
+```
+
+### Toast Types
+
+```typescript
+/** Toast type - matches extension pattern */
+type ToastType = 'success' | 'error' | 'warning' | 'info';
+
+/** Internal toast state */
+interface Toast {
+ id: string;
+ type: ToastType;
+ message: string;
+ duration?: number; // ms, default 4000
+}
+
+/** Configuration for automatic toasts */
+interface ToastConfig {
+ enabled?: boolean; // default: true
+ position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left';
+ maxToasts?: number; // default: 3
+ successMessage?: string; // default: "Feedback sent!"
+ errorMessage?: string; // default: "Failed to send feedback"
+}
+
+/** Standard colors - matching extension */
+const TOAST_COLORS = {
+ success: { bg: "#10b981", border: "#059669" },
+ error: { bg: "#ef4444", border: "#dc2626" },
+ warning: { bg: "#f59e0b", border: "#d97706" },
+ info: { bg: "#3b82f6", border: "#2563eb" },
+} as const;
+```
+
+### Public API
+
+```typescript
+// Imperative API - matches extension pattern
+import { showToast } from "@ewjdev/anyclick-react";
+
+showToast("Settings saved!", "success");
+showToast("Upload failed", "error");
+showToast("Feature coming soon", "warning");
+showToast("Processing...", "info");
+
+// Provider config for automatic toasts
+
+```
+
+### Provider Integration
+
+```tsx
+// In AnyclickProvider.tsx
+import { showToast, ToastContainer } from "./Toast";
+
+export function AnyclickProvider({
+ // ... existing props
+ toastConfig = { enabled: true },
+}: AnyclickProviderProps) {
+
+ const submitAnyclick = useCallback(async (...) => {
+ setIsSubmitting(true);
+ try {
+ await client.submitAnyclick(...);
+
+ // Show success toast - uses imperative API like extension
+ if (toastConfig.enabled !== false) {
+ showToast(
+ toastConfig.successMessage ?? "Feedback sent!",
+ "success"
+ );
+ }
+
+ onSubmitSuccess?.(payload);
+ } catch (error) {
+ // Show error toast
+ if (toastConfig.enabled !== false) {
+ showToast(
+ toastConfig.errorMessage ?? "Failed to send feedback",
+ "error"
+ );
+ }
+
+ onSubmitError?.(error, payload);
+ } finally {
+ setIsSubmitting(false);
+ // ... close menu
+ }
+ }, [...]);
+
+ return (
+
+ {/* Toast container renders via portal - only one needed */}
+
+ {/* ... rest of provider */}
+
+ );
+}
+```
+
+### Usage Examples
+
+```tsx
+// Automatic toasts on submission (default behavior)
+
+
+
+
+// Custom messages
+
+
+
+
+// Disable automatic toasts (handle manually)
+ showToast("Custom success!", "success")}
+>
+
+
+
+// Imperative use anywhere in the app
+import { showToast } from "@ewjdev/anyclick-react";
+
+function MyComponent() {
+ const handleAction = () => {
+ // Do something
+ showToast("Action completed", "success");
+ };
+}
+```
+
+### Toast Component Design
+
+Follows the extension's visual style - simple, colored background, single message:
+
+```tsx
+import { motion, AnimatePresence } from "motion/react";
+
+const TOAST_COLORS = {
+ success: { bg: "#10b981", border: "#059669" },
+ error: { bg: "#ef4444", border: "#dc2626" },
+ warning: { bg: "#f59e0b", border: "#d97706" },
+ info: { bg: "#3b82f6", border: "#2563eb" },
+} as const;
+
+function Toast({
+ toast,
+ onDismiss
+}: {
+ toast: { id: string; type: ToastType; message: string };
+ onDismiss: () => void;
+}) {
+ const { bg, border } = TOAST_COLORS[toast.type];
+
+ return (
+
+ {toast.message}
+
+
+ );
+}
+
+/** Zustand store for toast state */
+const useToastStore = create<{
+ toasts: Toast[];
+ addToast: (message: string, type: ToastType) => void;
+ removeToast: (id: string) => void;
+}>((set) => ({
+ toasts: [],
+ addToast: (message, type) => {
+ const id = Math.random().toString(36).slice(2);
+ set((state) => ({
+ toasts: [...state.toasts.slice(-2), { id, message, type }], // max 3
+ }));
+ // Auto-dismiss after 4 seconds (matching extension)
+ setTimeout(() => {
+ set((state) => ({
+ toasts: state.toasts.filter((t) => t.id !== id),
+ }));
+ }, 4000);
+ },
+ removeToast: (id) =>
+ set((state) => ({
+ toasts: state.toasts.filter((t) => t.id !== id),
+ })),
+}));
+
+/** Public imperative API - matches extension pattern */
+export function showToast(
+ message: string,
+ type: ToastType = "info"
+): void {
+ useToastStore.getState().addToast(message, type);
+}
+```
+
+### Enhanced Loading States in ContextMenu
+
+Update the submission UI to be clearer:
+
+**Current:**
+
+```
+[Send] → "Sending..."
+```
+
+**Proposed:**
+
+```
+[Send] → [Spinner] Sending... → [Success] ✓ Sent!
+```
+```tsx
+// In CommentForm
+
+```
+
+---
+
+## File Changes Summary
+
+### Part 1: FeaturesSection
+
+| File | Action | Description |
+
+|------|--------|-------------|
+
+| `apps/web/src/components/FeaturesSection.tsx` | **Modify** | Convert to client component, add expandable cards with workflow visualization |
+
+### Part 2: Toast System
+
+| File | Action | Description |
+
+|------|--------|-------------|
+
+| `packages/anyclick-react/src/Toast/Toast.tsx` | **Create** | Toast component |
+
+| `packages/anyclick-react/src/Toast/ToastContainer.tsx` | **Create** | Container with portal |
+
+| `packages/anyclick-react/src/Toast/toastStore.ts` | **Create** | Zustand store |
+
+| `packages/anyclick-react/src/Toast/types.ts` | **Create** | Type definitions |
+
+| `packages/anyclick-react/src/Toast/styles.ts` | **Create** | Style constants |
+
+| `packages/anyclick-react/src/Toast/index.ts` | **Create** | Barrel export |
+
+| `packages/anyclick-react/src/AnyclickProvider.tsx` | **Modify** | Add toast on submit |
+
+| `packages/anyclick-react/src/types.ts` | **Modify** | Add ToastConfig type |
+
+| `packages/anyclick-react/src/index.ts` | **Modify** | Export `showToast`, `ToastContainer`, `ToastConfig` |
+
+| `packages/anyclick-react/src/ContextMenu.tsx` | **Modify** | Improve loading UI |
+
+---
+
+## Implementation Order
+
+### Phase 1: Global Toast System (Do First)
+
+1. Create Toast component and store
+2. Integrate into AnyclickProvider
+3. Test submission flow end-to-end
+
+### Phase 2: FeaturesSection Enhancement
+
+1. Create WorkflowVisualization component
+2. Define feature configurations with workflows
+3. Implement expandable FeatureCard
+4. Add animation with framer-motion
+5. Test all cards expand/collapse properly
+
+---
+
+## Technical Considerations
+
+### Dependencies
+
+- **framer-motion** - Already in project, use for animations
+- **zustand** - Already in project, use for toast store
+- No new dependencies required
+
+### Accessibility
+
+- Toast announcements via `aria-live="polite"`
+- Escape key to dismiss
+- Focus management when cards expand
+- Reduced motion support
+
+### Performance
+
+- Toast store is minimal (just array of toasts)
+- WorkflowVisualization uses `setInterval`, cleanup on unmount
+- Card expansion uses CSS Grid layout shift (GPU accelerated)
+
+---
+
+## Success Criteria
+
+### FeaturesSection
+
+- [ ] All 6 cards expand on click
+- [ ] Workflow visualization auto-advances through 4 steps
+- [ ] Animation is smooth (60fps)
+- [ ] "Learn more" links navigate correctly
+- [ ] Cards collapse when clicking X or clicking outside
+
+### Toast System
+
+- [ ] Success toast appears after feedback submission
+- [ ] Error toast appears on submission failure
+- [ ] Toasts auto-dismiss after 4 seconds
+- [ ] Multiple toasts stack properly
+- [ ] Toast can be manually dismissed
+- [ ] Works in all browsers
+
+---
+
+## Out of Scope
+
+- Toast persistence across page navigation
+- Toast action buttons (undo, retry)
+- Custom toast themes per provider
+- Sound effects on toast
\ No newline at end of file
diff --git a/apps/web/src/components/FeaturesSection.tsx b/apps/web/src/components/FeaturesSection.tsx
index 873f675..c3850a7 100644
--- a/apps/web/src/components/FeaturesSection.tsx
+++ b/apps/web/src/components/FeaturesSection.tsx
@@ -1,15 +1,463 @@
+"use client";
+
+import * as React from "react";
import {
Camera,
+ Clipboard,
+ Eye,
+ FileText,
GitBranch,
+ Info,
Layers,
MousePointerClick,
+ RotateCcw,
+ Sparkles,
Terminal,
+ Target,
Zap,
} from "lucide-react";
+import { motion } from "motion/react";
+import { cn } from "@/lib/utils";
+import { FeatureCard } from "@/components/features/FeatureCard";
+import type { FeatureConfig } from "@/components/features/types";
+
+const features: FeatureConfig[] = [
+ {
+ id: "context-capture",
+ icon: ,
+ title: "Context-aware container",
+ description:
+ "Click to expand and explore a capture that starts with element + container context (no auto-play).",
+ expandedDescription:
+ "Start a capture to see element context, container context, connected data, and how Anyclick metadata is shaped for the next action.",
+ linkHref: "/examples/basic",
+ linkText: "Try the demo",
+ color: "violet",
+ colorClasses: {
+ border: "border-violet-500/30",
+ bg: "bg-violet-500",
+ text: "text-violet-400",
+ iconBg: "from-violet-500/20 to-violet-500/5",
+ dot: "bg-violet-500",
+ dotMuted: "bg-violet-500/50",
+ },
+ demo: {
+ defaultPanel: "data",
+ primaryCta: {
+ label: "Start capture",
+ action: { kind: "startCapture", setActivePanel: "data" },
+ },
+ secondaryCta: {
+ label: "Pick a target",
+ action: { kind: "toggleTargetPicker" },
+ },
+ menuItems: [
+ {
+ id: "view-data",
+ label: "View capture data",
+ icon: ,
+ action: { kind: "setPanel", panel: "data" },
+ },
+ {
+ id: "view-status",
+ label: "View status",
+ icon: ,
+ action: { kind: "setPanel", panel: "status" },
+ },
+ {
+ id: "view-next",
+ label: "View next action",
+ icon: ,
+ action: { kind: "setPanel", panel: "next" },
+ },
+ {
+ id: "copy-payload",
+ label: "Copy payload JSON",
+ icon: ,
+ action: { kind: "copy", target: "payloadJson" },
+ },
+ {
+ id: "reset",
+ label: "Reset capture",
+ icon: ,
+ action: { kind: "reset" },
+ },
+ ],
+ runner: {
+ enableScreenshots: false,
+ includeInspectInfo: true,
+ },
+ },
+ },
+ {
+ id: "visual-capture",
+ icon: ,
+ title: "Visual capture",
+ description:
+ "Capture element/container/viewport screenshots (when supported) and inspect sizes + errors.",
+ expandedDescription:
+ "Run a visual capture to see screenshots (or fallback errors), masking behavior, and how visuals attach to downstream workflows.",
+ linkHref: "/examples/basic",
+ linkText: "See it in action",
+ color: "cyan",
+ colorClasses: {
+ border: "border-cyan-500/30",
+ bg: "bg-cyan-500",
+ text: "text-cyan-400",
+ iconBg: "from-cyan-500/20 to-cyan-500/5",
+ dot: "bg-cyan-500",
+ dotMuted: "bg-cyan-500/50",
+ },
+ demo: {
+ defaultPanel: "data",
+ primaryCta: {
+ label: "Capture screenshots",
+ action: { kind: "startCapture", setActivePanel: "data" },
+ },
+ secondaryCta: {
+ label: "Retake",
+ action: { kind: "retakeScreenshots" },
+ },
+ menuItems: [
+ {
+ id: "view-screenshots",
+ label: "View screenshots",
+ icon: ,
+ action: { kind: "setPanel", panel: "data" },
+ },
+ {
+ id: "view-mask",
+ label: "View mask list",
+ icon: ,
+ action: { kind: "setPanel", panel: "data" },
+ },
+ {
+ id: "view-status",
+ label: "View status",
+ icon: ,
+ action: { kind: "setPanel", panel: "status" },
+ },
+ {
+ id: "copy-sizes",
+ label: "Copy screenshot sizes",
+ icon: ,
+ action: { kind: "copy", target: "screenshotSummary" },
+ },
+ {
+ id: "reset",
+ label: "Reset capture",
+ icon: ,
+ action: { kind: "reset" },
+ },
+ ],
+ runner: {
+ enableScreenshots: true,
+ includeInspectInfo: false,
+ },
+ },
+ },
+ {
+ id: "github-integration",
+ icon: ,
+ title: "Code source integration",
+ description:
+ "Draft rich GitHub issue content from captured context (no network calls in the demo).",
+ expandedDescription:
+ "Capture context and assemble an issue draft (markdown) showing how element + page context become developer-ready artifacts.",
+ linkHref: "/examples/github-integration",
+ linkText: "View integration",
+ color: "emerald",
+ colorClasses: {
+ border: "border-emerald-500/30",
+ bg: "bg-emerald-500",
+ text: "text-emerald-400",
+ iconBg: "from-emerald-500/20 to-emerald-500/5",
+ dot: "bg-emerald-500",
+ dotMuted: "bg-emerald-500/50",
+ },
+ demo: {
+ defaultPanel: "next",
+ primaryCta: {
+ label: "Draft GitHub issue",
+ action: { kind: "startCapture", setActivePanel: "next" },
+ },
+ secondaryCta: {
+ label: "Copy issue markdown",
+ action: { kind: "copy", target: "issueMarkdown" },
+ },
+ menuItems: [
+ {
+ id: "view-issue",
+ label: "View issue draft",
+ icon: ,
+ action: { kind: "setPanel", panel: "next" },
+ },
+ {
+ id: "view-payload",
+ label: "View payload JSON",
+ icon: ,
+ action: { kind: "setPanel", panel: "data" },
+ },
+ {
+ id: "view-status",
+ label: "View status",
+ icon: ,
+ action: { kind: "setPanel", panel: "status" },
+ },
+ {
+ id: "copy-issue",
+ label: "Copy issue markdown",
+ icon: ,
+ action: { kind: "copy", target: "issueMarkdown" },
+ },
+ {
+ id: "reset",
+ label: "Reset",
+ icon: ,
+ action: { kind: "reset" },
+ },
+ ],
+ runner: {
+ enableScreenshots: true,
+ includeInspectInfo: false,
+ },
+ },
+ },
+ {
+ id: "ai-agent",
+ icon: ,
+ title: "AI agent",
+ description:
+ "Shape capture context into a structured agent input for fast debugging and next steps.",
+ expandedDescription:
+ "Run capture and see the exact context bundle (prompt + structured data) that an agent would receive.",
+ linkHref: "/examples/cursor-local",
+ linkText: "Explore AI features",
+ color: "amber",
+ colorClasses: {
+ border: "border-amber-500/30",
+ bg: "bg-amber-500",
+ text: "text-amber-400",
+ iconBg: "from-amber-500/20 to-amber-500/5",
+ dot: "bg-amber-500",
+ dotMuted: "bg-amber-500/50",
+ },
+ demo: {
+ defaultPanel: "next",
+ primaryCta: {
+ label: "Ask AI about this",
+ action: { kind: "startCapture", setActivePanel: "next" },
+ },
+ secondaryCta: {
+ label: "Copy agent context",
+ action: { kind: "copy", target: "agentContext" },
+ },
+ menuItems: [
+ {
+ id: "view-agent",
+ label: "View agent input",
+ icon: ,
+ action: { kind: "setPanel", panel: "next" },
+ },
+ {
+ id: "view-payload",
+ label: "View payload JSON",
+ icon: ,
+ action: { kind: "setPanel", panel: "data" },
+ },
+ {
+ id: "view-status",
+ label: "View status",
+ icon: ,
+ action: { kind: "setPanel", panel: "status" },
+ },
+ {
+ id: "copy-agent",
+ label: "Copy agent context",
+ icon: ,
+ action: { kind: "copy", target: "agentContext" },
+ },
+ {
+ id: "reset",
+ label: "Reset",
+ icon: ,
+ action: { kind: "reset" },
+ },
+ ],
+ runner: {
+ enableScreenshots: false,
+ includeInspectInfo: false,
+ },
+ },
+ },
+ {
+ id: "framework-agnostic",
+ icon: ,
+ title: "Framework agnostic",
+ description:
+ "Switch between integration modes and copy the right snippet for your stack.",
+ expandedDescription:
+ "Toggle integration wiring (React vs vanilla, scoped vs global) and see how the same capture payload powers your next action.",
+ linkHref: "/docs/react",
+ linkText: "View docs",
+ color: "rose",
+ colorClasses: {
+ border: "border-rose-500/30",
+ bg: "bg-rose-500",
+ text: "text-rose-400",
+ iconBg: "from-rose-500/20 to-rose-500/5",
+ dot: "bg-rose-500",
+ dotMuted: "bg-rose-500/50",
+ },
+ demo: {
+ defaultPanel: "next",
+ primaryCta: {
+ label: "Toggle integration mode",
+ action: { kind: "toggleIntegrationMode" },
+ },
+ secondaryCta: {
+ label: "Copy snippet",
+ action: { kind: "copy", target: "snippet" },
+ },
+ menuItems: [
+ {
+ id: "view-wiring",
+ label: "View wiring",
+ icon: ,
+ action: { kind: "setPanel", panel: "next" },
+ },
+ {
+ id: "run-capture",
+ label: "Run sample capture",
+ icon: ,
+ action: { kind: "startCapture", setActivePanel: "status" },
+ },
+ {
+ id: "view-payload",
+ label: "View payload JSON",
+ icon: ,
+ action: { kind: "setPanel", panel: "data" },
+ },
+ {
+ id: "view-status",
+ label: "View status",
+ icon: ,
+ action: { kind: "setPanel", panel: "status" },
+ },
+ {
+ id: "copy-snippet",
+ label: "Copy snippet",
+ icon: ,
+ action: { kind: "copy", target: "snippet" },
+ },
+ {
+ id: "reset",
+ label: "Reset",
+ icon: ,
+ action: { kind: "reset" },
+ },
+ ],
+ runner: {
+ enableScreenshots: false,
+ includeInspectInfo: false,
+ },
+ },
+ },
+ {
+ id: "zero-config",
+ icon: ,
+ title: "Zero config",
+ description:
+ "Copy the minimal setup, then run a sample capture to see what comes out.",
+ expandedDescription:
+ "Get started in seconds. Copy the setup snippet, then run a sample capture to see the payload shape and default behavior.",
+ linkHref: "/docs/getting-started",
+ linkText: "Get started",
+ color: "indigo",
+ colorClasses: {
+ border: "border-indigo-500/30",
+ bg: "bg-indigo-500",
+ text: "text-indigo-400",
+ iconBg: "from-indigo-500/20 to-indigo-500/5",
+ dot: "bg-indigo-500",
+ dotMuted: "bg-indigo-500/50",
+ },
+ demo: {
+ defaultPanel: "next",
+ primaryCta: {
+ label: "Copy install + wrap snippet",
+ action: { kind: "copy", target: "commands" },
+ },
+ secondaryCta: {
+ label: "Run sample capture",
+ action: { kind: "startCapture", setActivePanel: "data" },
+ },
+ menuItems: [
+ {
+ id: "copy-snippet",
+ label: "Copy commands + snippet",
+ icon: ,
+ action: { kind: "copy", target: "commands" },
+ },
+ {
+ id: "view-payload",
+ label: "View payload JSON",
+ icon: ,
+ action: { kind: "setPanel", panel: "data" },
+ },
+ {
+ id: "view-status",
+ label: "View status",
+ icon: ,
+ action: { kind: "setPanel", panel: "status" },
+ },
+ {
+ id: "reset",
+ label: "Reset",
+ icon: ,
+ action: { kind: "reset" },
+ },
+ ],
+ runner: {
+ enableScreenshots: false,
+ includeInspectInfo: false,
+ },
+ },
+ },
+];
+
+export interface FeaturesSectionProps
+ extends React.HTMLAttributes {}
+
+export default function FeaturesSection({
+ className,
+ ...props
+}: FeaturesSectionProps) {
+ const [expandedId, setExpandedId] = React.useState(null);
+
+ const handleToggle = React.useCallback((id: string) => {
+ setExpandedId((prev) => (prev === id ? null : id));
+ }, []);
+
+ // Close expanded card when clicking outside the card area.
+ React.useEffect(() => {
+ const handleClickOutside = (e: MouseEvent) => {
+ const target = e.target as HTMLElement;
+ if (
+ expandedId &&
+ target.closest("[data-features-grid]") &&
+ !target.closest("[data-feature-card]")
+ ) {
+ setExpandedId(null);
+ }
+ };
+
+ document.addEventListener("click", handleClickOutside);
+ return () => document.removeEventListener("click", handleClickOutside);
+ }, [expandedId]);
-const FeaturesSection = () => {
return (
-
+
Everything you need for UI context
@@ -20,84 +468,21 @@ const FeaturesSection = () => {
-
- {/* Feature 1 */}
-
-
-
+
+ {features.map((feature) => (
+
+ handleToggle(feature.id)}
+ />
- Context-Aware Capture
-
- Right-click any element to capture its full DOM context, including
- selectors, data attributes, ancestors, and surrounding page
- information.
-
-
-
- {/* Feature 2 */}
-
-
-
-
-
Visual Capture
-
- Automatically capture screenshots of the target element, its
- container, and the full page—all included in the feedback payload.
-
-
-
- {/* Feature 3 */}
-
-
-
-
-
- Code Source Integration
-
-
- Automatically create GitHub Issues with rich context, formatted
- markdown, and embedded screenshots for seamless issue tracking.
-
-
-
- {/* Feature 4 */}
-
-
-
-
-
AI Agent
-
- Launch Cursor's AI agent directly from feedback—locally during
- development or via cloud agent for instant code fixes.
-
-
-
- {/* Feature 5 */}
-
-
-
-
-
Framework Agnostic
-
- Core library works with any JavaScript framework. Use the React
- provider for React apps, or build your own integration.
-
-
-
- {/* Feature 6 */}
-
-
-
-
-
Zero Config
-
- Works out of the box with sensible defaults. Right-click anywhere in
- your app and start capturing feedback immediately.
-
-
-
+ ))}
+
);
-};
-
-export default FeaturesSection;
+}
diff --git a/apps/web/src/components/features/CaptureDetailsPanel.tsx b/apps/web/src/components/features/CaptureDetailsPanel.tsx
new file mode 100644
index 0000000..97e5312
--- /dev/null
+++ b/apps/web/src/components/features/CaptureDetailsPanel.tsx
@@ -0,0 +1,67 @@
+"use client";
+
+import * as React from "react";
+import { cn } from "@/lib/utils";
+import type { DemoPanel } from "./types";
+
+export interface CaptureDetailsPanelProps
+ extends React.HTMLAttributes
{
+ activePanel: DemoPanel;
+ onChangePanel: (panel: DemoPanel) => void;
+ panels: {
+ data: React.ReactNode;
+ next: React.ReactNode;
+ status: React.ReactNode;
+ };
+}
+
+const panelLabels: Record = {
+ data: "Data",
+ next: "Next action",
+ status: "Status",
+};
+
+export function CaptureDetailsPanel({
+ activePanel,
+ className,
+ onChangePanel,
+ panels,
+ ...props
+}: CaptureDetailsPanelProps) {
+ return (
+
+
+ {(Object.keys(panelLabels) as DemoPanel[]).map((panel) => (
+
+ ))}
+
+
+
+ {activePanel === "data"
+ ? panels.data
+ : activePanel === "status"
+ ? panels.status
+ : panels.next}
+
+
+ );
+}
+
diff --git a/apps/web/src/components/features/CaptureWorkflowTimeline.tsx b/apps/web/src/components/features/CaptureWorkflowTimeline.tsx
new file mode 100644
index 0000000..e510305
--- /dev/null
+++ b/apps/web/src/components/features/CaptureWorkflowTimeline.tsx
@@ -0,0 +1,146 @@
+"use client";
+
+import * as React from "react";
+import { Check, Loader2, TriangleAlert } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+export type CaptureStepId = "connected" | "container" | "element" | "metadata";
+export type CaptureStepStatus =
+ | "error"
+ | "idle"
+ | "running"
+ | "simulated"
+ | "success";
+
+export interface CaptureStep {
+ id: CaptureStepId;
+ label: string;
+}
+
+export interface CaptureStepState {
+ detail?: string;
+ durationMs?: number;
+ errorMessage?: string;
+ status: CaptureStepStatus;
+}
+
+export interface CaptureWorkflowTimelineProps
+ extends React.HTMLAttributes {
+ accent: {
+ dot: string;
+ dotMuted: string;
+ text: string;
+ };
+ steps: CaptureStep[];
+ stepStates: Record;
+}
+
+function formatDuration(durationMs?: number) {
+ if (typeof durationMs !== "number") return null;
+ if (durationMs < 1000) return `${Math.round(durationMs)}ms`;
+ return `${(durationMs / 1000).toFixed(2)}s`;
+}
+
+export function CaptureWorkflowTimeline({
+ accent,
+ className,
+ steps,
+ stepStates,
+ ...props
+}: CaptureWorkflowTimelineProps) {
+ return (
+
+ {/* Progress dots */}
+
+ {steps.map((step) => {
+ const state = stepStates[step.id];
+ const dotClass =
+ state.status === "running"
+ ? `${accent.dot} scale-125`
+ : state.status === "success" || state.status === "simulated"
+ ? accent.dotMuted
+ : state.status === "error"
+ ? "bg-red-500"
+ : "bg-white/20";
+
+ return (
+
+ );
+ })}
+
+
+ {/* Step list */}
+
+ {steps.map((step) => {
+ const state = stepStates[step.id];
+ const durationLabel = formatDuration(state.durationMs);
+
+ return (
+
+
+ {state.status === "running" ? (
+
+ ) : state.status === "success" ? (
+
+ ) : state.status === "simulated" ? (
+
+ ) : state.status === "error" ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {step.label}
+ {state.status === "simulated" ? (
+
+ simulated
+
+ ) : null}
+
+ {durationLabel ? (
+
+ {durationLabel}
+
+ ) : null}
+
+
+ {state.status === "error" && state.errorMessage ? (
+
+ {state.errorMessage}
+
+ ) : state.detail ? (
+
+ {state.detail}
+
+ ) : (
+
+ {state.status === "idle" ? "Waiting…" : ""}
+
+ )}
+
+
+ );
+ })}
+
+
+ );
+}
+
diff --git a/apps/web/src/components/features/FeatureCaptureDemo.tsx b/apps/web/src/components/features/FeatureCaptureDemo.tsx
new file mode 100644
index 0000000..d0042de
--- /dev/null
+++ b/apps/web/src/components/features/FeatureCaptureDemo.tsx
@@ -0,0 +1,1153 @@
+"use client";
+
+import * as React from "react";
+import type {
+ AnyclickPayload,
+ ElementContext,
+ ElementInspectInfo,
+ PageContext,
+ ScreenshotCapture,
+ ScreenshotData,
+} from "@ewjdev/anyclick-core";
+import {
+ buildAnyclickPayload,
+ buildElementContext,
+ buildPageContext,
+ captureAllScreenshots,
+ formatBytes,
+ getElementInspectInfo,
+ isScreenshotSupported,
+} from "@ewjdev/anyclick-core";
+import {
+ ContextMenu,
+ type ContextMenuItem,
+ showToast,
+} from "@ewjdev/anyclick-react";
+import {
+ Braces,
+ Clipboard,
+ Info,
+ RotateCcw,
+ Sparkles,
+ Target,
+} from "lucide-react";
+import { cn } from "@/lib/utils";
+import { CaptureDetailsPanel } from "./CaptureDetailsPanel";
+import {
+ CaptureWorkflowTimeline,
+ type CaptureStep,
+ type CaptureStepId,
+ type CaptureStepState,
+} from "./CaptureWorkflowTimeline";
+import type { DemoPanel, FeatureConfig, FeatureDemoAction } from "./types";
+
+type CaptureRunStatus = "idle" | "running" | "success";
+
+type IntegrationMode = {
+ framework: "react" | "vanilla";
+ scope: "global" | "scoped";
+};
+
+type CaptureArtifacts = {
+ agentInput?: unknown;
+ connectedData?: unknown;
+ containerContext?: ElementContext;
+ elementContext?: ElementContext;
+ inspectInfo?: ElementInspectInfo;
+ integrationSnippet?: string;
+ issueMarkdown?: string;
+ metadata?: Record;
+ pageContext?: PageContext;
+ payload?: AnyclickPayload;
+ screenshots?: ScreenshotData | null;
+};
+
+const OFFSCREEN_POSITION = { x: -9999, y: -9999 };
+
+function nowMs() {
+ if (typeof performance !== "undefined" && typeof performance.now === "function")
+ return performance.now();
+ return Date.now();
+}
+
+async function sleep(ms: number) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function safeJson(value: unknown) {
+ try {
+ return JSON.stringify(value, null, 2);
+ } catch {
+ return JSON.stringify({ error: "Failed to serialize JSON" }, null, 2);
+ }
+}
+
+function summarizeScreenshotCapture(capture?: ScreenshotCapture) {
+ if (!capture) return null;
+ return {
+ height: capture.height,
+ sizeBytes: capture.sizeBytes,
+ sizeHuman: formatBytes(capture.sizeBytes),
+ width: capture.width,
+ };
+}
+
+function summarizeScreenshots(screenshots: ScreenshotData | null | undefined) {
+ if (!screenshots) return null;
+ return {
+ capturedAt: screenshots.capturedAt,
+ container: summarizeScreenshotCapture(screenshots.container),
+ element: summarizeScreenshotCapture(screenshots.element),
+ errors: screenshots.errors,
+ viewport: summarizeScreenshotCapture(screenshots.viewport),
+ };
+}
+
+function buildGitHubIssueMarkdown(input: {
+ element: ElementContext;
+ page: PageContext;
+ screenshots?: ScreenshotData | null;
+}) {
+ const { element, page } = input;
+ const screenshots = summarizeScreenshots(input.screenshots);
+
+ const lines: string[] = [
+ `## Summary`,
+ ``,
+ `Right-click capture on **${element.tag}** at \`${element.selector}\`.`,
+ ``,
+ `## Page`,
+ `- URL: ${page.url}`,
+ `- Title: ${page.title}`,
+ ``,
+ `## Element`,
+ `- Tag: \`${element.tag}\``,
+ element.id ? `- ID: \`${element.id}\`` : `- ID: _(none)_`,
+ element.classes.length
+ ? `- Classes: \`${element.classes.slice(0, 6).join(" ")}\``
+ : `- Classes: _(none)_`,
+ ``,
+ `## Context`,
+ `- Ancestors captured: ${element.ancestors.length}`,
+ `- data-* keys: ${Object.keys(element.dataAttributes).length}`,
+ ``,
+ `## Screenshots`,
+ ];
+
+ if (!screenshots) {
+ lines.push(`- Not captured`);
+ } else {
+ lines.push(
+ `- Element: ${screenshots.element ? screenshots.element.sizeHuman : "n/a"}`,
+ `- Container: ${
+ screenshots.container ? screenshots.container.sizeHuman : "n/a"
+ }`,
+ `- Viewport: ${
+ screenshots.viewport ? screenshots.viewport.sizeHuman : "n/a"
+ }`,
+ screenshots.errors
+ ? `- Errors: ${Object.keys(screenshots.errors).join(", ")}`
+ : `- Errors: none`,
+ );
+ }
+
+ lines.push(
+ ``,
+ `---`,
+ `_(This is a demo draft; no network calls are made.)_`,
+ );
+
+ return lines.join("\n");
+}
+
+function buildAgentInput(input: {
+ element: ElementContext;
+ page: PageContext;
+ screenshots?: ScreenshotData | null;
+}) {
+ const screenshots = summarizeScreenshots(input.screenshots);
+ return {
+ title: "Quick Ask Agent Input (demo)",
+ prompt:
+ "Explain what this element is, why it matters, and what the next debugging action should be.",
+ context: {
+ page: {
+ title: input.page.title,
+ url: input.page.url,
+ },
+ element: {
+ selector: input.element.selector,
+ tag: input.element.tag,
+ id: input.element.id ?? null,
+ classes: input.element.classes,
+ dataAttributes: input.element.dataAttributes,
+ ancestors: input.element.ancestors,
+ },
+ screenshots,
+ },
+ };
+}
+
+function buildIntegrationSnippet(mode: IntegrationMode) {
+ const scopeComment =
+ mode.scope === "scoped"
+ ? `// Scoped: only captures inside a subtree`
+ : `// Global: captures across the document`;
+
+ if (mode.framework === "react") {
+ return [
+ `import { AnyclickProvider } from "@ewjdev/anyclick-react";`,
+ `import { createHttpAdapter } from "@ewjdev/anyclick-github";`,
+ ``,
+ `const adapter = createHttpAdapter({ endpoint: "/api/feedback" });`,
+ ``,
+ scopeComment,
+ ``,
+ ` `,
+ ``,
+ ].join("\n");
+ }
+
+ return [
+ `import { createAnyclickClient } from "@ewjdev/anyclick-core";`,
+ ``,
+ `const client = createAnyclickClient({`,
+ ` adapter: { submitAnyclick: async (payload) => console.log(payload) },`,
+ ` // targetFilter: (event, el) => true,`,
+ `});`,
+ ``,
+ scopeComment,
+ `client.attach();`,
+ ].join("\n");
+}
+
+function buildZeroConfigSnippet() {
+ return [
+ `# install`,
+ `yarn add @ewjdev/anyclick-react @ewjdev/anyclick-github`,
+ ``,
+ `# wrap`,
+ `import { AnyclickProvider } from "@ewjdev/anyclick-react";`,
+ `import { createHttpAdapter } from "@ewjdev/anyclick-github";`,
+ ``,
+ `const adapter = createHttpAdapter({ endpoint: "/api/feedback" });`,
+ ``,
+ ``,
+ ` `,
+ ``,
+ ].join("\n");
+}
+
+function createInitialStepStates(): Record {
+ return {
+ connected: { status: "idle" },
+ container: { status: "idle" },
+ element: { status: "idle" },
+ metadata: { status: "idle" },
+ };
+}
+
+export interface FeatureCaptureDemoProps
+ extends React.HTMLAttributes {
+ feature: FeatureConfig;
+}
+
+export function FeatureCaptureDemo({
+ className,
+ feature,
+ ...props
+}: FeatureCaptureDemoProps) {
+ const [activePanel, setActivePanel] = React.useState(
+ feature.demo.defaultPanel,
+ );
+ const [runStatus, setRunStatus] = React.useState("idle");
+ const [stepStates, setStepStates] = React.useState<
+ Record
+ >(createInitialStepStates);
+ const [artifacts, setArtifacts] = React.useState({});
+
+ // Subtle interaction variants
+ const [isTargetPickerEnabled, setIsTargetPickerEnabled] = React.useState(false);
+ const [selectedTargetKey, setSelectedTargetKey] = React.useState("primary");
+ const [integrationMode, setIntegrationMode] = React.useState({
+ framework: "react",
+ scope: "global",
+ });
+
+ // Demo element refs
+ const containerRef = React.useRef(null);
+ const targetRefs = React.useRef>({});
+ const setTargetRef = React.useCallback(
+ (key: string) => (node: HTMLElement | null) => {
+ targetRefs.current[key] = node;
+ },
+ [],
+ );
+
+ // Right-click menu state (card-local)
+ const [menuVisible, setMenuVisible] = React.useState(false);
+ const [menuPosition, setMenuPosition] = React.useState(OFFSCREEN_POSITION);
+
+ const runTokenRef = React.useRef(0);
+
+ const steps: CaptureStep[] = React.useMemo(
+ () => [
+ { id: "element", label: "Capture element" },
+ { id: "container", label: "Capture container" },
+ { id: "connected", label: "Get connected data" },
+ { id: "metadata", label: "Shape anyclick metadata" },
+ ],
+ [],
+ );
+
+ const reset = React.useCallback(() => {
+ runTokenRef.current += 1;
+ setRunStatus("idle");
+ setStepStates(createInitialStepStates());
+ setArtifacts({});
+ setActivePanel(feature.demo.defaultPanel);
+ setMenuVisible(false);
+ setMenuPosition(OFFSCREEN_POSITION);
+ }, [feature.demo.defaultPanel]);
+
+ const updateStep = React.useCallback(
+ (stepId: CaptureStepId, patch: Partial) => {
+ setStepStates((prev) => ({
+ ...prev,
+ [stepId]: { ...prev[stepId], ...patch },
+ }));
+ },
+ [],
+ );
+
+ const getActiveTargetElement = React.useCallback((): HTMLElement | null => {
+ const explicit = targetRefs.current[selectedTargetKey];
+ if (explicit) return explicit;
+ return targetRefs.current.primary ?? null;
+ }, [selectedTargetKey]);
+
+ const copyToClipboard = React.useCallback(async (text: string) => {
+ try {
+ await navigator.clipboard.writeText(text);
+ showToast("Copied to clipboard", "success");
+ } catch {
+ showToast("Failed to copy", "error");
+ }
+ }, []);
+
+ const handleAction = React.useCallback(
+ async (action: FeatureDemoAction) => {
+ if (action.kind === "setPanel") {
+ setActivePanel(action.panel);
+ return;
+ }
+ if (action.kind === "reset") {
+ reset();
+ return;
+ }
+ if (action.kind === "toggleTargetPicker") {
+ setIsTargetPickerEnabled((prev) => !prev);
+ return;
+ }
+ if (action.kind === "toggleIntegrationMode") {
+ setIntegrationMode((prev) => ({
+ framework: prev.framework === "react" ? "vanilla" : "react",
+ scope: prev.scope === "global" ? "scoped" : "global",
+ }));
+ return;
+ }
+ if (action.kind === "retakeScreenshots") {
+ // Just re-run capture with screenshots enabled (if this card supports it)
+ // and keep the user on Data panel.
+ setActivePanel("data");
+ action = { kind: "startCapture", setActivePanel: "data" };
+ }
+ if (action.kind === "copy") {
+ const screenshotsSummary = summarizeScreenshots(artifacts.screenshots);
+ const payloadForCopy = artifacts.payload
+ ? {
+ ...artifacts.payload,
+ screenshots: artifacts.payload.screenshots
+ ? summarizeScreenshots(artifacts.payload.screenshots)
+ : screenshotsSummary ?? undefined,
+ }
+ : null;
+
+ if (action.target === "payloadJson") {
+ await copyToClipboard(payloadForCopy ? safeJson(payloadForCopy) : "{}");
+ return;
+ }
+ if (action.target === "issueMarkdown") {
+ await copyToClipboard(artifacts.issueMarkdown ?? "");
+ return;
+ }
+ if (action.target === "agentContext") {
+ await copyToClipboard(safeJson(artifacts.agentInput ?? {}));
+ return;
+ }
+ if (action.target === "snippet") {
+ const snippet =
+ feature.id === "framework-agnostic"
+ ? buildIntegrationSnippet(integrationMode)
+ : artifacts.integrationSnippet ?? "";
+ await copyToClipboard(snippet);
+ return;
+ }
+ if (action.target === "commands") {
+ await copyToClipboard(buildZeroConfigSnippet());
+ return;
+ }
+ if (action.target === "screenshotSummary") {
+ await copyToClipboard(safeJson(screenshotsSummary ?? {}));
+ return;
+ }
+ }
+
+ if (action.kind === "startCapture") {
+ const token = (runTokenRef.current += 1);
+ setRunStatus("running");
+ setStepStates(createInitialStepStates());
+ setArtifacts({});
+
+ const targetEl = getActiveTargetElement();
+ const containerEl = containerRef.current;
+
+ if (!targetEl || !containerEl) {
+ setRunStatus("idle");
+ updateStep("element", {
+ status: "error",
+ errorMessage: "Demo elements not ready",
+ });
+ return;
+ }
+
+ let elementContext: ElementContext | undefined;
+ let containerContext: ElementContext | undefined;
+ let inspectInfo: ElementInspectInfo | undefined;
+ let pageContext: PageContext | undefined;
+ let screenshots: ScreenshotData | null | undefined;
+ let connectedData: unknown;
+ let issueMarkdown: string | undefined;
+ let agentInput: unknown;
+ let integrationSnippet: string | undefined;
+
+ // Step 1: element
+ {
+ const start = nowMs();
+ updateStep("element", {
+ status: "running",
+ detail: "Building element selector + attributes…",
+ });
+ try {
+ elementContext = buildElementContext(targetEl);
+ inspectInfo = feature.demo.runner.includeInspectInfo
+ ? getElementInspectInfo(targetEl)
+ : undefined;
+
+ // ensure the UI updates even when capture is instant
+ await sleep(200);
+ if (runTokenRef.current !== token) return;
+
+ setArtifacts((prev) => ({
+ ...prev,
+ elementContext: elementContext!,
+ inspectInfo,
+ }));
+ updateStep("element", {
+ status: "success",
+ durationMs: nowMs() - start,
+ detail: `Selector: ${elementContext!.selector}`,
+ });
+ } catch (err) {
+ if (runTokenRef.current !== token) return;
+ updateStep("element", {
+ status: "error",
+ durationMs: nowMs() - start,
+ errorMessage: err instanceof Error ? err.message : String(err),
+ });
+ setRunStatus("idle");
+ return;
+ }
+ }
+
+ // Step 2: container
+ {
+ const start = nowMs();
+ updateStep("container", {
+ status: "running",
+ detail: "Capturing container context…",
+ });
+ try {
+ containerContext = buildElementContext(containerEl);
+ await sleep(200);
+ if (runTokenRef.current !== token) return;
+ setArtifacts((prev) => ({ ...prev, containerContext: containerContext! }));
+ updateStep("container", {
+ status: "success",
+ durationMs: nowMs() - start,
+ detail: `Selector: ${containerContext!.selector}`,
+ });
+ } catch (err) {
+ if (runTokenRef.current !== token) return;
+ // Fallback: simulate container
+ containerContext = elementContext;
+ setArtifacts((prev) => ({
+ ...prev,
+ containerContext: elementContext,
+ }));
+ updateStep("container", {
+ status: "simulated",
+ durationMs: nowMs() - start,
+ detail: "Using element context as container fallback",
+ });
+ }
+ }
+
+ // Step 3: connected data (screenshots, page context, feature-specific outputs)
+ {
+ const start = nowMs();
+ updateStep("connected", {
+ status: "running",
+ detail: "Collecting page + connected context…",
+ });
+
+ try {
+ pageContext = buildPageContext();
+ screenshots = undefined;
+
+ if (feature.demo.runner.enableScreenshots) {
+ updateStep("connected", {
+ status: "running",
+ detail: isScreenshotSupported()
+ ? "Capturing element/container/viewport…"
+ : "Screenshots unsupported; capturing errors…",
+ });
+ screenshots = await captureAllScreenshots(
+ targetEl,
+ containerEl,
+ // keep safe defaults for homepage demo; don’t show the preview UI
+ {
+ enabled: true,
+ showPreview: false,
+ },
+ );
+ }
+
+ // Feature-specific connected data + next action prep
+ connectedData =
+ feature.id === "github-integration"
+ ? {
+ issueTitle: `Issue: ${elementContext!.tag} @ ${elementContext!.selector}`,
+ labels: ["anyclick", "demo"],
+ }
+ : feature.id === "ai-agent"
+ ? {
+ intent: "Ask AI",
+ suggestedPrompts: [
+ "Why is this element here?",
+ "What should I debug next?",
+ ],
+ }
+ : feature.id === "visual-capture"
+ ? {
+ screenshots: summarizeScreenshots(screenshots),
+ masking: { sensitiveSelectors: "defaults" },
+ }
+ : feature.id === "framework-agnostic"
+ ? {
+ mode: integrationMode,
+ note: "Adapter + client wiring varies by environment",
+ }
+ : feature.id === "zero-config"
+ ? {
+ defaults: {
+ highlight: true,
+ screenshots: true,
+ menu: "preset",
+ },
+ }
+ : {
+ ancestors: elementContext!.ancestors.slice(0, 3),
+ dataAttributes: elementContext!.dataAttributes,
+ };
+
+ // Next action outputs
+ issueMarkdown =
+ feature.id === "github-integration"
+ ? buildGitHubIssueMarkdown({
+ element: elementContext!,
+ page: pageContext!,
+ screenshots: screenshots ?? null,
+ })
+ : undefined;
+
+ agentInput =
+ feature.id === "ai-agent"
+ ? buildAgentInput({
+ element: elementContext!,
+ page: pageContext!,
+ screenshots: screenshots ?? null,
+ })
+ : undefined;
+
+ integrationSnippet =
+ feature.id === "framework-agnostic"
+ ? buildIntegrationSnippet(integrationMode)
+ : undefined;
+
+ await sleep(250);
+ if (runTokenRef.current !== token) return;
+
+ setArtifacts((prev) => ({
+ ...prev,
+ agentInput,
+ connectedData,
+ integrationSnippet,
+ issueMarkdown,
+ pageContext,
+ screenshots: screenshots ?? null,
+ }));
+
+ updateStep("connected", {
+ status: "success",
+ durationMs: nowMs() - start,
+ detail:
+ feature.demo.runner.enableScreenshots && screenshots
+ ? `Screenshots: ${
+ summarizeScreenshots(screenshots)?.errors ? "partial" : "ok"
+ }`
+ : `Connected context captured`,
+ });
+ } catch (err) {
+ if (runTokenRef.current !== token) return;
+ updateStep("connected", {
+ status: "error",
+ durationMs: nowMs() - start,
+ errorMessage: err instanceof Error ? err.message : String(err),
+ });
+ setRunStatus("idle");
+ return;
+ }
+ }
+
+ // Step 4: shape metadata + payload
+ {
+ const start = nowMs();
+ updateStep("metadata", {
+ status: "running",
+ detail: "Shaping metadata + payload…",
+ });
+ try {
+ const screenshotSummary = summarizeScreenshots(screenshots ?? null);
+
+ const metadata: Record = {
+ demo: true,
+ featureId: feature.id,
+ selectedTargetKey,
+ containerContext: containerContext ?? null,
+ connectedData: connectedData ?? null,
+ screenshots: screenshotSummary,
+ integrationMode:
+ feature.id === "framework-agnostic" ? integrationMode : undefined,
+ };
+
+ const payload = buildAnyclickPayload(targetEl, feature.id, {
+ metadata,
+ });
+
+ // Attach screenshots for demo purposes (payload supports it)
+ if (screenshots) {
+ payload.screenshots = screenshots;
+ }
+
+ await sleep(200);
+ if (runTokenRef.current !== token) return;
+
+ setArtifacts((prev) => ({
+ ...prev,
+ metadata,
+ payload,
+ // Keep the page context captured in step 3 for display
+ pageContext: prev.pageContext ?? pageContext,
+ }));
+ updateStep("metadata", {
+ status: "success",
+ durationMs: nowMs() - start,
+ detail: `Payload type: ${payload.type}`,
+ });
+ setRunStatus("success");
+
+ if (action.setActivePanel) {
+ setActivePanel(action.setActivePanel);
+ }
+ } catch (err) {
+ if (runTokenRef.current !== token) return;
+ updateStep("metadata", {
+ status: "error",
+ durationMs: nowMs() - start,
+ errorMessage: err instanceof Error ? err.message : String(err),
+ });
+ setRunStatus("idle");
+ return;
+ }
+ }
+ }
+ },
+ [
+ artifacts,
+ copyToClipboard,
+ feature.demo.defaultPanel,
+ feature.demo.runner.enableScreenshots,
+ feature.demo.runner.includeInspectInfo,
+ feature.id,
+ getActiveTargetElement,
+ integrationMode,
+ reset,
+ selectedTargetKey,
+ updateStep,
+ ],
+ );
+
+ const handleTargetClick = React.useCallback(
+ (key: string) => {
+ if (!isTargetPickerEnabled) return;
+ setSelectedTargetKey(key);
+ setIsTargetPickerEnabled(false);
+ showToast(`Target selected: ${key}`, "info");
+ },
+ [isTargetPickerEnabled],
+ );
+
+ const menuItems: ContextMenuItem[] = React.useMemo(() => {
+ return feature.demo.menuItems.map((item) => ({
+ icon: item.icon,
+ label: item.label,
+ showComment: false,
+ type: `demo-${feature.id}-${item.id}`,
+ onClick: async ({ closeMenu }) => {
+ closeMenu();
+ await handleAction(item.action);
+ },
+ }));
+ }, [feature.demo.menuItems, feature.id, handleAction]);
+
+ const onOpenMenu = React.useCallback(
+ (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setMenuPosition({ x: e.clientX, y: e.clientY });
+ setMenuVisible(true);
+ },
+ [],
+ );
+
+ const onCloseMenu = React.useCallback(() => {
+ setMenuVisible(false);
+ setMenuPosition(OFFSCREEN_POSITION);
+ }, []);
+
+ const demoHeader = (
+
+
+ Right-click inside this demo for actions.
+
+
+ {runStatus === "running"
+ ? "Running…"
+ : runStatus === "success"
+ ? "Capture complete"
+ : "Idle"}
+
+
+ );
+
+ const screenshotsSummary = React.useMemo(
+ () => summarizeScreenshots(artifacts.screenshots),
+ [artifacts.screenshots],
+ );
+
+ const dataPanel = (
+
+
+ {demoHeader}
+
+
+
+
+ Element context
+
+
+ {safeJson(artifacts.elementContext ?? null)}
+
+
+
+ {artifacts.inspectInfo ? (
+
+
+ Inspect info
+
+
+ {safeJson(artifacts.inspectInfo)}
+
+
+ ) : null}
+
+
+
+ Container context
+
+
+ {safeJson(artifacts.containerContext ?? null)}
+
+
+
+
+
+ Page context
+
+
+ {safeJson(artifacts.pageContext ?? null)}
+
+
+
+
+
+ Connected data
+
+
+ {safeJson(artifacts.connectedData ?? null)}
+
+
+
+
+
+ Metadata + payload (sanitized)
+
+
+ {safeJson({
+ metadata: artifacts.metadata ?? null,
+ payload: artifacts.payload
+ ? {
+ ...artifacts.payload,
+ screenshots: artifacts.payload.screenshots
+ ? summarizeScreenshots(artifacts.payload.screenshots)
+ : undefined,
+ }
+ : null,
+ })}
+
+
+
+ {feature.demo.runner.enableScreenshots ? (
+
+
+ Screenshots
+
+
+
+ {safeJson(screenshotsSummary)}
+
+
+ {(
+ [
+ ["element", artifacts.screenshots?.element?.dataUrl],
+ ["container", artifacts.screenshots?.container?.dataUrl],
+ ["viewport", artifacts.screenshots?.viewport?.dataUrl],
+ ] as const
+ ).map(([label, dataUrl]) => (
+
+
+ {label}
+
+ {dataUrl ? (
+ // eslint-disable-next-line @next/next/no-img-element
+

+ ) : (
+
+ n/a
+
+ )}
+
+ ))}
+
+
+
+ ) : null}
+
+ );
+
+ const statusPanel = (
+
+
+
+
+ Run status:{" "}
+ {runStatus}
+
+
+ Screenshot support:{" "}
+
+ {isScreenshotSupported() ? "yes" : "no"}
+
+
+
+
+
+
+ {safeJson({
+ steps: stepStates,
+ simulatedFallbacks: Object.entries(stepStates)
+ .filter(([, s]) => s.status === "simulated")
+ .map(([k]) => k),
+ screenshotErrors: screenshotsSummary?.errors ?? null,
+ })}
+
+
+ );
+
+ const nextPanel = (
+
+ {feature.id === "github-integration" ? (
+
+ {artifacts.issueMarkdown ?? "Run the capture to draft an issue…"}
+
+ ) : feature.id === "ai-agent" ? (
+
+ {safeJson(artifacts.agentInput ?? {})}
+
+ ) : feature.id === "framework-agnostic" ? (
+
+
+
+ Mode:{" "}
+
+ {integrationMode.framework} / {integrationMode.scope}
+
+
+
+
+ {buildIntegrationSnippet(integrationMode)}
+
+
+ ) : feature.id === "zero-config" ? (
+
+ {buildZeroConfigSnippet()}
+
+ ) : (
+
+ {safeJson({
+ metadata: artifacts.metadata ?? null,
+ payload: artifacts.payload
+ ? {
+ ...artifacts.payload,
+ screenshots: artifacts.payload.screenshots
+ ? summarizeScreenshots(artifacts.payload.screenshots)
+ : undefined,
+ }
+ : null,
+ })}
+
+ )}
+
+ );
+
+ const primary = feature.demo.primaryCta;
+ const secondary = feature.demo.secondaryCta;
+
+ return (
+
+ {/* CTA row */}
+
+
+
+ {secondary ? (
+ (() => {
+ const isSecondaryDisabled =
+ runStatus === "running" ||
+ (secondary.action.kind === "retakeScreenshots" &&
+ runStatus !== "success");
+
+ return (
+
+ );
+ })()
+ ) : null}
+
+
+
+
+ {/* Demo sandbox (captures real DOM context from these elements) */}
+
+
+
+ Demo sandbox
+
+ (selected:{" "}
+ {selectedTargetKey})
+
+
+
+ {isTargetPickerEnabled
+ ? "Click an element below to select it"
+ : "Click primary CTA to run capture"}
+
+
+
+
+
+
+
+
+
handleTargetClick("info")}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") handleTargetClick("info");
+ }}
+ className={cn(
+ "rounded-lg border px-3 py-2 text-sm transition-colors select-none",
+ selectedTargetKey === "info"
+ ? "border-white/25 bg-white/10"
+ : "border-white/10 bg-white/5 hover:bg-white/8",
+ )}
+ >
+
+ Info row
+
+
+
+ data-attrs, text, hierarchy
+
+
+
+
+
+ {/* Workflow timeline */}
+
+
+ {/* Details */}
+
+
+ {/* Card-local context menu */}
+
>}
+ highlightConfig={{ enabled: false }}
+ isSubmitting={false}
+ items={menuItems}
+ onClose={onCloseMenu}
+ onSelect={() => {
+ // no-op: all items are handled via onClick hooks
+ }}
+ position={menuPosition}
+ screenshotConfig={{ enabled: false, showPreview: false }}
+ targetElement={getActiveTargetElement() ?? containerRef.current}
+ visible={menuVisible}
+ />
+
+ );
+}
+
diff --git a/apps/web/src/components/features/FeatureCard.tsx b/apps/web/src/components/features/FeatureCard.tsx
new file mode 100644
index 0000000..858919b
--- /dev/null
+++ b/apps/web/src/components/features/FeatureCard.tsx
@@ -0,0 +1,143 @@
+"use client";
+
+import * as React from "react";
+import Link from "next/link";
+import { AnimatePresence, motion } from "motion/react";
+import { ArrowRight, X } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { FeatureCaptureDemo } from "./FeatureCaptureDemo";
+import type { FeatureConfig } from "./types";
+
+export interface FeatureCardProps {
+ className?: string;
+ feature: FeatureConfig;
+ isExpanded: boolean;
+ onToggle: () => void;
+}
+
+export function FeatureCard({
+ className,
+ feature,
+ isExpanded,
+ onToggle,
+}: FeatureCardProps) {
+ const handleClick = React.useCallback(() => {
+ if (!isExpanded) {
+ onToggle();
+ }
+ }, [isExpanded, onToggle]);
+
+ const handleClose = React.useCallback(
+ (e: React.MouseEvent) => {
+ e.stopPropagation();
+ onToggle();
+ },
+ [onToggle],
+ );
+
+ return (
+
+
+ {!isExpanded ? (
+
+
+ {feature.icon}
+
+
+ {feature.title}
+
+
+ {feature.description}
+
+
+ Learn more
+
+
+ ) : (
+
+
+
+
+
+ {feature.icon}
+
+
+
+ {feature.title}
+
+
+
+
+
+
+ {feature.expandedDescription}
+
+
+
+
+ e.stopPropagation()}
+ className={cn(
+ "mt-4 inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium text-white transition-opacity hover:opacity-90",
+ feature.colorClasses.bg,
+ )}
+ >
+ {feature.linkText}
+
+
+
+ )}
+
+
+ );
+}
+
diff --git a/apps/web/src/components/features/types.ts b/apps/web/src/components/features/types.ts
new file mode 100644
index 0000000..5be9e85
--- /dev/null
+++ b/apps/web/src/components/features/types.ts
@@ -0,0 +1,77 @@
+import type { ReactNode } from "react";
+
+export type DemoPanel = "data" | "next" | "status";
+
+export type FeatureDemoActionKind =
+ | "copy"
+ | "reset"
+ | "retakeScreenshots"
+ | "setPanel"
+ | "startCapture"
+ | "toggleIntegrationMode"
+ | "toggleTargetPicker";
+
+export type CopyTarget =
+ | "agentContext"
+ | "commands"
+ | "issueMarkdown"
+ | "payloadJson"
+ | "screenshotSummary"
+ | "snippet";
+
+export type FeatureDemoAction =
+ | {
+ kind: "startCapture";
+ /** Optional hint to switch panels after starting capture. */
+ setActivePanel?: DemoPanel;
+ }
+ | { kind: "reset" }
+ | { kind: "toggleTargetPicker" }
+ | { kind: "retakeScreenshots" }
+ | { kind: "toggleIntegrationMode" }
+ | { kind: "setPanel"; panel: DemoPanel }
+ | { kind: "copy"; target: CopyTarget };
+
+export interface FeatureDemoMenuItem {
+ action: FeatureDemoAction;
+ icon?: ReactNode;
+ id: string;
+ label: string;
+}
+
+export interface FeatureDemoConfig {
+ /** Default panel when the card expands (and after reset). */
+ defaultPanel: DemoPanel;
+ /** Primary CTA shown in the card. */
+ primaryCta: { action: FeatureDemoAction; label: string };
+ /** Optional secondary CTA shown in the card. */
+ secondaryCta?: { action: FeatureDemoAction; label: string };
+ /** Right-click menu items for the demo area. */
+ menuItems: FeatureDemoMenuItem[];
+ /** Capture runner options for this card. */
+ runner: {
+ enableScreenshots?: boolean;
+ includeInspectInfo?: boolean;
+ };
+}
+
+export interface FeatureConfig {
+ color: string;
+ colorClasses: {
+ bg: string;
+ border: string;
+ dot: string;
+ dotMuted: string;
+ iconBg: string;
+ text: string;
+ };
+ demo: FeatureDemoConfig;
+ description: string;
+ expandedDescription: string;
+ icon: ReactNode;
+ id: string;
+ linkHref: string;
+ linkText: string;
+ title: string;
+}
+
diff --git a/packages/anyclick-react/src/AnyclickProvider.tsx b/packages/anyclick-react/src/AnyclickProvider.tsx
index c577b07..657fde9 100644
--- a/packages/anyclick-react/src/AnyclickProvider.tsx
+++ b/packages/anyclick-react/src/AnyclickProvider.tsx
@@ -26,6 +26,7 @@ import type {
} from "@ewjdev/anyclick-core";
import { createAnyclickClient } from "@ewjdev/anyclick-core";
import { ContextMenu } from "./ContextMenu";
+import { ToastContainer, showToast } from "./Toast";
import { AnyclickContext, useAnyclick } from "./context";
import { findContainerParent } from "./highlight";
import { type ProviderInstance, useProviderStore } from "./store";
@@ -90,6 +91,7 @@ export function AnyclickProvider({
stripAttributes,
targetFilter,
theme,
+ toastConfig,
touchHoldDurationMs,
touchMoveThreshold,
}: AnyclickProviderProps) {
@@ -343,6 +345,21 @@ export function AnyclickProvider({
metadata,
screenshots,
});
+
+ // Show success toast if enabled (default: true)
+ if (toastConfig?.enabled !== false) {
+ showToast(toastConfig?.successMessage ?? "Feedback sent!", "success");
+ }
+ } catch (error) {
+ // Show error toast if enabled (default: true)
+ if (toastConfig?.enabled !== false) {
+ showToast(
+ toastConfig?.errorMessage ?? "Failed to send feedback",
+ "error",
+ );
+ }
+ // Re-throw so the client's onSubmitError callback can handle it
+ throw error;
} finally {
setIsSubmitting(false);
setMenuVisible(false);
@@ -351,7 +368,7 @@ export function AnyclickProvider({
setContainerElement(null);
}
},
- [metadata],
+ [metadata, toastConfig],
);
// Open menu programmatically
@@ -458,6 +475,9 @@ export function AnyclickProvider({
children
);
+ // Only render ToastContainer for non-scoped (root) providers to avoid duplicates
+ const shouldRenderToastContainer = !scoped && toastConfig?.enabled !== false;
+
return (
@@ -478,6 +498,9 @@ export function AnyclickProvider({
targetElement={targetElement}
visible={menuVisible && !effectiveDisabled}
/>
+ {shouldRenderToastContainer && (
+
+ )}
);
diff --git a/packages/anyclick-react/src/ContextMenu.tsx b/packages/anyclick-react/src/ContextMenu.tsx
index 8e6737d..97027ea 100644
--- a/packages/anyclick-react/src/ContextMenu.tsx
+++ b/packages/anyclick-react/src/ContextMenu.tsx
@@ -13,6 +13,7 @@ import {
ChevronRightIcon,
FlagIcon,
GripVertical,
+ Loader2,
PlusIcon,
Sparkles,
ThumbsUpIcon,
@@ -212,9 +213,20 @@ const CommentForm = React.memo(function CommentForm({
...menuStyles.button,
...menuStyles.submitButton,
...(isSubmitting ? menuStyles.submitButtonDisabled : {}),
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: 6,
}}
>
- {isSubmitting ? "Sending..." : "Send"}
+ {isSubmitting ? (
+ <>
+
+ Sending...
+ >
+ ) : (
+ "Send"
+ )}
diff --git a/packages/anyclick-react/src/Toast/Toast.tsx b/packages/anyclick-react/src/Toast/Toast.tsx
new file mode 100644
index 0000000..57188ea
--- /dev/null
+++ b/packages/anyclick-react/src/Toast/Toast.tsx
@@ -0,0 +1,76 @@
+"use client";
+
+/**
+ * Toast component for @ewjdev/anyclick-react
+ *
+ * Individual toast notification with animation support.
+ *
+ * @module Toast/Toast
+ * @since 1.6.0
+ */
+import React, { useCallback } from "react";
+import { X } from "lucide-react";
+import { motion } from "motion/react";
+import { TOAST_COLORS, closeButtonStyles, toastStyles } from "./styles";
+import type { Toast as ToastType } from "./types";
+
+export interface ToastProps {
+ /** Toast data */
+ toast: ToastType;
+ /** Callback when toast is dismissed */
+ onDismiss: (id: string) => void;
+}
+
+/**
+ * Individual toast notification component.
+ *
+ * Renders a toast with:
+ * - Colored background based on type
+ * - Message text
+ * - Dismiss button
+ * - Enter/exit animations
+ */
+export const Toast = React.memo(function Toast({
+ toast,
+ onDismiss,
+}: ToastProps) {
+ const { bg, border, text } = TOAST_COLORS[toast.type];
+
+ const handleDismiss = useCallback(() => {
+ onDismiss(toast.id);
+ }, [onDismiss, toast.id]);
+
+ return (
+
+ {toast.message}
+
+
+ );
+});
diff --git a/packages/anyclick-react/src/Toast/ToastContainer.tsx b/packages/anyclick-react/src/Toast/ToastContainer.tsx
new file mode 100644
index 0000000..3aa5e84
--- /dev/null
+++ b/packages/anyclick-react/src/Toast/ToastContainer.tsx
@@ -0,0 +1,80 @@
+"use client";
+
+/**
+ * ToastContainer component for @ewjdev/anyclick-react
+ *
+ * Renders toasts in a fixed position container using a portal.
+ *
+ * @module Toast/ToastContainer
+ * @since 1.6.0
+ */
+import React, { useCallback, useEffect, useState } from "react";
+import { createPortal } from "react-dom";
+import { AnimatePresence } from "motion/react";
+import { Toast } from "./Toast";
+import { POSITION_STYLES, containerStyles } from "./styles";
+import { useToastStore } from "./toastStore";
+import type { ToastConfig } from "./types";
+
+export interface ToastContainerProps {
+ /** Position of the toast container */
+ position?: ToastConfig["position"];
+}
+
+/**
+ * Container component that renders all active toasts.
+ *
+ * Uses a portal to render toasts at the document body level,
+ * ensuring they appear above all other content.
+ *
+ * @example
+ * ```tsx
+ * // Typically included automatically by AnyclickProvider
+ *
+ * ```
+ */
+export function ToastContainer({
+ position = "bottom-right",
+}: ToastContainerProps) {
+ const [mounted, setMounted] = useState(false);
+ const toasts = useToastStore((state) => state.toasts);
+ const removeToast = useToastStore((state) => state.removeToast);
+
+ // Only render after mount to avoid SSR hydration issues
+ useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ const handleDismiss = useCallback(
+ (id: string) => {
+ removeToast(id);
+ },
+ [removeToast],
+ );
+
+ // Don't render on server or if no toasts
+ if (!mounted) {
+ return null;
+ }
+
+ const positionStyle = POSITION_STYLES[position];
+
+ const container = (
+
+
+ {toasts.map((toast) => (
+
+ ))}
+
+
+ );
+
+ // Render via portal to document body
+ return createPortal(container, document.body);
+}
diff --git a/packages/anyclick-react/src/Toast/index.ts b/packages/anyclick-react/src/Toast/index.ts
new file mode 100644
index 0000000..0e51409
--- /dev/null
+++ b/packages/anyclick-react/src/Toast/index.ts
@@ -0,0 +1,43 @@
+/**
+ * Toast module for @ewjdev/anyclick-react
+ *
+ * Provides toast notifications following the showToast pattern
+ * from anyclick-extension.
+ *
+ * @module Toast
+ * @since 1.6.0
+ */
+
+// Components
+export { Toast } from "./Toast";
+export type { ToastProps } from "./Toast";
+
+export { ToastContainer } from "./ToastContainer";
+export type { ToastContainerProps } from "./ToastContainer";
+
+// Store and imperative API
+export {
+ useToastStore,
+ showToast,
+ dismissToast,
+ clearToasts,
+} from "./toastStore";
+
+// Types
+export type {
+ Toast as ToastData,
+ ToastType,
+ ToastConfig,
+ ToastStore,
+} from "./types";
+
+// Styles (for advanced customization)
+export {
+ TOAST_COLORS,
+ DEFAULT_TOAST_DURATION,
+ DEFAULT_MAX_TOASTS,
+ POSITION_STYLES,
+ containerStyles,
+ toastStyles,
+ closeButtonStyles,
+} from "./styles";
diff --git a/packages/anyclick-react/src/Toast/styles.ts b/packages/anyclick-react/src/Toast/styles.ts
new file mode 100644
index 0000000..879ef53
--- /dev/null
+++ b/packages/anyclick-react/src/Toast/styles.ts
@@ -0,0 +1,117 @@
+/**
+ * Toast styles for @ewjdev/anyclick-react
+ *
+ * Uses the same color palette as anyclick-extension for consistency.
+ *
+ * @module Toast/styles
+ * @since 1.6.0
+ */
+import type { ToastType } from "./types";
+
+/**
+ * Standard toast colors matching the extension.
+ * - success: emerald
+ * - error: red
+ * - warning: amber
+ * - info: blue
+ */
+export const TOAST_COLORS: Record<
+ ToastType,
+ { bg: string; border: string; text: string }
+> = {
+ success: { bg: "#10b981", border: "#059669", text: "#ffffff" },
+ error: { bg: "#ef4444", border: "#dc2626", text: "#ffffff" },
+ warning: { bg: "#f59e0b", border: "#d97706", text: "#ffffff" },
+ info: { bg: "#3b82f6", border: "#2563eb", text: "#ffffff" },
+} as const;
+
+/**
+ * Default duration for toast auto-dismiss (4 seconds).
+ * Matches the extension's content.ts behavior.
+ */
+export const DEFAULT_TOAST_DURATION = 4000;
+
+/**
+ * Maximum number of toasts to show at once.
+ */
+export const DEFAULT_MAX_TOASTS = 3;
+
+/**
+ * Position styles for the toast container.
+ */
+export const POSITION_STYLES: Record<
+ "top-right" | "top-left" | "bottom-right" | "bottom-left",
+ React.CSSProperties
+> = {
+ "top-right": {
+ top: 20,
+ right: 20,
+ flexDirection: "column",
+ },
+ "top-left": {
+ top: 20,
+ left: 20,
+ flexDirection: "column",
+ },
+ "bottom-right": {
+ bottom: 20,
+ right: 20,
+ flexDirection: "column-reverse",
+ },
+ "bottom-left": {
+ bottom: 20,
+ left: 20,
+ flexDirection: "column-reverse",
+ },
+};
+
+/**
+ * Base container styles for the toast stack.
+ */
+export const containerStyles: React.CSSProperties = {
+ position: "fixed",
+ display: "flex",
+ gap: 8,
+ zIndex: 999999,
+ pointerEvents: "none",
+};
+
+/**
+ * Base toast styles.
+ */
+export const toastStyles: React.CSSProperties = {
+ display: "flex",
+ alignItems: "center",
+ gap: 8,
+ padding: "12px 16px",
+ borderRadius: 8,
+ fontSize: 14,
+ fontWeight: 500,
+ fontFamily:
+ '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
+ boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15), 0 0 1px rgba(0, 0, 0, 0.1)",
+ backdropFilter: "blur(8px)",
+ pointerEvents: "auto",
+ maxWidth: 360,
+ minWidth: 200,
+};
+
+/**
+ * Close button styles.
+ */
+export const closeButtonStyles: React.CSSProperties = {
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ width: 20,
+ height: 20,
+ padding: 0,
+ border: "none",
+ background: "transparent",
+ color: "inherit",
+ opacity: 0.7,
+ cursor: "pointer",
+ borderRadius: 4,
+ transition: "opacity 0.15s ease",
+ flexShrink: 0,
+};
diff --git a/packages/anyclick-react/src/Toast/toastStore.ts b/packages/anyclick-react/src/Toast/toastStore.ts
new file mode 100644
index 0000000..7ca8167
--- /dev/null
+++ b/packages/anyclick-react/src/Toast/toastStore.ts
@@ -0,0 +1,120 @@
+/**
+ * Toast store for @ewjdev/anyclick-react
+ *
+ * Uses Zustand for lightweight state management.
+ * Provides both hook-based and imperative API access.
+ *
+ * @module Toast/toastStore
+ * @since 1.6.0
+ */
+import { create } from "zustand";
+import { DEFAULT_MAX_TOASTS, DEFAULT_TOAST_DURATION } from "./styles";
+import type { Toast, ToastStore, ToastType } from "./types";
+
+/**
+ * Generate a unique ID for toasts.
+ */
+function generateId(): string {
+ return Math.random().toString(36).slice(2, 11);
+}
+
+/**
+ * Zustand store for managing toast state.
+ *
+ * @internal
+ */
+export const useToastStore = create((set, get) => ({
+ toasts: [],
+
+ addToast: (message: string, type: ToastType, duration?: number) => {
+ const id = generateId();
+ const actualDuration = duration ?? DEFAULT_TOAST_DURATION;
+
+ set((state) => ({
+ // Keep only the last (maxToasts - 1) toasts to make room for new one
+ toasts: [
+ ...state.toasts.slice(-(DEFAULT_MAX_TOASTS - 1)),
+ { id, message, type, duration: actualDuration },
+ ],
+ }));
+
+ // Auto-dismiss after duration
+ setTimeout(() => {
+ const { toasts } = get();
+ // Only remove if the toast still exists (wasn't manually dismissed)
+ if (toasts.some((t) => t.id === id)) {
+ set((state) => ({
+ toasts: state.toasts.filter((t) => t.id !== id),
+ }));
+ }
+ }, actualDuration);
+ },
+
+ removeToast: (id: string) => {
+ set((state) => ({
+ toasts: state.toasts.filter((t) => t.id !== id),
+ }));
+ },
+
+ clearToasts: () => {
+ set({ toasts: [] });
+ },
+}));
+
+/**
+ * Show a toast notification.
+ *
+ * This is the primary imperative API for showing toasts.
+ * Matches the `showToast(message, type)` pattern from anyclick-extension.
+ *
+ * @param message - The message to display
+ * @param type - Toast type: 'success' | 'error' | 'warning' | 'info'
+ * @param duration - Optional duration in ms (default: 4000)
+ *
+ * @example
+ * ```ts
+ * import { showToast } from "@ewjdev/anyclick-react";
+ *
+ * // Success notification
+ * showToast("Settings saved!", "success");
+ *
+ * // Error notification
+ * showToast("Upload failed", "error");
+ *
+ * // Warning notification
+ * showToast("Feature coming soon", "warning");
+ *
+ * // Info notification (default type)
+ * showToast("Processing...", "info");
+ * showToast("Processing..."); // type defaults to "info"
+ * ```
+ *
+ * @since 1.6.0
+ */
+export function showToast(
+ message: string,
+ type: ToastType = "info",
+ duration?: number,
+): void {
+ useToastStore.getState().addToast(message, type, duration);
+}
+
+/**
+ * Remove a specific toast by ID.
+ *
+ * @param id - The toast ID to remove
+ *
+ * @since 1.6.0
+ */
+export function dismissToast(id: string): void {
+ useToastStore.getState().removeToast(id);
+}
+
+/**
+ * Clear all toasts.
+ *
+ * @since 1.6.0
+ */
+export function clearToasts(): void {
+ useToastStore.getState().clearToasts();
+}
diff --git a/packages/anyclick-react/src/Toast/types.ts b/packages/anyclick-react/src/Toast/types.ts
new file mode 100644
index 0000000..f3e8978
--- /dev/null
+++ b/packages/anyclick-react/src/Toast/types.ts
@@ -0,0 +1,89 @@
+/**
+ * Toast types for @ewjdev/anyclick-react
+ *
+ * Matches the showToast pattern from anyclick-extension for consistency.
+ *
+ * @module Toast/types
+ * @since 1.6.0
+ */
+
+/**
+ * Toast notification type.
+ * Matches the extension pattern: success | error | warning | info
+ */
+export type ToastType = "success" | "error" | "warning" | "info";
+
+/**
+ * Internal toast state representation.
+ */
+export interface Toast {
+ /** Unique identifier */
+ id: string;
+ /** Toast type determines color */
+ type: ToastType;
+ /** Message to display */
+ message: string;
+ /** Duration in ms before auto-dismiss (default: 4000) */
+ duration?: number;
+}
+
+/**
+ * Configuration for automatic toasts on submission.
+ *
+ * @example
+ * ```tsx
+ *
+ * ```
+ */
+export interface ToastConfig {
+ /**
+ * Whether automatic toasts are enabled.
+ * @default true
+ */
+ enabled?: boolean;
+
+ /**
+ * Position of the toast container.
+ * @default "bottom-right"
+ */
+ position?: "top-right" | "top-left" | "bottom-right" | "bottom-left";
+
+ /**
+ * Maximum number of toasts to show at once.
+ * @default 3
+ */
+ maxToasts?: number;
+
+ /**
+ * Custom success message for submissions.
+ * @default "Feedback sent!"
+ */
+ successMessage?: string;
+
+ /**
+ * Custom error message for failed submissions.
+ * @default "Failed to send feedback"
+ */
+ errorMessage?: string;
+}
+
+/**
+ * Toast store state interface.
+ */
+export interface ToastStore {
+ /** Current toasts */
+ toasts: Toast[];
+ /** Add a new toast */
+ addToast: (message: string, type: ToastType, duration?: number) => void;
+ /** Remove a toast by id */
+ removeToast: (id: string) => void;
+ /** Clear all toasts */
+ clearToasts: () => void;
+}
diff --git a/packages/anyclick-react/src/index.ts b/packages/anyclick-react/src/index.ts
index 8cfa092..8abbb63 100644
--- a/packages/anyclick-react/src/index.ts
+++ b/packages/anyclick-react/src/index.ts
@@ -428,6 +428,52 @@ export type {
AnyclickLogoProps,
} from "./AnyclickLogo";
+// ============================================================================
+// Toast Notifications
+// ============================================================================
+
+/**
+ * Show a toast notification.
+ * Matches the `showToast(message, type)` pattern from anyclick-extension.
+ *
+ * @param message - The message to display
+ * @param type - Toast type: 'success' | 'error' | 'warning' | 'info'
+ * @param duration - Optional duration in ms (default: 4000)
+ *
+ * @example
+ * ```ts
+ * import { showToast } from "@ewjdev/anyclick-react";
+ *
+ * showToast("Settings saved!", "success");
+ * showToast("Upload failed", "error");
+ * showToast("Feature coming soon", "warning");
+ * showToast("Processing...", "info");
+ * ```
+ *
+ * @since 1.6.0
+ */
+export { showToast, dismissToast, clearToasts } from "./Toast";
+
+/**
+ * Toast container component.
+ * Automatically included by AnyclickProvider, but can be used standalone.
+ */
+export { ToastContainer } from "./Toast";
+
+/**
+ * Toast store hook for advanced use cases.
+ */
+export { useToastStore } from "./Toast";
+
+export type {
+ /** Toast notification type */
+ ToastType,
+ /** Toast configuration for AnyclickProvider */
+ ToastConfig,
+ /** Internal toast data structure */
+ ToastData,
+} from "./Toast";
+
// ============================================================================
// QuickChat
// ============================================================================
diff --git a/packages/anyclick-react/src/types.ts b/packages/anyclick-react/src/types.ts
index 4a9a449..6b4c265 100644
--- a/packages/anyclick-react/src/types.ts
+++ b/packages/anyclick-react/src/types.ts
@@ -19,6 +19,9 @@ import type {
import { CompactModeConfig } from "./InspectDialog/InspectSimple";
import type { QuickChatConfig } from "./QuickChat/types";
+// Re-export ToastConfig from Toast module for convenience
+export type { ToastConfig } from "./Toast/types";
+
// ============================================================================
// Theme Configuration
// ============================================================================
@@ -387,6 +390,26 @@ export interface AnyclickProviderProps {
* Set to null or { disabled: true } to disable anyclick in this subtree.
*/
theme?: AnyclickTheme | null;
+ /**
+ * Configuration for toast notifications.
+ * By default, toasts are shown on submission success/error.
+ * Set `enabled: false` to disable automatic toasts.
+ *
+ * @example
+ * ```tsx
+ *
+ * ```
+ *
+ * @since 1.6.0
+ */
+ toastConfig?: import("./Toast/types").ToastConfig;
/** Duration in ms to hold touch before triggering context menu (default: 500) */
touchHoldDurationMs?: number;
/** Maximum movement in px before touch hold is cancelled (default: 10) */