diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 00000000..4155100a --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,18 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "web-dev", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev:web"], + "port": 5173, + "autoPort": false + }, + { + "name": "animation-preview", + "runtimeExecutable": "npx", + "runtimeArgs": ["vite", "--config", "vite.config.animation.ts"], + "port": 5199 + } + ] +} diff --git a/.claude/skills/animation-interface-demo.md b/.claude/skills/animation-interface-demo.md new file mode 100644 index 00000000..5ec8308c --- /dev/null +++ b/.claude/skills/animation-interface-demo.md @@ -0,0 +1,380 @@ +--- +name: animation-interface-demo +description: Generate a self-contained TSX + CSS Module looping interface animation demo. Use when the user describes a workflow, product process, or UI flow that should be visualized as an animated interface preview on the Eigent website. +--- + +# Animation Interface Demo Generator + +## Goal + +Generate a complete, self-contained looping interface animation demo as three files: + +1. `{Name}Display.tsx` — server-renderable React component (structure + data attributes) +2. `{Name}Cycler.tsx` — client-side animation island (DOM mutations only, no re-renders) +3. `{Name}Display.module.css` — all styles, no Tailwind, no CSS variables from the host app + +Output files go in `animation/` at the project root. + +--- + +## Workflow + +When the user describes a workflow or process: + +1. **Parse the workflow** into a flat ordered list of tasks (typically 3–6). Each task belongs to an agent or role. +2. **Identify agents/roles** and their tools/capabilities (shown as tags). +3. **Decide on interface layout** — the canonical layout has a left chat panel + right workspace with agent cards. +4. **Generate all three files** at once. + +--- + +## Architecture Rules + +### No dependencies +- Only `react`, `react/jsx-runtime`, `next/image` (for workspace previews), and `lucide-react` icons are allowed. +- No Tailwind classes. No CSS variables from the host project. No `framer-motion`. No state management. +- The `Display` component must be fully server-renderable (no `"use client"` directive). +- The `Cycler` component gets `"use client"` and is the ONLY interactive island. + +### Responsive scaling shell +Every display uses this exact scaling pattern: + +```css +.container { + position: relative; + width: 100%; + aspect-ratio: 1600 / 960; /* matches the inner canvas */ + container-type: inline-size; + --ui-scale: calc(100cqw / 1600px); /* scales everything proportionally */ + pointer-events: none; + user-select: none; + -webkit-user-select: none; + touch-action: pan-y; +} + +.inner { + position: absolute; + top: 0; left: 0; + width: 1600px; + height: 960px; + transform-origin: top left; + transform: scale(var(--ui-scale, 1)); + /* …border, background, overflow: hidden */ +} +``` + +The inner canvas is always **1600×960 px** at design scale. The container scales it to fill whatever width the parent provides. All child sizes use plain `px` values — they scale automatically. + +### Animation data attributes + +The Cycler drives the animation entirely via `data-*` attribute mutations and `textContent` updates. No React state. No re-renders. + +| Attribute | Where | Purpose | +|-----------|-------|---------| +| `data-cycle-root` | root container | Cycler `querySelector` anchor | +| `data-task-index={n}` | each task item | Marks the task's global index (0-based) | +| `data-done="true\|false"` | each task item | CSS toggles icon + background | +| `data-cycle-summary="done\|ongoing"` | badge text `` | Cycler updates count text | +| `data-cycle-chip="done\|ongoing"` | filter chip `` | Wrapper for per-agent chip | +| `data-agent-indexes="0,1"` | filter chip | Comma-separated global task indexes this agent owns | +| `data-cycle-count="done\|ongoing"` | count `` inside chip | Cycler updates count text | + +### CSS icon toggle pattern + +Both the "done" and "ongoing" icons are always in the DOM. CSS hides the inactive one: + +```css +.iconDone, .iconOngoing { display: none; } +.taskItem[data-done="true"] .iconDone { display: inline-block; } +.taskItem[data-done="false"] .iconOngoing { display: inline-block; } +``` + +The Cycler only sets `el.dataset.done = idx < step ? "true" : "false"` — no class toggling needed. + +--- + +## Display Component Template + +```tsx +// {Name}Display.tsx (NO "use client" — server-renderable) +import Image from "next/image"; +import { /* lucide icons */ } from "lucide-react"; +import s from "./{Name}Display.module.css"; +import {Name}Cycler from "./{Name}Cycler"; + +const TOTAL_TASKS = N; // total number of animated tasks + +function TaskItem({ done, text, index }: { done?: boolean; text: string; index: number }) { + return ( +
+
+ + +
+

{text}

+
+ ); +} + +export default function {Name}Display() { + const completedTaskCount = 2; // initial snapshot for SSR + const isTaskDone = (i: number) => i < completedTaskCount; + const getDoneCount = (indexes: number[]) => indexes.filter(isTaskDone).length; + const getOngoingCount = (indexes: number[]) => indexes.length - getDoneCount(indexes); + + return ( + + ); +} +``` + +--- + +## Cycler Component Template + +The Cycler template below is reusable across all demos. Copy it verbatim and adjust only: +- `CYCLE_MS` — how long each step holds (default 1500ms) +- `PAUSE_AT_FULL_MS` — pause when all tasks done before resetting (default 1800ms) +- `ROOT_MARGIN` — IntersectionObserver margin for early wake-up (default "200px") + +```tsx +"use client"; +import { useEffect, useRef } from "react"; + +const CYCLE_MS = 1500; +const PAUSE_AT_FULL_MS = 1800; +const ROOT_MARGIN = "200px"; + +export default function {Name}Cycler({ totalTasks }: { totalTasks: number }) { + const anchorRef = useRef(null); + + useEffect(() => { + const anchor = anchorRef.current; + if (!anchor) return; + if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return; + + const root = anchor.closest("[data-cycle-root]"); + if (!root) return; + + const taskItems = Array.from(root.querySelectorAll("[data-task-index]")); + const summarySpans = Array.from(root.querySelectorAll("[data-cycle-summary]")); + const chipCountSpans = Array.from(root.querySelectorAll("[data-cycle-count]")); + if (taskItems.length === 0) return; + + let step = 0; + let timer: ReturnType | null = null; + let isVisible = true; + let isTabVisible = !document.hidden; + + const applyStep = () => { + for (const el of taskItems) { + const idx = Number(el.dataset.taskIndex); + el.dataset.done = idx < step ? "true" : "false"; + } + for (const el of summarySpans) { + const role = el.dataset.cycleSummary; + if (role === "done") el.textContent = String(step); + else if (role === "ongoing") el.textContent = String(totalTasks - step); + } + for (const el of chipCountSpans) { + const chip = el.closest("[data-cycle-chip]"); + const indexesAttr = chip?.dataset.agentIndexes; + if (!chip || !indexesAttr) continue; + const indexes = indexesAttr.split(",").map(Number); + const doneCount = indexes.filter((i) => i < step).length; + const role = el.dataset.cycleCount; + el.textContent = String(role === "done" ? doneCount : indexes.length - doneCount); + } + }; + + const tick = () => { + if (!isVisible || !isTabVisible) { timer = setTimeout(tick, CYCLE_MS); return; } + step = (step + 1) % (totalTasks + 1); + applyStep(); + timer = setTimeout(tick, step === totalTasks ? PAUSE_AT_FULL_MS : CYCLE_MS); + }; + + step = 0; + applyStep(); + + let idleHandle: number | null = null; + let idleTimeout: ReturnType | null = null; + const start = () => { timer = setTimeout(tick, CYCLE_MS); }; + if (typeof window.requestIdleCallback === "function") { + idleHandle = window.requestIdleCallback(start, { timeout: 2500 }); + } else { + idleTimeout = setTimeout(start, 500); + } + + const io = new IntersectionObserver(([e]) => { isVisible = e.isIntersecting; }, { rootMargin: ROOT_MARGIN }); + io.observe(root); + const onVisibility = () => { isTabVisible = !document.hidden; }; + document.addEventListener("visibilitychange", onVisibility); + + return () => { + if (timer !== null) clearTimeout(timer); + if (idleHandle !== null && typeof window.cancelIdleCallback === "function") window.cancelIdleCallback(idleHandle); + if (idleTimeout !== null) clearTimeout(idleTimeout); + io.disconnect(); + document.removeEventListener("visibilitychange", onVisibility); + }; + }, [totalTasks]); + + return