diff --git a/components.json b/components.json index ffe928f5..4e5efeb1 100644 --- a/components.json +++ b/components.json @@ -1,21 +1,21 @@ { - "$schema": "https://ui.shadcn.com/schema.json", - "style": "new-york", - "rsc": true, - "tsx": true, - "tailwind": { - "config": "", - "css": "src/app/globals.css", - "baseColor": "neutral", - "cssVariables": true, - "prefix": "" - }, - "aliases": { - "components": "@/components", - "utils": "@/lib/utils", - "ui": "@/components/ui", - "lib": "@/lib", - "hooks": "@/hooks" - }, - "iconLibrary": "lucide" -} \ No newline at end of file + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" + } \ No newline at end of file diff --git a/docs/docs.json b/docs/docs.json index 71916938..59440f1e 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -87,6 +87,11 @@ "spells/tooltip-menu" ] }, + { + "group": "Guidance", + "icon": "bolt", + "pages": ["guidance/api-and-types", "guidance/endrect-utils"] + }, { "group": "External Integrations (Coming Soon)", "icon": "puzzle-piece", diff --git a/docs/guidance/api-and-types.mdx b/docs/guidance/api-and-types.mdx new file mode 100644 index 00000000..abadbd79 --- /dev/null +++ b/docs/guidance/api-and-types.mdx @@ -0,0 +1,320 @@ +--- +title: 'Guidance API and Types' +description: 'Control guidance via store actions only; the renderer handles visuals. Full API and examples for every GuidanceType.' +--- + +Cedar’s guidance is controlled through the store. You enqueue items with `useGuidance()` and the `GuidanceRenderer` (rendered once near the app root) handles visuals. + +## Setup + +```tsx +import { GuidanceRenderer } from 'cedar-os'; + +export function AppShell({ children }: { children: React.ReactNode }) { + return ( + <> + + {children} + + ); +} +``` + +## useGuidance API + +```ts +import { useGuidance } from 'cedar-os'; +d; +const { + // State + queue, + currentGuidance, + isActive, + prevCursorPosition, + onQueueComplete, + isAnimatingOut, + guidanceLogId, + guidanceSessionId, + + // Actions + addGuidance, + addGuidances, + addGuidancesToStart, + replaceGuidances, + removeGuidance, + clearQueue, + nextGuidance, + setIsActive, + setPrevCursorPosition, + setOnQueueComplete, + setIsAnimatingOut, + setGuidanceSessionId, + setCurrentGuidance, +} = useGuidance(); +``` + +### Actions + +- addGuidance(guidance) +- addGuidances(guidances) +- addGuidancesToStart(guidances) +- replaceGuidances(guidances) +- removeGuidance(id) +- clearQueue() +- nextGuidance(guidanceId?) +- setIsActive(boolean) +- setPrevCursorPosition({ x, y } | null) +- setOnQueueComplete(callback | null) +- setIsAnimatingOut(boolean) +- setGuidanceSessionId(string) +- setCurrentGuidance(guidance | null) + +Guidance targets accept any `PositionOrElement`: a DOM element, a selector (e.g., `'#save'`), `{ x, y }` coordinates, `'cursor'`, or a lazy resolver `{ _lazy: true, resolve: () => element }`. The renderer derives `endRect` for you and handles scrolling when supported. + +## Guidance types and examples + +All examples assume `const { addGuidance } = useGuidance()`. + +### CURSOR_TAKEOVER + +```tsx +addGuidance({ + type: 'CURSOR_TAKEOVER', + isRedirected: true, + messages: ['Let’s begin…'], + blocking: true, +}); +``` + +### VIRTUAL_CLICK + +```tsx +addGuidance({ + type: 'VIRTUAL_CLICK', + endPosition: '#settings', + tooltipText: 'Open settings', + tooltipPosition: 'right', + tooltipAnchor: 'rect', + advanceMode: 'default', // auto | external | default | number | (() => boolean) + blocking: true, + shouldScroll: true, +}); +``` + +### VIRTUAL_DRAG + +```tsx +addGuidance({ + type: 'VIRTUAL_DRAG', + startPosition: '#card-a', + endPosition: '#card-b', + tooltipText: 'Drag A onto B', + startTooltip: { tooltipText: 'Start', tooltipAnchor: 'rect' }, + dragCursor: true, + shouldScroll: true, +}); +``` + +### MULTI_VIRTUAL_CLICK + +```tsx +addGuidance({ + type: 'MULTI_VIRTUAL_CLICK', + guidances: [ + { endPosition: '#nav-profile', tooltipText: 'Profile' }, + { endPosition: '#nav-billing', tooltipText: 'Billing' }, + { endPosition: '#nav-notifications', tooltipText: 'Notifications' }, + ], + loop: false, + delay: 500, + advanceMode: 'default', +}); +``` + +### VIRTUAL_TYPING + +```tsx +addGuidance({ + type: 'VIRTUAL_TYPING', + endPosition: '#email', + expectedValue: (val) => /@/.test(val), + checkExistingValue: true, + typingDelay: 120, + tooltipText: 'Enter your email', + tooltipPosition: 'bottom', + tooltipAnchor: 'rect', + advanceMode: 'default', + blocking: true, +}); +``` + +### CHAT + +```tsx +addGuidance({ + type: 'CHAT', + content: { role: 'assistant', type: 'text', content: 'Welcome to Cedar!' }, + messageDelay: 200, + autoAdvance: true, + customMessages: [ + { + content: { role: 'assistant', type: 'text', content: 'Step 1 complete.' }, + messageDelay: 500, + }, + ], +}); +``` + +### CHAT_TOOLTIP + +```tsx +addGuidance({ + type: 'CHAT_TOOLTIP', + content: 'Open chat any time', + position: 'top', + duration: 3000, +}); +``` + +### IDLE + +```tsx +addGuidance({ + type: 'IDLE', + duration: 1000, // or use advanceFunction(next => …) +}); +``` + +### DIALOGUE + +```tsx +addGuidance({ + type: 'DIALOGUE', + text: 'Review changes before publishing', + advanceMode: 'auto', + blocking: true, + highlightElements: ['#changes-panel', '#publish-button'], + shouldScroll: true, +}); +``` + +### DIALOGUE_BANNER + +```tsx +addGuidance({ + type: 'DIALOGUE_BANNER', + text: 'New: Real-time collaboration', + advanceMode: 'external', +}); +``` + +### SURVEY + +```tsx +addGuidance({ + type: 'SURVEY', + title: 'Quick feedback', + description: 'Help us improve', + questions: [ + { id: 'q1', type: 'nps', question: 'Likelihood to recommend?' }, + { id: 'q2', type: 'shortText', question: 'What can we improve?' }, + ], + blocking: false, + submitButtonText: 'Send', +}); +``` + +### EXECUTE_CLICK + +```tsx +addGuidance({ + type: 'EXECUTE_CLICK', + target: '#submit', + tooltipText: 'Submitting…', + showCursor: true, // false executes immediately without animation + shouldScroll: true, +}); +``` + +### EXECUTE_TYPING + +```tsx +addGuidance({ + type: 'EXECUTE_TYPING', + endPosition: '#search', + expectedValue: 'hello world', +}); +``` + +### TOAST + +```tsx +addGuidance({ + type: 'TOAST', + title: 'Saved', + description: 'Your settings have been saved.', + variant: 'success', + position: 'bottom-right', + duration: 3000, +}); +``` + +### IF + +```tsx +addGuidance({ + type: 'IF', + condition: () => Boolean(document.querySelector('#pro-feature')), + trueGuidance: { type: 'DIALOGUE', text: 'Pro feature available' }, + falseGuidance: { type: 'DIALOGUE', text: 'Upgrade to Pro to continue' }, + advanceMode: 'default', // or a predicate/callback +}); +``` + +### GATE_IF + +```tsx +addGuidance({ + type: 'GATE_IF', + condition: async () => { + const ok = await Promise.resolve(true); + return ok; + }, + trueGuidances: [ + { + type: 'CHAT', + content: { + role: 'assistant', + type: 'text', + content: 'Great! Let’s continue.', + }, + }, + ], + falseGuidances: [ + { + type: 'CHAT', + content: { role: 'assistant', type: 'text', content: 'We’ll wait here.' }, + }, + ], +}); +``` + +### NAVIGATE + +```tsx +addGuidance({ + type: 'NAVIGATE', + url: 'https://docs.yourapp.com/getting-started', +}); +``` + +### RIGHT_CLICK + +```tsx +addGuidance({ + type: 'RIGHT_CLICK', + duration: 2000, +}); +``` + +That’s it: enqueue via `useGuidance`; the renderer handles visuals, endRect math, offscreen logic, and tooltips automatically. diff --git a/docs/guidance/endrect-utils.mdx b/docs/guidance/endrect-utils.mdx new file mode 100644 index 00000000..25486fd1 --- /dev/null +++ b/docs/guidance/endrect-utils.mdx @@ -0,0 +1,66 @@ +--- +title: 'endRect Utilities' +description: 'How targets resolve to endRect and the helpers involved.' +--- + +The renderer derives `endRect` (a DOMRect) from your guidance targets. These helpers power that resolution and viewport-aware behavior. + +## Position and rect helpers + +```ts +import { + getPositionFromElementWithViewport, + getRectFromPositionOrElement, + type Position, + type PositionOrElement, +} from 'cedar-os'; + +// From an element +const el = document.querySelector('#save') as HTMLElement; +const pos = getPositionFromElementWithViewport(el, 10, true); // scroll if needed +const rect = getRectFromPositionOrElement(el, 10, true); + +// From coordinates +const p: Position = { x: 200, y: 320 }; +// getRectFromPositionOrElement returns null for pure coordinates + +// Lazy resolver +const lazy: PositionOrElement = { + _lazy: true, + resolve: () => document.querySelector('#avatar')!, +}; +const rect2 = getRectFromPositionOrElement(lazy, 10, true); +``` + +## Viewport helpers + +```ts +import { + isRectInViewport, + isRectPartiallyInViewport, + getVisibleRectPercentage, + isPositionInViewport, + getOffscreenDirection, +} from 'cedar-os'; + +const inView = isRectInViewport(rect!, 8); +const partially = isRectPartiallyInViewport(rect!); +const visiblePct = getVisibleRectPercentage(rect!); // 0..1 +const pointOK = isPositionInViewport({ x: 50, y: 80 }, 4); +const offscreen = getOffscreenDirection({ x: -10, y: 200 }); // { left: true, ... } | null +``` + +## Offscreen edge pointer math + +```ts +// Internal helper used by the renderer when a target is offscreen +// to position an arrow at the nearest viewport edge. +import /* not exported publicly */ 'cedar-os'; +// Conceptual API: +// calculateEdgePointerPosition(targetRect) => { position: {x,y}, angle, edge: 'top'|'right'|'bottom'|'left' } +``` + +Notes: + +- Prefer passing elements or selectors; rect math is most precise on elements. +- Set `shouldScroll: true` on guidance items that target potentially offscreen elements. diff --git a/package-lock.json b/package-lock.json index b29eaa43..df7faef1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,8 +32,9 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "diff": "^8.0.2", + "framer-motion": "12.17.0", "lucide-react": "^0.525.0", - "motion": "^12.23.9", + "motion-plus-react": "1.3.0", "next": "^15.3.0", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -7614,38 +7615,6 @@ "tailwindcss": "4.1.11" } }, - "node_modules/@testing-library/dom": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", - "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.3.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "picocolors": "1.1.1", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@testing-library/dom/node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "dequal": "^2.0.3" - } - }, "node_modules/@testing-library/jest-dom": { "version": "6.6.4", "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.6.4.tgz", @@ -8072,14 +8041,6 @@ "@tiptap/pm": "^2.7.0" } }, - "node_modules/@types/aria-query": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", - "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/@types/aws-lambda": { "version": "8.10.147", "license": "MIT" @@ -10647,14 +10608,6 @@ "node": ">=0.10.0" } }, - "node_modules/dom-accessibility-api": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/dotenv": { "version": "16.6.1", "license": "BSD-2-Clause", @@ -11781,7 +11734,8 @@ }, "node_modules/framer-motion": { "version": "12.17.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.17.0.tgz", + "integrity": "sha512-2hISKgDk49yCLStwG1wf4Kdy/D6eBw9/eRNaWFIYoI9vMQ/Mqd1Fz+gzVlEtxJmtQ9y4IWnXm19/+UXD3dAYAA==", "dependencies": { "motion-dom": "^12.17.0", "motion-utils": "^12.12.1", @@ -14606,17 +14560,6 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/lz-string": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "dev": true, - "license": "MIT", - "peer": true, - "bin": { - "lz-string": "bin/bin.js" - } - }, "node_modules/magic-string": { "version": "0.30.17", "dev": true, @@ -15600,9 +15543,9 @@ } }, "node_modules/motion-dom": { - "version": "12.23.9", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.9.tgz", - "integrity": "sha512-6Sv++iWS8XMFCgU1qwKj9l4xuC47Hp4+2jvPfyTXkqDg2tTzSgX6nWKD4kNFXk0k7llO59LZTPuJigza4A2K1A==", + "version": "12.23.12", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.12.tgz", + "integrity": "sha512-RcR4fvMCTESQBD/uKQe49D5RUeDOokkGRmz4ceaJKDBgHYtZtntC/s2vLvY38gqGaytinij/yi3hMcWVcEF5Kw==", "dependencies": { "motion-utils": "^12.23.6" } @@ -15617,13 +15560,14 @@ } }, "node_modules/motion-plus-react": { - "version": "1.4.1", - "license": "MIT", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/motion-plus-react/-/motion-plus-react-1.3.0.tgz", + "integrity": "sha512-lJcU0fwxvLCgZBP78NFDMzoZj9GOoa+jqiSxzT6xyAlrpvH7KlLLMvVetgjwuTbHeKCo1hLbkC9F1XfOWzRS/A==", "dependencies": { - "motion": "^12.19.0", - "motion-dom": "^12.19.0", - "motion-plus-dom": "^1.3.1", - "motion-utils": "^12.19.0" + "motion": "^12.16.0", + "motion-dom": "^12.16.0", + "motion-plus-dom": "^1.0.0", + "motion-utils": "^12.12.1" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", @@ -15644,11 +15588,11 @@ "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==" }, "node_modules/motion/node_modules/framer-motion": { - "version": "12.23.9", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.9.tgz", - "integrity": "sha512-TqEHXj8LWfQSKqfdr5Y4mYltYLw96deu6/K9kGDd+ysqRJPNwF9nb5mZcrLmybHbU7gcJ+HQar41U3UTGanbbQ==", + "version": "12.23.12", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.12.tgz", + "integrity": "sha512-6e78rdVtnBvlEVgu6eFEAgG9v3wLnYEboM8I5O5EXvfKC8gxGQB8wXJdhkMy10iVcn05jl6CNw7/HTsTCfwcWg==", "dependencies": { - "motion-dom": "^12.23.9", + "motion-dom": "^12.23.12", "motion-utils": "^12.23.6", "tslib": "^2.4.0" }, @@ -16081,11 +16025,6 @@ "tar": "^7.0.1" } }, - "node_modules/openapi-types": { - "version": "12.1.3", - "license": "MIT", - "peer": true - }, "node_modules/optionator": { "version": "0.9.4", "dev": true, @@ -16768,44 +16707,6 @@ "node": ">= 0.8.0" } }, - "node_modules/pretty-format": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", - "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^17.0.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/pretty-format/node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/process": { "version": "0.11.10", "license": "MIT", @@ -18808,6 +18709,95 @@ "version": "2.8.1", "license": "0BSD" }, + "node_modules/tsup": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.0.tgz", + "integrity": "sha512-VmBp77lWNQq6PfuMqCHD3xWl22vEoWsKajkF8t+yMBawlUS8JzEI+vOVMeuNZIuMML8qXRizFKi9oD5glKQVcQ==", + "dev": true, + "dependencies": { + "bundle-require": "^5.1.0", + "cac": "^6.7.14", + "chokidar": "^4.0.3", + "consola": "^3.4.0", + "debug": "^4.4.0", + "esbuild": "^0.25.0", + "fix-dts-default-cjs-exports": "^1.0.0", + "joycon": "^3.1.1", + "picocolors": "^1.1.1", + "postcss-load-config": "^6.0.1", + "resolve-from": "^5.0.0", + "rollup": "^4.34.8", + "source-map": "0.8.0-beta.0", + "sucrase": "^3.35.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.11", + "tree-kill": "^1.2.2" + }, + "bin": { + "tsup": "dist/cli-default.js", + "tsup-node": "dist/cli-node.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@microsoft/api-extractor": "^7.36.0", + "@swc/core": "^1", + "postcss": "^8.4.12", + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "postcss": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/tsup/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/tsup/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/tsup/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/tw-animate-css": { "version": "1.3.5", "dev": true, @@ -19706,7 +19696,7 @@ } }, "packages/cedar-os": { - "version": "0.0.22", + "version": "0.0.28", "license": "ISC", "dependencies": { "@ai-sdk/xai": "^1.2.18", @@ -19731,7 +19721,7 @@ "framer-motion": "12.17.0", "gsap": "^3.12.7", "lucide-react": "^0.363.0", - "motion-plus-react": "^1.2.0", + "motion-plus-react": "1.3.0", "prosemirror-model": "1.25.2", "react-markdown": "^10.1.0", "uuid": "^11.1.0", @@ -19817,30 +19807,6 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0" } }, - "packages/cedar-os/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, - "packages/cedar-os/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "packages/cedar-os/node_modules/tailwindcss": { "version": "3.4.17", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", @@ -19915,75 +19881,6 @@ } } }, - "packages/cedar-os/node_modules/tsup": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.0.tgz", - "integrity": "sha512-VmBp77lWNQq6PfuMqCHD3xWl22vEoWsKajkF8t+yMBawlUS8JzEI+vOVMeuNZIuMML8qXRizFKi9oD5glKQVcQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "bundle-require": "^5.1.0", - "cac": "^6.7.14", - "chokidar": "^4.0.3", - "consola": "^3.4.0", - "debug": "^4.4.0", - "esbuild": "^0.25.0", - "fix-dts-default-cjs-exports": "^1.0.0", - "joycon": "^3.1.1", - "picocolors": "^1.1.1", - "postcss-load-config": "^6.0.1", - "resolve-from": "^5.0.0", - "rollup": "^4.34.8", - "source-map": "0.8.0-beta.0", - "sucrase": "^3.35.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.11", - "tree-kill": "^1.2.2" - }, - "bin": { - "tsup": "dist/cli-default.js", - "tsup-node": "dist/cli-node.js" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@microsoft/api-extractor": "^7.36.0", - "@swc/core": "^1", - "postcss": "^8.4.12", - "typescript": ">=4.5.0" - }, - "peerDependenciesMeta": { - "@microsoft/api-extractor": { - "optional": true - }, - "@swc/core": { - "optional": true - }, - "postcss": { - "optional": true - }, - "typescript": { - "optional": true - } - } - }, - "packages/cedar-os/node_modules/tsup/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "packages/cli": { "name": "cedar-os-cli", "version": "0.1.14", @@ -20027,22 +19924,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "packages/cli/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "packages/cli/node_modules/commander": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", @@ -20051,83 +19932,6 @@ "engines": { "node": ">=16" } - }, - "packages/cli/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, - "packages/cli/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "packages/cli/node_modules/tsup": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.0.tgz", - "integrity": "sha512-VmBp77lWNQq6PfuMqCHD3xWl22vEoWsKajkF8t+yMBawlUS8JzEI+vOVMeuNZIuMML8qXRizFKi9oD5glKQVcQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "bundle-require": "^5.1.0", - "cac": "^6.7.14", - "chokidar": "^4.0.3", - "consola": "^3.4.0", - "debug": "^4.4.0", - "esbuild": "^0.25.0", - "fix-dts-default-cjs-exports": "^1.0.0", - "joycon": "^3.1.1", - "picocolors": "^1.1.1", - "postcss-load-config": "^6.0.1", - "resolve-from": "^5.0.0", - "rollup": "^4.34.8", - "source-map": "0.8.0-beta.0", - "sucrase": "^3.35.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.11", - "tree-kill": "^1.2.2" - }, - "bin": { - "tsup": "dist/cli-default.js", - "tsup-node": "dist/cli-node.js" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@microsoft/api-extractor": "^7.36.0", - "@swc/core": "^1", - "postcss": "^8.4.12", - "typescript": ">=4.5.0" - }, - "peerDependenciesMeta": { - "@microsoft/api-extractor": { - "optional": true - }, - "@swc/core": { - "optional": true - }, - "postcss": { - "optional": true - }, - "typescript": { - "optional": true - } - } } } } diff --git a/package.json b/package.json index f5f94c41..4550dbe4 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,8 @@ "clsx": "^2.1.1", "diff": "^8.0.2", "lucide-react": "^0.525.0", - "motion": "^12.23.9", + "framer-motion": "12.17.0", + "motion-plus-react": "1.3.0", "next": "^15.3.0", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/packages/cedar-os-components/spells/QuestioningSpell.tsx b/packages/cedar-os-components/spells/QuestioningSpell.tsx index 2077d630..a8b7ec8e 100644 --- a/packages/cedar-os-components/spells/QuestioningSpell.tsx +++ b/packages/cedar-os-components/spells/QuestioningSpell.tsx @@ -11,7 +11,7 @@ import { type ActivationConditions, } from 'cedar-os'; // TODO: TooltipText component needs to be created -// import TooltipText from '@/components/interactions/components/TooltipText'; +// import TooltipText from '@/components/guidance/components/TooltipText'; interface QuestioningSpellProps { /** Unique identifier for this spell instance */ diff --git a/packages/cedar-os/package.json b/packages/cedar-os/package.json index a788eb90..3067284c 100644 --- a/packages/cedar-os/package.json +++ b/packages/cedar-os/package.json @@ -57,7 +57,7 @@ "framer-motion": "12.17.0", "gsap": "^3.12.7", "lucide-react": "^0.363.0", - "motion-plus-react": "^1.2.0", + "motion-plus-react": "1.3.0", "prosemirror-model": "1.25.2", "react-markdown": "^10.1.0", "uuid": "^11.1.0", diff --git a/packages/cedar-os/pnpm-lock.yaml b/packages/cedar-os/pnpm-lock.yaml index 471a5a3e..9b5792d6 100644 --- a/packages/cedar-os/pnpm-lock.yaml +++ b/packages/cedar-os/pnpm-lock.yaml @@ -81,8 +81,8 @@ importers: specifier: ^0.363.0 version: 0.363.0(react@18.3.1) motion-plus-react: - specifier: ^1.2.0 - version: 1.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: 1.3.0 + version: 1.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) prosemirror-model: specifier: 1.25.2 version: 1.25.2 @@ -2564,6 +2564,20 @@ packages: react-dom: optional: true + framer-motion@12.23.12: + resolution: {integrity: sha512-6e78rdVtnBvlEVgu6eFEAgG9v3wLnYEboM8I5O5EXvfKC8gxGQB8wXJdhkMy10iVcn05jl6CNw7/HTsTCfwcWg==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + fs-minipass@2.1.0: resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} engines: {node: '>= 8'} @@ -3031,14 +3045,14 @@ packages: module-details-from-path@1.0.4: resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} - motion-dom@12.18.1: - resolution: {integrity: sha512-dR/4EYT23Snd+eUSLrde63Ws3oXQtJNw/krgautvTfwrN/2cHfCZMdu6CeTxVfRRWREW3Fy1f5vobRDiBb/q+w==} + motion-dom@12.23.12: + resolution: {integrity: sha512-RcR4fvMCTESQBD/uKQe49D5RUeDOokkGRmz4ceaJKDBgHYtZtntC/s2vLvY38gqGaytinij/yi3hMcWVcEF5Kw==} - motion-plus-dom@1.0.0: - resolution: {integrity: sha512-2P0qzvM0s9Xjt1+ZvC5ZtSPxQvC8tlMt7yIO46iQ/ZnPEB4PwwOWfLLVqS1vIlV/3DUGrYimdpZ28TsxykXTEQ==} + motion-plus-dom@1.5.1: + resolution: {integrity: sha512-dq4dRRfJe9Rb9uUjd1pJQ2g40U/WnRbevb6+d4gLHPJAJdAot49XubPHrH/hHlRYc5jsc6r3MGM5UyqeLHPYkw==} - motion-plus-react@1.2.0: - resolution: {integrity: sha512-0GSJtCmANZkXGNeKNHUFrT3diaenLDDKeDxazexGnOVu4lgziJMYYCm/BXFt58q+X8mbCx8mf8fs3bxgAJT/UQ==} + motion-plus-react@1.3.0: + resolution: {integrity: sha512-lJcU0fwxvLCgZBP78NFDMzoZj9GOoa+jqiSxzT6xyAlrpvH7KlLLMvVetgjwuTbHeKCo1hLbkC9F1XfOWzRS/A==} peerDependencies: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 @@ -3048,11 +3062,11 @@ packages: react-dom: optional: true - motion-utils@12.18.1: - resolution: {integrity: sha512-az26YDU4WoDP0ueAkUtABLk2BIxe28d8NH1qWT8jPGhPyf44XTdDUh8pDk9OPphaSrR9McgpcJlgwSOIw/sfkA==} + motion-utils@12.23.6: + resolution: {integrity: sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==} - motion@12.17.0: - resolution: {integrity: sha512-04K87lcwuA1vK3CLpKegncbBq6y2dmQjUsp9iPz0SxFACdBtEHB3V120k0G/t1blHATFuoqMrrLELRljUdQCeg==} + motion@12.23.12: + resolution: {integrity: sha512-8jCD8uW5GD1csOoqh1WhH1A6j5APHVE15nuBkFeRiMzYBdRwyAHmSP/oXSuW0WJPZRXTFdBoG4hY9TFWNhhwng==} peerDependencies: '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 @@ -6918,8 +6932,17 @@ snapshots: framer-motion@12.17.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - motion-dom: 12.18.1 - motion-utils: 12.18.1 + motion-dom: 12.23.12 + motion-utils: 12.23.6 + tslib: 2.8.1 + optionalDependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + framer-motion@12.23.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + motion-dom: 12.23.12 + motion-utils: 12.23.6 tslib: 2.8.1 optionalDependencies: react: 18.3.1 @@ -7493,37 +7516,37 @@ snapshots: module-details-from-path@1.0.4: {} - motion-dom@12.18.1: + motion-dom@12.23.12: dependencies: - motion-utils: 12.18.1 + motion-utils: 12.23.6 - motion-plus-dom@1.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + motion-plus-dom@1.5.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - motion: 12.17.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - motion-dom: 12.18.1 - motion-utils: 12.18.1 + motion: 12.23.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + motion-dom: 12.23.12 + motion-utils: 12.23.6 transitivePeerDependencies: - '@emotion/is-prop-valid' - react - react-dom - motion-plus-react@1.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + motion-plus-react@1.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - motion: 12.17.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - motion-dom: 12.18.1 - motion-plus-dom: 1.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - motion-utils: 12.18.1 + motion: 12.23.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + motion-dom: 12.23.12 + motion-plus-dom: 1.5.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + motion-utils: 12.23.6 optionalDependencies: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) transitivePeerDependencies: - '@emotion/is-prop-valid' - motion-utils@12.18.1: {} + motion-utils@12.23.6: {} - motion@12.17.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + motion@12.23.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - framer-motion: 12.17.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + framer-motion: 12.23.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1) tslib: 2.8.1 optionalDependencies: react: 18.3.1 diff --git a/packages/cedar-os/src/components/CedarCopilot.client.tsx b/packages/cedar-os/src/components/CedarCopilot.client.tsx index afb507ec..0fbec8a0 100644 --- a/packages/cedar-os/src/components/CedarCopilot.client.tsx +++ b/packages/cedar-os/src/components/CedarCopilot.client.tsx @@ -1,6 +1,5 @@ 'use client'; -import React, { useEffect } from 'react'; import { useCedarStore } from '@/store/CedarStore'; import type { ProviderConfig, @@ -8,8 +7,9 @@ import type { } from '@/store/agentConnection/AgentConnectionTypes'; import type { MessageRenderer } from '@/store/messages/MessageTypes'; import { MessageStorageConfig } from '@/store/messages/messageStorage'; -import type { VoiceState } from '@/store/voice/voiceSlice'; import { useCedarState } from '@/store/stateSlice/useCedarState'; +import type { VoiceState } from '@/store/voice/voiceSlice'; +import React, { useEffect } from 'react'; export interface CedarCopilotProps { children: React.ReactNode; diff --git a/packages/cedar-os/src/components/guidance/GuidanceRenderer.tsx b/packages/cedar-os/src/components/guidance/GuidanceRenderer.tsx new file mode 100644 index 00000000..efbf0a2a --- /dev/null +++ b/packages/cedar-os/src/components/guidance/GuidanceRenderer.tsx @@ -0,0 +1,878 @@ +'use client'; + +import { getPositionFromElement } from '@/components/guidance'; +import { CedarCursor } from '@/components/guidance/components/CedarCursor'; +import DialogueBanner from '@/components/guidance/components/DialogueBanner'; +import DialogueBox from '@/components/guidance/components/DialogueBox'; +import ExecuteTyping from '@/components/guidance/components/ExecuteTyping'; +import HighlightOverlay from '@/components/guidance/components/HighlightOverlay'; +import RightClickIndicator from '@/components/guidance/components/RightClickIndicator'; + +import TooltipText from '@/components/guidance/components/TooltipText'; +import VirtualCursor from '@/components/guidance/components/VirtualCursor'; +import VirtualTypingCursor from '@/components/guidance/components/VirtualTypingCursor'; +import { PositionOrElement } from '@/components/guidance/utils/positionUtils'; +import ToastCard from '@/components/guidance/components/ToastCard'; +import { + ChatGuidance, + DialogueBannerGuidance, + GateIfGuidance, + VirtualClickGuidance, + VirtualDragGuidance, + VirtualTypingGuidance, +} from '@/store/guidance/guidanceSlice'; +import { useGuidance, useMessages } from '@/store/CedarStore'; +import { motion } from 'motion/react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import IFGuidanceRenderer from '@/components/guidance/components/IFGuidanceRenderer'; + +// Simplified GuidanceRenderer that delegates IF rendering to IFGuidanceRenderer +const GuidanceRenderer: React.FC = () => { + const { + currentGuidance, + nextGuidance, + isActive, + prevCursorPosition, + isAnimatingOut, + addGuidancesToStart, + } = useGuidance(); + + // Message helpers + const { addMessage } = useMessages(); + + const [guidanceKey, setGuidanceKey] = useState(''); + const [currentClickIndex, setCurrentClickIndex] = useState(0); + const [dragIterationCount, setDragIterationCount] = useState(0); + const [isDragAnimatingOut, setIsDragAnimatingOut] = useState(false); + const throttleRef = useRef(false); + const executeClickTargetRef = useRef(null); + const functionAdvanceModeIntervalRef = useRef(null); + + // Call next guidance when animation completes + const handleGuidanceEnd = useCallback(() => { + nextGuidance(currentGuidance?.id); + }, [currentGuidance, nextGuidance]); + + // Initialize to user's cursor position and set up tracking + useEffect(() => { + // Function to update cursor position + const updateCursorPosition = (e: MouseEvent) => { + // Skip update if we're throttling + if (throttleRef.current) return; + + // Set throttle flag + throttleRef.current = true; + + // Create position object + const position = { + x: e.clientX, + y: e.clientY, + }; + + // Store position in DOM for direct access by components + document.body.setAttribute( + 'data-cursor-position', + JSON.stringify(position) + ); + + // Clear throttle after delay + const throttleTimeout = setTimeout(() => { + throttleRef.current = false; + }, 16); // ~60fps (1000ms/60) + + return () => clearTimeout(throttleTimeout); + }; + + // Add event listener for mouse movement + document.addEventListener('mousemove', updateCursorPosition); + // Clean up event listener on component unmount + return () => { + document.removeEventListener('mousemove', updateCursorPosition); + }; + }, []); + + // When the guidance changes, update the key to force a complete re-render + useEffect(() => { + // Handle CHAT guidances: dispatch MessageInput(s) via addMessage + if (currentGuidance?.type === 'CHAT') { + const chatGuidance = currentGuidance as ChatGuidance; + { + const runChat = async () => { + // Primary message + const delay = chatGuidance.messageDelay ?? 0; + if (delay > 0) { + await new Promise((res) => setTimeout(res, delay)); + } + addMessage(chatGuidance.content); + if (chatGuidance.autoAdvance !== false) { + handleGuidanceEnd(); + } + + // Custom messages + if (chatGuidance.customMessages) { + for (const msg of chatGuidance.customMessages) { + const msgDelay = msg.messageDelay ?? 0; + if (msgDelay > 0) { + await new Promise((res) => setTimeout(res, msgDelay)); + } + addMessage(msg.content); + if (msg.autoAdvance !== false) { + handleGuidanceEnd(); + } + } + } + }; + runChat(); + } + } + + if (currentGuidance?.id) { + setGuidanceKey(currentGuidance.id); + setCurrentClickIndex(0); + setDragIterationCount(0); + setIsDragAnimatingOut(false); + + // Handle GATE_IF guidances by evaluating the condition once and adding appropriate guidances + if (currentGuidance.type === 'GATE_IF') { + const gateIfGuidance = currentGuidance as GateIfGuidance; + + // Get the condition result + const evaluateCondition = async () => { + try { + // Get initial result + const result = + typeof gateIfGuidance.condition === 'function' + ? gateIfGuidance.condition() + : gateIfGuidance.condition; + + let finalResult: boolean; + + if (result instanceof Promise) { + finalResult = await result; + } else { + finalResult = !!result; + } + + // Add the appropriate guidances to the queue based on the result + if (finalResult) { + addGuidancesToStart(gateIfGuidance.trueGuidances); + } else { + addGuidancesToStart(gateIfGuidance.falseGuidances); + } + } catch (error) { + console.error('Error evaluating GATE_IF condition:', error); + // In case of error, add the falseGuidances as fallback + addGuidancesToStart(gateIfGuidance.falseGuidances); + } + }; + + // Start the evaluation process + evaluateCondition(); + + return; + } + + // Store target for EXECUTE_CLICK guidances + if (currentGuidance.type === 'EXECUTE_CLICK') { + executeClickTargetRef.current = currentGuidance.target; + } else { + executeClickTargetRef.current = null; + } + + // Clean up any existing interval + if (functionAdvanceModeIntervalRef.current) { + clearInterval(functionAdvanceModeIntervalRef.current); + functionAdvanceModeIntervalRef.current = null; + } + + // Set up interval for function-based advanceMode + // Use proper type guards to ensure we're dealing with the right guidance types + if ( + currentGuidance.type === 'VIRTUAL_CLICK' || + currentGuidance.type === 'VIRTUAL_DRAG' || + currentGuidance.type === 'VIRTUAL_TYPING' + ) { + // Now we know it's a VirtualClickGuidance, VirtualDragGuidance, or VirtualTypingGuidance and has advanceMode + const clickOrDragGuidance = currentGuidance as + | VirtualClickGuidance + | VirtualDragGuidance + | VirtualTypingGuidance; + + if (typeof clickOrDragGuidance.advanceMode === 'function') { + const advanceFn = clickOrDragGuidance.advanceMode; + + // If the function expects at least one argument, we treat it as + // the **callback** variant – invoke once and let it call + // `nextGuidance` (via handleGuidanceEnd) when ready. + if (advanceFn.length >= 1) { + (advanceFn as (next: () => void) => void)(() => { + // Ensure we don't create a new reference every call + handleGuidanceEnd(); + }); + // No polling interval in this mode + return; + } + + // Otherwise treat it as the **predicate** variant that returns + // a boolean and should be polled until true. + + if ((advanceFn as () => boolean)()) { + // If the predicate returns true immediately, advance on next tick + setTimeout(() => handleGuidanceEnd(), 0); + return; + } + + // Set up interval to periodically check the predicate (every 500 ms) + functionAdvanceModeIntervalRef.current = setInterval(() => { + const shouldAdvance = (advanceFn as () => boolean)(); + if (shouldAdvance) { + handleGuidanceEnd(); + + if (functionAdvanceModeIntervalRef.current) { + clearInterval(functionAdvanceModeIntervalRef.current); + functionAdvanceModeIntervalRef.current = null; + } + } + }, 500); + } + } + } + + // Clean up interval on unmount or when currentGuidance changes + return () => { + if (functionAdvanceModeIntervalRef.current) { + clearInterval(functionAdvanceModeIntervalRef.current); + functionAdvanceModeIntervalRef.current = null; + } + }; + }, [ + currentGuidance?.id, + currentGuidance?.type, + handleGuidanceEnd, + addGuidancesToStart, + addMessage, + nextGuidance, + ]); + + // Function to execute the actual click - now outside of conditional blocks + const executeClick = useCallback(() => { + // Exit if no current guidance or not an execute click guidance + if (!currentGuidance || currentGuidance.type !== 'EXECUTE_CLICK') { + return; + } + + try { + // Get the target element - properly handling lazy elements + let targetElement: PositionOrElement = currentGuidance.target; + + // Check if this is a lazy element and resolve it if needed + if ( + targetElement && + typeof targetElement === 'object' && + '_lazy' in targetElement && + targetElement._lazy + ) { + targetElement = targetElement.resolve() as PositionOrElement; + } + + // Check if we have a ref + if ( + targetElement && + typeof targetElement === 'object' && + 'current' in targetElement + ) { + targetElement = targetElement.current as PositionOrElement; + } + + // Handle string selectors + if (typeof targetElement === 'string') { + const element = document.querySelector(targetElement); + if (element instanceof HTMLElement) { + targetElement = element; + } + } + + // Check if we have a DOM element + if (targetElement instanceof Element) { + // First, ensure the element is in view + if (currentGuidance.shouldScroll !== false) { + targetElement.scrollIntoView({ + behavior: 'smooth', + block: 'center', + }); + } + + // Trigger click after a short delay to allow for scrolling + setTimeout(() => { + // Create and dispatch a click event + const clickEvent = new MouseEvent('click', { + bubbles: true, + cancelable: true, + view: window, + }); + targetElement.dispatchEvent(clickEvent); + + // Move to the next guidance + handleGuidanceEnd(); + }, 300); + } else { + // Handle case where we have coordinates instead of an element + const position = getPositionFromElement(targetElement); + if (position) { + // Find the element at the position coordinates + const elementAtPosition = document.elementFromPoint( + position.x, + position.y + ) as HTMLElement | null; + + if (elementAtPosition) { + // First, ensure the element is in view if needed + if (currentGuidance.shouldScroll !== false) { + elementAtPosition.scrollIntoView({ + behavior: 'smooth', + block: 'center', + }); + } + + // Short delay to allow for scrolling + setTimeout(() => { + // Create and dispatch a click event + const clickEvent = new MouseEvent('click', { + bubbles: true, + cancelable: true, + view: window, + }); + elementAtPosition.dispatchEvent(clickEvent); + + // Move to the next guidance + handleGuidanceEnd(); + }, 300); + } else { + console.error('No element found at the specified position'); + handleGuidanceEnd(); // Proceed to next guidance anyway + } + } else { + console.error('Unable to execute click: Invalid target'); + handleGuidanceEnd(); // Proceed to next guidance anyway + } + } + } catch (error) { + console.error('Error executing click:', error); + handleGuidanceEnd(); // Proceed to next guidance anyway + } + }, [currentGuidance, handleGuidanceEnd]); + + // Modified effect to handle IDLE guidances with automatic duration + useEffect(() => { + if (!currentGuidance) return; + + if (currentGuidance.type === 'IDLE') { + if (currentGuidance.duration) { + const timeout = setTimeout(() => { + nextGuidance(currentGuidance.id); + }, currentGuidance.duration); + + return () => clearTimeout(timeout); + } + if (currentGuidance.advanceFunction) { + currentGuidance.advanceFunction(() => { + nextGuidance(currentGuidance.id); + }); + } + } + + // Handle auto-completing CHAT_TOOLTIP guidances with duration + if (currentGuidance.type === 'CHAT_TOOLTIP' && currentGuidance.duration) { + const timeout = setTimeout(() => { + nextGuidance(currentGuidance.id); + }, currentGuidance.duration); + + return () => clearTimeout(timeout); + } + + // Handle EXECUTE_CLICK guidances without animation - directly execute the click + if ( + currentGuidance.type === 'EXECUTE_CLICK' && + currentGuidance.showCursor === false + ) { + // Execute click directly rather than setting a state + executeClick(); + } + }, [currentGuidance, nextGuidance, executeClick, handleGuidanceEnd]); + + // Handler for cursor animation completion + const handleCursorAnimationComplete = useCallback( + (clicked: boolean) => { + // Use type guards for different guidance types + if (currentGuidance?.type === 'VIRTUAL_CLICK') { + const clickGuidance = currentGuidance as VirtualClickGuidance; + if ( + clickGuidance.advanceMode !== 'external' && + typeof clickGuidance.advanceMode !== 'function' + ) { + return handleGuidanceEnd(); + } + } + + // For VIRTUAL_DRAG with external advance mode, loop the animation + if (currentGuidance?.type === 'VIRTUAL_DRAG') { + // CARE -> it should default to clickable + if (clicked && currentGuidance.advanceMode !== 'external') { + return handleGuidanceEnd(); + } + + // Start fade-out animation + setIsDragAnimatingOut(true); + + // After fade-out completes, increment iteration and restart animation + setTimeout(() => { + setDragIterationCount((prev) => prev + 1); + setIsDragAnimatingOut(false); + }, 300); // Duration of fadeout animation + } + }, + [handleGuidanceEnd, currentGuidance] + ); + + // Handler for MULTI_VIRTUAL_CLICK completion + const handleMultiClickComplete = useCallback(() => { + if (currentGuidance?.type === 'MULTI_VIRTUAL_CLICK') { + // If there are more clicks to go through + if (currentClickIndex < currentGuidance.guidances.length - 1) { + // Move to the next click + setCurrentClickIndex((prevIndex) => prevIndex + 1); + } else if (currentGuidance.loop) { + // If looping is enabled, start from the beginning + setCurrentClickIndex(0); + } else if (currentGuidance.advanceMode !== 'external') { + // Complete the entire guidance only if advanceMode is not 'external' + handleGuidanceEnd(); + } + } + }, [currentGuidance, currentClickIndex, handleGuidanceEnd]); + + // Handle delay between clicks for MULTI_VIRTUAL_CLICK + useEffect(() => { + if ( + currentGuidance?.type === 'MULTI_VIRTUAL_CLICK' && + currentClickIndex > 0 + ) { + const defaultDelay = 500; // Default delay between clicks in ms + const delay = + currentGuidance.delay !== undefined + ? currentGuidance.delay + : defaultDelay; + + // Apply delay before showing the next click + const timer = setTimeout(() => { + // This empty timeout just creates a delay + }, delay); + + return () => clearTimeout(timer); + } + }, [currentGuidance, currentClickIndex]); + + // If there's no current guidance or the animation system is inactive, don't render anything + if (!isActive || !currentGuidance) { + return null; + } + + // Render the appropriate component based on guidance type + switch (currentGuidance.type) { + case 'IF': + return ( + + ); + case 'GATE_IF': + // GATE_IF guidances are handled in the useEffect and don't need special rendering + return null; + case 'CURSOR_TAKEOVER': + return ( + + ); + + case 'VIRTUAL_CLICK': { + // Determine the start position - use guidance.startPosition if provided, + // otherwise use the previous cursor position, or undefined if neither exists + const resolvedStartPosition: PositionOrElement | undefined = + currentGuidance.startPosition || + (prevCursorPosition ? prevCursorPosition : undefined); + + // Determine the advanceMode from the guidance – if the provided value + // is a **callback** variant (expects an argument), fall back to + // 'default' as VirtualCursor itself doesn't need to know about it. + const rawAdvanceMode = currentGuidance.advanceMode; + type CursorAdvanceMode = + | 'auto' + | 'external' + | 'default' + | number + | (() => boolean); + + const advanceMode: CursorAdvanceMode = + typeof rawAdvanceMode === 'function' && + ((rawAdvanceMode as (...args: unknown[]) => unknown).length ?? 0) >= 1 + ? 'default' + : (rawAdvanceMode as CursorAdvanceMode) || 'default'; + + return ( + + + + ); + } + + case 'VIRTUAL_DRAG': { + // Determine the start position - use guidance.startPosition if provided, + // otherwise use the previous cursor position, or undefined if neither exists + const resolvedStartPosition: PositionOrElement | undefined = + currentGuidance.startPosition || + (prevCursorPosition ? prevCursorPosition : undefined); + + return ( + + + + ); + } + + case 'MULTI_VIRTUAL_CLICK': { + // Determine the current click guidance + const currentClickGuidance = currentGuidance.guidances[currentClickIndex]; + + // Determine the start position based on the click index + let startPosition: PositionOrElement | undefined; + if (currentClickIndex === 0) { + // For the first click, use the previous cursor position or the specified start position + startPosition = + currentClickGuidance.startPosition || + (prevCursorPosition ? prevCursorPosition : undefined); + } else { + // For subsequent clicks, always use their specified start position if available, + // otherwise fallback to the end position of the previous click + startPosition = + currentClickGuidance.startPosition || + currentGuidance.guidances[currentClickIndex - 1].endPosition; + } + + // Use the same advanceMode calculation as for VIRTUAL_CLICK + const rawAdvanceModeMulti = currentClickGuidance.advanceMode; + type CursorAdvanceModeMulti = + | 'auto' + | 'external' + | 'default' + | number + | (() => boolean); + + const advanceMode: CursorAdvanceModeMulti = + typeof rawAdvanceModeMulti === 'function' && + ((rawAdvanceModeMulti as (...args: unknown[]) => unknown).length ?? + 0) >= 1 + ? 'default' + : (rawAdvanceModeMulti as CursorAdvanceModeMulti) || 'default'; + + return ( + + ); + } + + case 'VIRTUAL_TYPING': { + // Determine the start position - use guidance.startPosition if provided, + const typingStartPosition: PositionOrElement | undefined = + currentGuidance.startPosition || + (prevCursorPosition ? prevCursorPosition : undefined); + + return ( + { + if (typeof currentGuidance.advanceMode === 'function') { + return 'default'; + } + return ( + (currentGuidance.advanceMode as + | 'auto' + | 'external' + | 'default' + | number + | undefined) || 'default' + ); + })()} + onAnimationComplete={handleGuidanceEnd} + blocking={currentGuidance.blocking} + /> + ); + } + + case 'CHAT_TOOLTIP': { + // Find the chat button to position the tooltip + const chatButton = + document.querySelector('.CedarChatButton') || + document.querySelector('[data-cedar-chat-button]'); + const chatButtonRect = chatButton?.getBoundingClientRect(); + + if (!chatButtonRect) { + // If chat button not found, complete this guidance and go to next + setTimeout(handleGuidanceEnd, 100); + return null; + } + + // Calculate centered position above chat button + // We know TooltipText with position="top" will apply translateY(-100%) + // So we place this at the center top of the button + const tooltipPosition = { + left: chatButtonRect.left + chatButtonRect.width / 2, + top: chatButtonRect.top - 15, // Add a small vertical offset + }; + + return ( + + handleGuidanceEnd()} + /> + + ); + } + + case 'CHAT': + return null; + + case 'IDLE': + return null; + + case 'DIALOGUE': + return ( + <> + {currentGuidance.highlightElements && ( + + )} + boolean) => { + if ( + typeof currentGuidance.advanceMode === 'function' && + (( + currentGuidance.advanceMode as (...args: unknown[]) => unknown + ).length ?? 0) >= 1 + ) { + return 'default'; + } + return ( + (currentGuidance.advanceMode as + | 'auto' + | 'external' + | 'default' + | number + | (() => boolean)) || 'default' + ); + })()} + blocking={currentGuidance.blocking} + onComplete={handleGuidanceEnd} + /> + + ); + + case 'DIALOGUE_BANNER': { + const bannerGuidance = currentGuidance as DialogueBannerGuidance & { + children?: React.ReactNode; + }; + return ( + + {bannerGuidance.children ?? bannerGuidance.text} + + ); + } + + case 'EXECUTE_CLICK': { + // Only render cursor animation if showCursor is true (default) or undefined + const showCursor = currentGuidance.showCursor !== false; + + if (showCursor) { + // Determine the start position - use guidance.startPosition if provided, + // otherwise use the previous cursor position, or undefined if neither exists + const resolvedStartPosition: PositionOrElement | undefined = + currentGuidance.startPosition || + (prevCursorPosition ? prevCursorPosition : undefined); + + return ( + + + + ); + } + + // No visual component needed as the executeClick will be triggered by the useEffect + return null; + } + + case 'TOAST': { + return ( + nextGuidance(currentGuidance.id)} + /> + ); + } + + case 'EXECUTE_TYPING': { + return ( + + ); + } + + case 'RIGHT_CLICK': { + return ( + + ); + } + + default: + console.error('Unknown guidance type:', currentGuidance); + return null; + } +}; + +export default GuidanceRenderer; diff --git a/packages/cedar-os/src/components/guidance/components/BaseCursor.tsx b/packages/cedar-os/src/components/guidance/components/BaseCursor.tsx new file mode 100644 index 00000000..453d0a02 --- /dev/null +++ b/packages/cedar-os/src/components/guidance/components/BaseCursor.tsx @@ -0,0 +1,275 @@ +import React, { ReactNode } from 'react'; +import { motion } from 'motion/react'; +import TooltipText from '@/components/guidance/components/TooltipText'; +import { useStyling } from '@/store/CedarStore'; +import { Position } from '@/components/guidance/utils/positionUtils'; + +// Define Timeout type +export type Timeout = ReturnType; + +// Calculate best tooltip position based on viewport visibility using screen quadrants +export const calculateBestTooltipPosition = ( + position: Position, + tooltipContent: string | undefined, + positionOverride?: 'left' | 'right' | 'top' | 'bottom' +): 'left' | 'right' | 'top' | 'bottom' => { + // If preferred position is provided, use it + if (positionOverride) { + return positionOverride; + } + + // If no content, default to right + if (!tooltipContent) { + return 'right'; + } + + // Get viewport dimensions + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + // Determine screen center points + const centerX = viewportWidth / 2; + const centerY = viewportHeight / 2; + + // Determine which quadrant the cursor is in + const isRight = position.x >= centerX; + const isBottom = position.y >= centerY; + + // Choose position based on quadrant to keep tooltip on screen + if (isRight) { + // Right side of screen - prefer left tooltip + if (isBottom) { + // Bottom-right quadrant - use left or top + // For elements near bottom, prioritize top + if (position.y > viewportHeight - 150) { + return 'top'; + } + return 'left'; + } else { + // Top-right quadrant - use left or bottom + return 'left'; + } + } else { + // Left side of screen - prefer right tooltip + if (isBottom) { + // Bottom-left quadrant - use right or top + // For elements near bottom, prioritize top + if (position.y > viewportHeight - 150) { + return 'top'; + } + return 'right'; + } else { + // Top-left quadrant - use right or bottom + return 'right'; + } + } +}; + +interface BaseCursorProps { + startPosition: Position; + endPosition: Position; + fadeOut: boolean; + onAnimationComplete: () => void; + children?: ReactNode; + tooltipText?: string; + tooltipPosition?: 'left' | 'right' | 'top' | 'bottom'; + tooltipAnchor?: 'rect' | 'cursor'; // Whether to anchor tooltip to rect or cursor + showTooltip?: boolean; + onTooltipComplete: () => void; + cursorKey?: string; + endRect?: DOMRect | null; + dragCursor?: boolean; // Whether to use the drag cursor animation +} + +const BaseCursor: React.FC = ({ + startPosition, + endPosition, + fadeOut, + onAnimationComplete, + children, + tooltipText, + tooltipPosition = 'bottom', + tooltipAnchor = 'rect', + showTooltip = false, + onTooltipComplete, + cursorKey = 'base-cursor', + endRect = null, + dragCursor = false, +}) => { + const { styling } = useStyling(); + const [dragState, setDragState] = React.useState( + dragCursor ? 'closed' : 'normal' + ); + const animationCompleteCallback = React.useRef(onAnimationComplete); + + React.useEffect(() => { + animationCompleteCallback.current = onAnimationComplete; + }, [onAnimationComplete]); + + // Determine animation config based on dragCursor + const animationConfig = React.useMemo(() => { + if (dragCursor) { + // Slower, ease-in-out animation for drag operations + return { + type: 'tween' as const, + duration: 1, // Duration for the movement + ease: 'easeInOut' as const, // Smooth ease-in-out curve + delay: 0.3, // Delay before starting movement + }; + } + // Default spring animation for standard cursor movements + return { + type: 'spring' as const, + stiffness: 50, + damping: 17, + mass: 1.8, + }; + }, [dragCursor]); + + // Handle animation states for drag cursor + React.useEffect(() => { + if (dragCursor) { + // Start with open hand + setDragState('open'); + + // After 0.3s delay, close the hand to start the drag + const closeHandTimeout = setTimeout(() => { + setDragState('closed'); + }, 300); + + return () => clearTimeout(closeHandTimeout); + } + }, [dragCursor]); + + // Handle the completion of the movement animation + const handleMovementComplete = () => { + if (dragCursor) { + // Open hand immediately after movement completes + setDragState('open'); + + // After 0.3s delay, call the onAnimationComplete callback + const completeTimeout = setTimeout(() => { + animationCompleteCallback.current(); + }, 300); + + return () => clearTimeout(completeTimeout); + } else { + // For non-drag animations, call the callback immediately + onAnimationComplete(); + } + }; + + // Render different cursor SVGs based on dragState + const renderCursor = () => { + if (!dragCursor || dragState === 'normal') { + // Default pointer cursor + return ( + + + + ); + } else if (dragState === 'closed') { + return ( + + + + + ); + } else if (dragState === 'open') { + return ( + + + + + ); + } + }; + + return ( + + {renderCursor()} + + {tooltipText && showTooltip ? ( + { + onTooltipComplete(); + }} + fadeOut={fadeOut} + endRect={endRect} + /> + ) : ( + children + )} + + ); +}; + +export default BaseCursor; diff --git a/packages/cedar-os/src/components/guidance/components/CedarCursor.tsx b/packages/cedar-os/src/components/guidance/components/CedarCursor.tsx new file mode 100644 index 00000000..2d9e9801 --- /dev/null +++ b/packages/cedar-os/src/components/guidance/components/CedarCursor.tsx @@ -0,0 +1,510 @@ +'use client'; + +import gsap from 'gsap'; +import React, { useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { motion } from 'motion/react'; +import { useStyling, useGuidanceStyling } from '@/store/CedarStore'; + +interface CedarCursorProps { + isRedirected: boolean; + messages?: string[]; // Add support for custom messages + onAnimationComplete?: () => void; + cursorColor?: string; // Add support for custom cursor color + blocking?: boolean; // Add blocking overlay support +} + +export function CedarCursor({ + isRedirected, + messages = ['Oh...', 'Is this an investor I see?', 'Enter Secret Demo'], // Default messages + onAnimationComplete, + cursorColor = '#FFBFE9', // Default color - pink + blocking = false, // Default to non-blocking +}: CedarCursorProps) { + const cursorRef = useRef(null); + const textRef = useRef(null); + const particlesRef = useRef(null); + const cursorCtx = useRef(null); + const [isAnimationComplete, setIsAnimationComplete] = useState(false); + const [fadeOut, setFadeOut] = useState(false); + // const [cursorPosition, setCursorPosition] = useState({ x: 0, y: 0 }); + + // --------------------------------------------------------------------- + // Tooltip-like styling (reuse values from global styling context) + // --------------------------------------------------------------------- + const { styling } = useStyling(); + const { guidanceStyling, getGuidanceTextColor } = useGuidanceStyling(); + + // Derive styling values mimicking TooltipText.tsx + const bgColor = styling?.color || cursorColor; + const textColor = getGuidanceTextColor(bgColor); + const tooltipStyle = guidanceStyling?.tooltipStyle || 'lined'; + const tooltipFontSize = guidanceStyling?.tooltipSize || 'sm'; + + const fontSizeClassMap: Record = { + xs: 'text-xs', + sm: 'text-sm', + base: 'text-base', + lg: 'text-lg', + xl: 'text-xl', + '2xl': 'text-2xl', + }; + const fontSizeClass = fontSizeClassMap[tooltipFontSize] ?? 'text-sm'; + + const boxShadowValue = + tooltipStyle === 'lined' + ? `0 0 8px 2px ${bgColor}80, 0 4px 6px -1px rgba(0,0,0,0.1)` + : `0 0 2px 1px ${bgColor}30, 0 2px 4px -1px rgba(0,0,0,0.1)`; + + const tooltipBg = tooltipStyle === 'lined' ? 'white' : bgColor; + const tooltipBorderColor = tooltipStyle === 'lined' ? bgColor : 'white'; + + // Base style applied to the floating text element + const baseTextStyle: React.CSSProperties = { + opacity: 0, + transform: 'translate(-50%, -100%)', + willChange: 'transform', + position: 'fixed', + zIndex: 2147483647, + backgroundColor: tooltipBg, + color: textColor, + borderColor: tooltipBorderColor, + boxShadow: boxShadowValue, + padding: '6px 10px', + borderRadius: '9999px', + }; + + // Calculate duration based on message length + const calculateDuration = (message: string): number => { + // Based on reading speed: ~30 characters per second + // Minimum duration from 0.5s + const baseTime = 0.5; + const charTime = message.length / 30; + return Math.max(baseTime, charTime); + }; + + // Generate random size for the cursor + const getRandomSize = (): number => { + // Random number between 1.8 and 2.8 for more constrained size variation + return 1.8 + Math.random() * 1.0; + }; + + // Get a subtle size change (smaller variance) + const getSubtleChange = (currentSize: number): number => { + // Add or subtract a smaller amount (0.2 to 0.4) from current size for more subtle changes + const change = 0.2 + Math.random() * 0.2; + // 50% chance to grow or shrink + return Math.random() > 0.5 + ? Math.min(currentSize + change, 2.8) + : Math.max(currentSize - change, 1.8); + }; + + // Handle cursor movement + useEffect(() => { + const cursor = cursorRef.current; + const text = textRef.current; + if (!cursor || !text) return; + + const onMouseMove = (e: MouseEvent) => { + // Store cursor position for particles + // setCursorPosition({ x: e.clientX, y: e.clientY }); + + // Apply position directly to match exact cursor position + cursor.style.left = `${e.clientX}px`; + cursor.style.top = `${e.clientY}px`; + + // Position the text tooltip directly above the cursor with direct CSS positioning + // Center it horizontally and position it above the cursor + text.style.left = `${e.clientX}px`; + text.style.top = `${e.clientY - 40}px`; // Increased from 40px to 50px for better spacing + }; + + document.body.style.cursor = 'none'; + + window.addEventListener('mousemove', onMouseMove); + return () => { + window.removeEventListener('mousemove', onMouseMove); + document.body.style.cursor = 'auto'; + }; + }, []); + + // Handle redirect animation sequence + useEffect(() => { + const cursor = cursorRef.current; + const text = textRef.current; + if (!cursor || !text) return; + + cursorCtx.current?.revert(); + cursorCtx.current = gsap.context(() => { + const tl = gsap.timeline({ + onComplete: () => { + setIsAnimationComplete(true); + setFadeOut(true); + // Fix for single message issue: If there's just one message, + // ensure we call onAnimationComplete to avoid getting stuck + if (messages.length === 1 && onAnimationComplete) { + onAnimationComplete(); + } + }, + }); + + // Initial pause - now 2 seconds + tl.to({}, { duration: 1.5 }); + + // Track cursor size to allow for relative changes + let currentCursorSize = 1; + + // Create animation sequence for each message + messages.forEach((message, index) => { + // Calculate message display duration based on length + const messageDuration = calculateDuration(message); + // Random size for initial cursor growth + const initialSize = getRandomSize(); + currentCursorSize = initialSize; + + // For first message + if (index === 0) { + tl.to(cursor, { + scale: initialSize, + duration: 0.5, + ease: 'elastic.out(1, 0.5)', + }) + .set(text, { + innerHTML: message, + backgroundColor: tooltipBg, + borderColor: tooltipBorderColor, + color: textColor, + }) + .to(text, { + opacity: 1, + scale: 1, + duration: 0.5, + padding: '4px 8px', + borderRadius: '9999px', + ease: 'power2.out', + onComplete: () => { + text.style.setProperty('opacity', '1', 'important'); + }, + }); + + // Create a nested timeline for talking animation + const talkingTl = gsap.timeline(); + // Pass the initialSize to maintain the expanded cursor + // createTalkingAnimation(talkingTl, cursor, message, initialSize); + + // Add the talking animation to the main timeline + tl.add(talkingTl, '+=0.1'); + + // Add 1-2 subtle size changes during text display (reduced from 2-3) + const numChanges = 1 + Math.floor(Math.random() * 2); + const changeInterval = messageDuration / (numChanges + 1); + + for (let i = 0; i < numChanges; i++) { + const newSize = getSubtleChange(currentCursorSize); + currentCursorSize = newSize; + + tl.to( + cursor, + { + scale: newSize, + duration: 0.3, + ease: 'power1.inOut', + }, + `+=${changeInterval}` + ); + } + + // Add remaining pause time + tl.to({}, { duration: changeInterval }); + } + // For middle messages - interleave cursor and text animations + else if (index < messages.length - 1) { + // Fade out previous text + tl.to(text, { + opacity: 0, + scale: 0.8, + duration: 0.3, + ease: 'power2.in', + }) + // Animate cursor between messages (with varied size change) + .to(cursor, { + scale: 1.8 + Math.random() * 0.4, // More constrained transition size + duration: 0.3, + ease: 'power2.in', + }) + // Set new message text while it's invisible + .set(text, { + innerHTML: message, + backgroundColor: tooltipBg, + borderColor: tooltipBorderColor, + color: textColor, + }) + // Scale up cursor with elastic effect (to random size) + .to(cursor, { + scale: initialSize, + duration: 0.5, + ease: 'elastic.out(1, 0.5)', + }) + // Bring in the new text + .to(text, { + opacity: 1, + scale: 1.2, + duration: 0.5, + padding: '4px 8px', + borderRadius: '9999px', + backgroundColor: tooltipBg, + borderColor: tooltipBorderColor, + color: textColor, + ease: 'power2.out', + }); + + // Create a nested timeline for talking animation + const talkingTl = gsap.timeline(); + // Pass the initialSize to maintain the expanded cursor + // createTalkingAnimation(talkingTl, cursor, message, initialSize); + + // Add the talking animation to the main timeline + tl.add(talkingTl, '+=0.1'); + + // Add 1-2 subtle size changes during text display (reduced from 2-4) + const numChanges = 1 + Math.floor(Math.random() * 2); + const changeInterval = messageDuration / (numChanges + 1); + + for (let i = 0; i < numChanges; i++) { + const newSize = getSubtleChange(currentCursorSize); + currentCursorSize = newSize; + + tl.to( + cursor, + { + scale: newSize, + duration: 0.3, + ease: 'power1.inOut', + }, + `+=${changeInterval}` + ); + } + + // Add remaining pause time + tl.to({}, { duration: changeInterval }); + } + // For the last message (transform to button) + else { + // Fade out previous text + tl.to(text, { + opacity: 0, + scale: 0.8, + duration: 0.3, + ease: 'power2.in', + }) + // Animate cursor between messages (with varied size) + .to(cursor, { + scale: 1.8 + Math.random() * 0.4, // More constrained transition size + duration: 0.3, + ease: 'power2.in', + }) + // Set new message text while it's invisible + .set(text, { + innerHTML: message, + backgroundColor: tooltipBg, + borderColor: tooltipBorderColor, + color: textColor, + }) + // Scale up cursor for final message (to random size) + .to(cursor, { + scale: 2.0 + Math.random() * 0.6, // More constrained between 2.0 and 2.6 + duration: 0.5, + ease: 'elastic.out(1, 0.5)', + }) + // Show the final message with styling + .to(text, { + opacity: 1, + color: textColor, + padding: '6px 10px', + borderRadius: '9999px', + boxShadow: boxShadowValue, + backgroundColor: tooltipBg, + borderColor: tooltipBorderColor, + scale: 1.2, + duration: 0.5, + ease: 'power2.out', + }); + + // Create a nested timeline for talking animation for the final message + const talkingTl = gsap.timeline(); + // Use the larger final size for the last message + // const finalSize = 2.0 + Math.random() * 0.6; + // createTalkingAnimation(talkingTl, cursor, message, finalSize); + + // Add the talking animation to the main timeline + tl.add(talkingTl, '+=0.1'); + + // Add 2-3 subtle size changes during final message (reduced from 3-5) + const numChanges = 2 + Math.floor(Math.random() * 2); + const changeInterval = messageDuration / (numChanges + 1); + + for (let i = 0; i < numChanges; i++) { + const newSize = getSubtleChange(currentCursorSize); + currentCursorSize = newSize; + + tl.to( + cursor, + { + scale: newSize, + duration: 0.3, + ease: 'power1.inOut', + }, + `+=${changeInterval}` + ); + } + + // Add remaining time, then fade out + tl.to({}, { duration: changeInterval }) + // Add fade-out animation + .to(text, { + opacity: 0, + y: -20, + duration: 0.8, + ease: 'power2.in', + }) + .to(cursor, { + scale: 1.2, // Slightly expand before popping + duration: 0.15, + ease: 'power1.out', + }) + .to(cursor, { + scale: 0, + opacity: 0, + duration: 0.15, + ease: 'power4.in', // Sharper easing for more "pop" feeling + }) + .to({}, { duration: 0.5 }) // Add half-second delay + .call(() => onAnimationComplete?.()); + } + }); + }); + + return () => cursorCtx.current?.revert(); + }, [ + messages, + onAnimationComplete, + cursorColor, + tooltipBg, + tooltipBorderColor, + textColor, + boxShadowValue, + ]); + + // Handle global click to redirect - only when isAnimationComplete and isRedirected are true + useEffect(() => { + if (!isAnimationComplete || !isRedirected) return; + + const handleClick = () => { + // Open in a new tab instead of using router + window.open('/forecast', '_blank', 'noopener,noreferrer'); + }; + + window.addEventListener('click', handleClick); + return () => { + window.removeEventListener('click', handleClick); + }; + }, [isAnimationComplete, isRedirected]); + + return ( + <> + + + {/* Blocking overlay */} + {blocking && + typeof window !== 'undefined' && + createPortal( + { + e.preventDefault(); + e.stopPropagation(); + }} + aria-hidden='true' + />, + document.body + )} + + {/* Added wrapper div with max z-index to create new stacking context */} +
+
+
+ {/* Initial message content - GSAP will update this during animation sequence */} + {messages[0]} +
+ {/* Container for particle effects */} +
+
+ + ); +} diff --git a/packages/cedar-os/src/components/guidance/components/ClickableArea.tsx b/packages/cedar-os/src/components/guidance/components/ClickableArea.tsx new file mode 100644 index 00000000..cf224f08 --- /dev/null +++ b/packages/cedar-os/src/components/guidance/components/ClickableArea.tsx @@ -0,0 +1,242 @@ +import { motion } from 'framer-motion'; +import React, { useEffect, useRef } from 'react'; +import { cn } from '@/styles/stylingUtils'; +import { useStyling } from '@/store/CedarStore'; +import { createPortal } from 'react-dom'; + +interface ClickableAreaProps { + rect: DOMRect; + onClick?: () => void; + className?: string; + blocking?: boolean; // When true, creates an overlay to block clicks outside the target area + fadeOut?: boolean; // Controls whether the area and overlay should fade out + buffer?: number; // Optional buffer around the clickable area in pixels + disabled?: boolean; // When true, disables click interaction and blocks click propagation +} + +const ClickableArea: React.FC = ({ + rect, + onClick, + className, + blocking = false, + fadeOut = false, + buffer = 0, + disabled = false, +}: ClickableAreaProps) => { + const areaRef = useRef(null); + const { styling } = useStyling(); + + // Extract if this area has a ring style (which indicates it's used with blocking overlay) + const hasRingClass = className?.includes('ring-') || blocking; + + // Add event listeners for click and dragend events + useEffect(() => { + if (!rect || !onClick || disabled) return; + + // Add a flag to prevent duplicate calls within a short timeframe + let isHandlingClick = false; + + const isWithinBounds = (x: number, y: number) => { + return ( + x >= rect.left - buffer && + x <= rect.left + rect.width + buffer && + y >= rect.top - buffer && + y <= rect.top + rect.height + buffer + ); + }; + + // Shared handler for all events to prevent duplicates + const handleEvent = (e: MouseEvent | DragEvent) => { + if (isWithinBounds(e.clientX, e.clientY) && !isHandlingClick) { + isHandlingClick = true; + onClick(); + + // Reset the flag after a short delay + const timeout = setTimeout(() => { + isHandlingClick = false; + }, 100); // 100ms debounce + return () => clearTimeout(timeout); + } + }; + + // Add event listeners - using capture (true) for drag/drop events + window.addEventListener('click', handleEvent, true); + window.addEventListener('drop', handleEvent, true); // Using capture phase + + // Clean up on unmount + return () => { + window.removeEventListener('click', handleEvent, true); + window.removeEventListener('drop', handleEvent, true); // Match capture phase in cleanup + }; + }, [onClick, rect, buffer, disabled]); + + if (!rect) return null; + + // Use a stronger box shadow when this is being used with blocking overlay + const boxShadowStyle = + blocking || hasRingClass + ? `0 0 0 3px white, 0 0 0 6px ${ + styling.color || '#FFBFE9' + }, 0 0 30px 10px rgba(255, 255, 255, 0.9)` + : `0 0 0 2px white, 0 0 0 4px ${styling.color || '#FFBFE9'}`; + + return ( + <> + {/* Blocking overlay with a cut-out for the clickable area */} + {blocking && + rect && + typeof window !== 'undefined' && + createPortal( + <> + {/* Top overlay */} + {rect.top > 0 && ( + { + e.preventDefault(); + e.stopPropagation(); + }} + aria-hidden='true' + /> + )} + + {/* Left overlay */} + {rect.left > 0 && ( + { + e.preventDefault(); + e.stopPropagation(); + }} + aria-hidden='true' + /> + )} + + {/* Right overlay */} + {rect.left + rect.width < window.innerWidth && ( + { + e.preventDefault(); + e.stopPropagation(); + }} + aria-hidden='true' + /> + )} + + {/* Bottom overlay */} + {rect.top + rect.height < window.innerHeight && ( + { + e.preventDefault(); + e.stopPropagation(); + }} + aria-hidden='true' + /> + )} + , + document.body + )} + + {/* The actual clickable area */} + { + if (disabled) { + e.preventDefault(); + e.stopPropagation(); + } + }} + aria-hidden='true' + /> + + ); +}; + +export default ClickableArea; diff --git a/packages/cedar-os/src/components/guidance/components/DialogueBanner.tsx b/packages/cedar-os/src/components/guidance/components/DialogueBanner.tsx new file mode 100644 index 00000000..11d551f2 --- /dev/null +++ b/packages/cedar-os/src/components/guidance/components/DialogueBanner.tsx @@ -0,0 +1,122 @@ +'use client'; + +import React, { useEffect, useState, useRef } from 'react'; +import Container3D from '@/components/guidance/generic/Container3D'; +import GlowingMesh from '@/components/guidance/generic/GlowingMesh'; + +export interface DialogueBannerProps { + /** Optional children content to display instead of typewriter text */ + children?: React.ReactNode; + /** Optional text for typewriter or fallback if no children */ + text?: string; + style?: React.CSSProperties; + advanceMode?: + | 'auto' + | 'external' + | 'default' + | number + | (() => boolean) + | ((nextAction: () => void) => void); + onComplete: () => void; +} + +const DialogueBanner: React.FC = ({ + text, + children, + style, + advanceMode = 'default', + onComplete, +}) => { + const [displayedText, setDisplayedText] = useState(''); + const timeoutRef = useRef(null); + const typingSpeed = 30; + + const isAuto = advanceMode === 'auto'; + const isNumericDelay = typeof advanceMode === 'number'; + const isFunctionPredicate = + typeof advanceMode === 'function' && + (advanceMode as () => boolean).length === 0; + const delayDuration = isNumericDelay ? (advanceMode as number) : 3000; + + useEffect(() => { + if (!isFunctionPredicate) return; + const predicate = advanceMode as () => boolean; + if (predicate()) { + onComplete(); + return; + } + const interval = setInterval(() => { + if (predicate()) { + onComplete(); + clearInterval(interval); + } + }, 500); + return () => clearInterval(interval); + }, [advanceMode, isFunctionPredicate, onComplete]); + + useEffect(() => { + // Skip typing effect if children provided + if (children) { + return; + } + const sourceText = text ?? ''; + let index = 0; + const interval = setInterval(() => { + if (index < sourceText.length) { + setDisplayedText(sourceText.substring(0, index + 1)); + index++; + } else { + clearInterval(interval); + if (isAuto) { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => onComplete(), 5000); + } else if (isNumericDelay) { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => onComplete(), delayDuration); + } + } + }, typingSpeed); + return () => { + clearInterval(interval); + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }; + }, [ + text, + advanceMode, + isAuto, + isNumericDelay, + delayDuration, + onComplete, + children, + ]); + + // Fully opaque center mask for stronger fade effect + const maskImage = + 'linear-gradient(to right, transparent 0%, rgba(0,0,0,1) 10%, rgba(0,0,0,1) 90%, transparent 100%)'; + + const wrapperStyle: React.CSSProperties = { + position: 'fixed', + top: '15%', + left: '50%', + transform: 'translateX(-50%)', + width: '100%', + maxWidth: '42rem', + pointerEvents: 'none', + WebkitMaskImage: maskImage, + maskImage: maskImage, + }; + + return ( +
+ + {/* Render children if provided, else fallback to displayed text */} + {children ?? displayedText} + + +
+ ); +}; + +export default DialogueBanner; diff --git a/packages/cedar-os/src/components/guidance/components/DialogueBox.tsx b/packages/cedar-os/src/components/guidance/components/DialogueBox.tsx new file mode 100644 index 00000000..d3ef7c8c --- /dev/null +++ b/packages/cedar-os/src/components/guidance/components/DialogueBox.tsx @@ -0,0 +1,257 @@ +'use client'; + +import React, { useEffect, useState, useRef } from 'react'; +import { motion } from 'framer-motion'; +import { useStyling, useGuidanceStyling } from '@/store/CedarStore'; +import { createPortal } from 'react-dom'; + +export interface DialogueBoxProps { + text: string; + style?: React.CSSProperties; + advanceMode: 'auto' | 'external' | 'default' | number | (() => boolean); + onComplete: () => void; + blocking?: boolean; // When true, creates an overlay to block clicks outside the dialogue +} + +const DialogueBox: React.FC = ({ + text, + style, + advanceMode, + onComplete, + blocking = false, +}) => { + const { styling } = useStyling(); + const { guidanceStyling, getGuidanceTextColor } = useGuidanceStyling(); + const [displayedText, setDisplayedText] = useState(''); + const [isTypingComplete, setIsTypingComplete] = useState(false); + const typingSpeed = 30; // ms per character + const timeoutRef = useRef(null); + + // Determine behavior based on advanceMode + const isAuto = advanceMode === 'auto'; + const isNumericDelay = typeof advanceMode === 'number'; + const isFunctionMode = typeof advanceMode === 'function'; + const delayDuration = isNumericDelay ? advanceMode : 3000; // Default 3s delay for 'auto' + + // Call advanceMode function on initialization if it's a function + useEffect(() => { + // Only set up interval if we have a function mode + if (!isFunctionMode) return; + + // First, check immediately + const shouldAdvance = (advanceMode as () => boolean)(); + if (shouldAdvance) { + // If function returns true, complete the dialogue immediately + onComplete(); + return; // Exit early, no need to set up interval + } + + // Set up interval to periodically check the condition (every 500ms) + const checkInterval = setInterval(() => { + // Call the function and check if we should advance + const shouldAdvance = (advanceMode as () => boolean)(); + if (shouldAdvance) { + // If function returns true, complete the dialogue + onComplete(); + // Clear the interval once we've advanced + clearInterval(checkInterval); + } + }, 500); // Check every 500ms + + // Clean up when component unmounts + return () => { + clearInterval(checkInterval); + }; + }, [isFunctionMode, advanceMode, onComplete]); + + // Handle typing animation effect + useEffect(() => { + let currentIndex = 0; + const typingInterval = setInterval(() => { + if (currentIndex < text.length) { + setDisplayedText(text.substring(0, currentIndex + 1)); + currentIndex++; + } else { + clearInterval(typingInterval); + setIsTypingComplete(true); + + // When typing completes, handle auto advance + if (isAuto || isNumericDelay) { + // Clear any existing timeout + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + // Set timeout to advance after the specified delay + timeoutRef.current = setTimeout(() => { + onComplete(); + }, delayDuration); + } + } + }, typingSpeed); + + return () => { + clearInterval(typingInterval); + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, [text, advanceMode, onComplete, isAuto, isNumericDelay, delayDuration]); + + // Handler for advancing the dialogue + const handleAdvanceDialogue = () => { + if (isTypingComplete && advanceMode === 'default') { + onComplete(); + } + }; + + // Global click handler for screen + useEffect(() => { + // Only add the event listener when typing is complete and advanceMode is 'default' + if (isTypingComplete && advanceMode === 'default') { + window.addEventListener('click', handleAdvanceDialogue); + } + + return () => { + window.removeEventListener('click', handleAdvanceDialogue); + }; + }, [isTypingComplete, advanceMode, onComplete]); + + // Define the box shadow style similar to ClickableArea + const boxShadowStyle = `0 0 0 2px white, 0 0 0 4px ${ + styling.color || '#FFBFE9' + }, 0 0 30px rgba(255, 255, 255, 0.8)`; + + // Function to safely render the icon component + const renderIconComponent = () => { + // If iconComponent doesn't exist, return null + if (!styling.iconComponent) { + return null; + } + + // Create a wrapper component for the icon + return ( + + {React.isValidElement(styling.iconComponent) + ? styling.iconComponent + : null} + + ); + }; + + // Create the combined content that will be placed in the portal + const dialogueContent = ( + <> + {/* Blocking overlay to prevent interactions outside the dialogue */} + {blocking && ( + { + // Allow clicks on the overlay to advance the dialogue + if (isTypingComplete && advanceMode === 'default') { + e.preventDefault(); + e.stopPropagation(); + handleAdvanceDialogue(); + } + }} + aria-hidden='true' + /> + )} + + +
{ + // Prevent click from propagating to window + e.stopPropagation(); + // If typing is complete and mode is default, complete dialogue + if (isTypingComplete && advanceMode === 'default') { + handleAdvanceDialogue(); + } + }}> + {/* Static size container that establishes dimensions based on full text */} +
+ {/* Hidden full text to establish exact dimensions */} + + + {/* Visible animated text positioned absolutely within the sized container */} +
+ {displayedText} +
+
+ + {/* Continue indicator */} + {isTypingComplete && advanceMode === 'default' && ( + + Click anywhere to continue... + + )} + + {/* Custom icon component or default decorative accent */} + {styling.iconComponent && + React.isValidElement(styling.iconComponent) ? ( + renderIconComponent() + ) : ( + + )} +
+
+ + ); + + // Use createPortal to insert both elements directly into the document body + return typeof window !== 'undefined' + ? createPortal(dialogueContent, document.body) + : null; +}; + +export default DialogueBox; diff --git a/packages/cedar-os/src/components/guidance/components/EdgePointer.tsx b/packages/cedar-os/src/components/guidance/components/EdgePointer.tsx new file mode 100644 index 00000000..543f42a5 --- /dev/null +++ b/packages/cedar-os/src/components/guidance/components/EdgePointer.tsx @@ -0,0 +1,260 @@ +import React, { useState } from 'react'; +import { motion } from 'framer-motion'; +import { Position } from '@/components/guidance/utils/positionUtils'; +import { useStyling } from '@/store/CedarStore'; +import TooltipText from '@/components/guidance/components/TooltipText'; +import { SPRING_CONFIGS } from '@/components/guidance/utils/constants'; + +interface EdgePointerProps { + startPosition: Position; // Starting position, same as BaseCursor + endRect: DOMRect; // The endRect from VirtualCursor to determine edge position + fadeOut?: boolean; + tooltipText?: string; + onAnimationComplete?: () => void; + onTooltipComplete?: () => void; + shouldAnimateStartMotion: boolean; +} + +/** + * Calculate the position and rotation of the edge pointer + * @param targetRect The target DOM rect + * @returns Position, angle, and edge information + */ +const calculateEdgePointerPosition = ( + targetRect: DOMRect +): { + position: Position; + angle: number; + edge: 'top' | 'right' | 'bottom' | 'left'; +} => { + // Get viewport dimensions + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + // Calculate center of viewport and target + const centerX = viewportWidth / 2; + const centerY = viewportHeight / 2; + const targetX = targetRect.left + targetRect.width / 2; + const targetY = targetRect.top + targetRect.height / 2; + + // Edge padding for better visibility + const edgePadding = 40; + const rightEdgePadding = 60; // Extra padding for right edge to account for scrollbars + + // Calculate angle from center to target + const angleRad = Math.atan2(targetY - centerY, targetX - centerX); + const angleDeg = (angleRad * 180) / Math.PI + 135; + + // Determine which edge the pointer should be placed on + let edge: 'top' | 'right' | 'bottom' | 'left'; + let edgeX: number; + let edgeY: number; + + // Check if target is off the right side + if (targetRect.left > viewportWidth) { + edge = 'right'; + edgeX = viewportWidth - rightEdgePadding; + edgeY = Math.max( + edgePadding, + Math.min(viewportHeight - edgePadding, targetY) + ); + } + // Check if target is off the left side + else if (targetRect.right < 0) { + edge = 'left'; + edgeX = edgePadding; + edgeY = Math.max( + edgePadding, + Math.min(viewportHeight - edgePadding, targetY) + ); + } + // Check if target is off the bottom + else if (targetRect.top > viewportHeight) { + edge = 'bottom'; + edgeY = viewportHeight - edgePadding; + edgeX = Math.max( + edgePadding, + Math.min(viewportWidth - rightEdgePadding, targetX) + ); + } + // Check if target is off the top + else if (targetRect.bottom < 0) { + edge = 'top'; + edgeY = edgePadding; + edgeX = Math.max( + edgePadding, + Math.min(viewportWidth - rightEdgePadding, targetX) + ); + } + // Fallback - choose the closest edge + else { + const distToRight = viewportWidth - targetX; + const distToLeft = targetX; + const distToBottom = viewportHeight - targetY; + const distToTop = targetY; + + const minDist = Math.min(distToRight, distToLeft, distToBottom, distToTop); + + if (minDist === distToRight) { + edge = 'right'; + edgeX = viewportWidth - rightEdgePadding; + edgeY = targetY; + } else if (minDist === distToLeft) { + edge = 'left'; + edgeX = edgePadding; + edgeY = targetY; + } else if (minDist === distToBottom) { + edge = 'bottom'; + edgeY = viewportHeight - edgePadding; + edgeX = targetX; + } else { + edge = 'top'; + edgeY = edgePadding; + edgeX = targetX; + } + } + + // Ensure we stay within viewport bounds + edgeX = Math.max( + edgePadding, + Math.min(viewportWidth - rightEdgePadding, edgeX) + ); + edgeY = Math.max(edgePadding, Math.min(viewportHeight - edgePadding, edgeY)); + + return { + position: { x: edgeX, y: edgeY }, + angle: angleDeg, + edge, + }; +}; + +const EdgePointer: React.FC = ({ + startPosition, + endRect, + fadeOut = false, + tooltipText = 'The element is off screen here!', + onAnimationComplete, + onTooltipComplete, + shouldAnimateStartMotion, +}) => { + const { styling } = useStyling(); + const [showTooltip, setShowTooltip] = useState(false); + // Calculate edge pointer position from the endRect + const pointerData = calculateEdgePointerPosition(endRect); + + // Get the appropriate spring configuration + const actualSpringConfig = fadeOut + ? SPRING_CONFIGS.FADEOUT + : SPRING_CONFIGS.EDGE_POINTER; + + // Handler for cursor animation completion + const handleAnimationComplete = () => { + if (onAnimationComplete) { + onAnimationComplete(); + } + + // Show tooltip after animation completes + if (tooltipText) { + setShowTooltip(true); + } + }; + + // Handler for tooltip completion + const handleTooltipComplete = () => { + if (onTooltipComplete) { + onTooltipComplete(); + } + }; + + // Convert edge to tooltip position + const getTooltipPosition = (): 'left' | 'right' | 'top' | 'bottom' => { + // Return the opposite direction of the edge + switch (pointerData.edge) { + case 'top': + return 'bottom'; // If pointer is at top edge, tooltip below it + case 'right': + return 'left'; // If pointer is at right edge, tooltip left of it + case 'bottom': + return 'top'; // If pointer is at bottom edge, tooltip above it + case 'left': + return 'right'; // If pointer is at left edge, tooltip right of it + default: + return 'top'; + } + }; + + // Create a fake DOMRect to position the tooltip correctly + const createPointerRect = (): DOMRect => { + return { + x: pointerData.position.x, + y: pointerData.position.y, + width: 28, + height: 28, + top: pointerData.position.y - 16, + right: pointerData.position.x + 20, + bottom: pointerData.position.y + 20, + left: pointerData.position.x - 16, + toJSON: () => {}, + }; + }; + + return ( + + {/* Cursor SVG */} + + + + + {/* Tooltip */} + {tooltipText && showTooltip && ( + + )} + + ); +}; + +export default EdgePointer; diff --git a/packages/cedar-os/src/components/guidance/components/ExecuteTyping.tsx b/packages/cedar-os/src/components/guidance/components/ExecuteTyping.tsx new file mode 100644 index 00000000..3e9db973 --- /dev/null +++ b/packages/cedar-os/src/components/guidance/components/ExecuteTyping.tsx @@ -0,0 +1,83 @@ +import React, { useEffect, useRef } from 'react'; +import { + PositionOrElement, + LazyPositionOrElement, +} from '@/components/guidance/utils/positionUtils'; +import { + isInputElement, + startTypingAnimation, + Timeout, +} from '@/components/guidance/utils/typingUtils'; + +interface ExecuteTypingProps { + endPosition: PositionOrElement; + expectedValue: string; + onComplete?: () => void; +} + +const ExecuteTyping: React.FC = ({ + endPosition, + expectedValue, + onComplete, +}) => { + const typingTimeoutRef = useRef(null); + + useEffect(() => { + // Properly resolve the endPosition if it's a lazy reference + let resolvedEndPosition = endPosition; + if ( + endPosition && + typeof endPosition === 'object' && + '_lazy' in endPosition + ) { + resolvedEndPosition = (endPosition as LazyPositionOrElement).resolve(); + } + + // Validate that we have a proper element + const isValidElement = + resolvedEndPosition && + typeof resolvedEndPosition === 'object' && + (resolvedEndPosition as HTMLElement).getBoundingClientRect; + + // Get the actual element + const endElement = isValidElement + ? (resolvedEndPosition as HTMLElement) + : null; + + if (!endElement || !isInputElement(endElement)) { + console.error('ExecuteTyping: Invalid or non-input element provided'); + onComplete?.(); + return; + } + + if (typeof expectedValue !== 'string') { + console.error('ExecuteTyping: Only string expectedValue is supported'); + onComplete?.(); + return; + } + + // Start typing immediately + startTypingAnimation( + endElement, + expectedValue, + 150, // default typing delay + true, // check existing value + () => { + onComplete?.(); + }, + typingTimeoutRef + ); + + // Cleanup on unmount + return () => { + if (typingTimeoutRef.current) { + clearTimeout(typingTimeoutRef.current); + } + }; + }, [endPosition, expectedValue, onComplete]); + + // This component doesn't render anything visible + return null; +}; + +export default ExecuteTyping; diff --git a/packages/cedar-os/src/components/guidance/components/HighlightOverlay.tsx b/packages/cedar-os/src/components/guidance/components/HighlightOverlay.tsx new file mode 100644 index 00000000..a05ed9e3 --- /dev/null +++ b/packages/cedar-os/src/components/guidance/components/HighlightOverlay.tsx @@ -0,0 +1,184 @@ +'use client'; + +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { motion } from 'framer-motion'; +import { + getRectFromPositionOrElement, + PositionOrElement, +} from '../utils/positionUtils'; +import ClickableArea from './ClickableArea'; + +export interface HighlightOverlayProps { + elements: + | PositionOrElement[] + | { _lazy: true; resolve: () => PositionOrElement[] }; + shouldScroll?: boolean; +} + +export const HighlightOverlay: React.FC = ({ + elements, + shouldScroll = true, +}) => { + // Store element rects in state + const [elementRects, setElementRects] = useState<(DOMRect | null)[]>([]); + // Store resolved elements + const [resolvedElements, setResolvedElements] = useState( + [] + ); + // Store element refs for tracking + const elementsRef = useRef(resolvedElements); + // Store original elements prop for tracking + const originalElementsRef = useRef(elements); + // Animation frame ID for requestAnimationFrame + const rafRef = useRef(null); + // Last timestamp for throttling updates + const lastUpdateTimeRef = useRef(0); + // Last timestamp for resolving lazy elements + const lastResolveTimeRef = useRef(0); + // Update interval in ms + const updateInterval = 100; + // Resolver update interval in ms (refresh lazy elements every 500ms) + const resolverUpdateInterval = 500; + // Flag to track if we have a lazy resolver + const isLazyRef = useRef(false); + + // Update original elements ref when prop changes + useEffect(() => { + originalElementsRef.current = elements; + // Determine if we have a lazy resolver + isLazyRef.current = !!( + elements && + typeof elements === 'object' && + '_lazy' in elements && + elements._lazy + ); + }, [elements]); + + // Function to resolve elements from a lazy resolver + const resolveElements = useCallback(() => { + const currentElements = originalElementsRef.current; + + if ( + currentElements && + typeof currentElements === 'object' && + '_lazy' in currentElements && + currentElements._lazy + ) { + try { + // Resolve the elements and update state + const resolved = currentElements.resolve(); + setResolvedElements(resolved || []); + } catch (error) { + console.error('Error resolving lazy elements:', error); + setResolvedElements([]); + } + } else if (Array.isArray(currentElements)) { + // If not lazy, just use the elements directly + setResolvedElements(currentElements); + } + }, []); + + // Function to update the element rects based on current positions + const updateElementRects = useCallback( + (timestamp?: number) => { + // If we have a lazy resolver and enough time has passed, resolve elements again + if ( + isLazyRef.current && + timestamp && + timestamp - lastResolveTimeRef.current >= resolverUpdateInterval + ) { + resolveElements(); + lastResolveTimeRef.current = timestamp; + } + + if (!elementsRef.current || elementsRef.current.length === 0) return; + + const newRects = elementsRef.current.map((element) => + getRectFromPositionOrElement(element, 10, shouldScroll) + ); + + // Only update state if there's a change to avoid unnecessary rerenders + const hasChanged = newRects.some((rect, index) => { + const currentRect = elementRects[index]; + return ( + !currentRect || + !rect || + rect.x !== currentRect.x || + rect.y !== currentRect.y || + rect.width !== currentRect.width || + rect.height !== currentRect.height + ); + }); + + if (hasChanged || elementRects.length !== newRects.length) { + setElementRects(newRects); + } + }, + [elementRects, resolveElements, shouldScroll] + ); + + // The animation frame loop function with throttling + const animationFrameLoop = useCallback( + (timestamp: number) => { + // Only update if enough time has passed since last update + if (timestamp - lastUpdateTimeRef.current >= updateInterval) { + updateElementRects(timestamp); + lastUpdateTimeRef.current = timestamp; + } + + // Continue the loop by requesting the next frame + rafRef.current = requestAnimationFrame(animationFrameLoop); + }, + [updateElementRects] + ); + + // Initial resolution of elements when component mounts or elements prop changes + useEffect(() => { + resolveElements(); + }, [resolveElements]); + + // Update elementsRef when resolved elements change + useEffect(() => { + elementsRef.current = resolvedElements; + // Initial update right away + updateElementRects(); + }, [resolvedElements, updateElementRects]); + + // Set up position tracking using requestAnimationFrame + useEffect(() => { + // Initial update right away + updateElementRects(); + + // Start the animation frame loop + rafRef.current = requestAnimationFrame(animationFrameLoop); + + // Clean up animation frame on unmount + return () => { + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + }; + }, [animationFrameLoop, updateElementRects]); + + // Don't render anything if no elements to highlight + if (!elementRects.length) return null; + + return ( + <> + {elementRects.map((rect, index) => + rect ? ( + + + + ) : null + )} + + ); +}; + +export default HighlightOverlay; diff --git a/packages/cedar-os/src/components/guidance/components/IFGuidanceRenderer.tsx b/packages/cedar-os/src/components/guidance/components/IFGuidanceRenderer.tsx new file mode 100644 index 00000000..4607ff5d --- /dev/null +++ b/packages/cedar-os/src/components/guidance/components/IFGuidanceRenderer.tsx @@ -0,0 +1,568 @@ +import React, { useEffect, useCallback } from 'react'; +import { useGuidance } from '@/store/CedarStore'; +import { + IFGuidance, + Guidance, + VirtualClickGuidance, +} from '@/store/guidance/guidanceSlice'; +import VirtualCursor from '@/components/guidance/components/VirtualCursor'; +import VirtualTypingCursor from '@/components/guidance/components/VirtualTypingCursor'; +import ExecuteTyping from '@/components/guidance/components/ExecuteTyping'; +import { CedarCursor } from '@/components/guidance/components/CedarCursor'; +import DialogueBox from '@/components/guidance/components/DialogueBox'; + +import TooltipText from './TooltipText'; +import ToastCard from './ToastCard'; +import { motion } from 'framer-motion'; +import { PositionOrElement } from '../utils/positionUtils'; + +interface IFGuidanceRendererProps { + guidance: IFGuidance; + guidanceKey: string; + prevCursorPosition: { x: number; y: number } | null; + isAnimatingOut: boolean; + handleGuidanceEnd: () => void; + handleMultiClickComplete: () => void; + currentClickIndex: number; + executeClick: () => void; + dragIterationCount: number; + isDragAnimatingOut: boolean; + setDragIterationCount: React.Dispatch>; + setIsDragAnimatingOut: React.Dispatch>; +} + +const IFGuidanceRenderer: React.FC = ({ + guidance, + guidanceKey, + prevCursorPosition, + isAnimatingOut, + handleGuidanceEnd, + handleMultiClickComplete, + currentClickIndex, + executeClick, + dragIterationCount, + isDragAnimatingOut, + setDragIterationCount, + setIsDragAnimatingOut, +}) => { + const { nextGuidance } = useGuidance(); + const [conditionResult, setConditionResult] = React.useState( + null + ); + const [currentGuidanceToRender, setCurrentGuidanceToRender] = + React.useState(null); + + // Ref to track if we've set up the interval for function-based advanceMode + const functionAdvanceModeIntervalRef = React.useRef( + null + ); + + // Effect to evaluate the condition and set the guidance to render + useEffect(() => { + let isMounted = true; + + const setupInitialState = async () => { + try { + // Get initial result + const result = + typeof guidance.condition === 'function' + ? guidance.condition() + : guidance.condition; + + let finalResult: boolean; + + if (result instanceof Promise) { + finalResult = await result; + } else { + finalResult = !!result; + } + + if (!isMounted) return; + + // Set the condition result and the guidance to render + setConditionResult(finalResult); + setCurrentGuidanceToRender( + finalResult ? guidance.trueGuidance : guidance.falseGuidance + ); + + // Now handle the advanceCondition if it's a function + if (typeof guidance.advanceCondition === 'function') { + const advanceFn = guidance.advanceCondition; + + // If the function expects at least one argument, we treat it as + // the **callback** variant – invoke once and let it call + // `nextGuidance` (via handleGuidanceEnd) when ready. + if (advanceFn.length >= 1) { + (advanceFn as (next: () => void) => void)(() => { + // Ensure we don't create a new reference every call + handleGuidanceEnd(); + }); + return; + } + + // Otherwise treat it as the **predicate** variant that returns + // a boolean and should be polled until true. + + // Check once immediately + const checkCondition = async () => { + try { + const shouldAdvance = (advanceFn as () => boolean)(); + if (shouldAdvance) { + handleGuidanceEnd(); + return; + } + + // Set up interval to periodically check the predicate (every 500 ms) + functionAdvanceModeIntervalRef.current = setInterval(() => { + const shouldAdvanceInterval = (advanceFn as () => boolean)(); + if (shouldAdvanceInterval) { + handleGuidanceEnd(); + + if (functionAdvanceModeIntervalRef.current) { + clearInterval(functionAdvanceModeIntervalRef.current); + functionAdvanceModeIntervalRef.current = null; + } + } + }, 500); + } catch (error) { + console.error('Error in advanceCondition function:', error); + handleGuidanceEnd(); // Proceed to next guidance anyway + } + }; + + checkCondition(); + } else if ( + guidance.advanceCondition === 'auto' || + typeof guidance.advanceCondition === 'number' + ) { + // Handle auto advance or numeric delay + const delay = + typeof guidance.advanceCondition === 'number' + ? guidance.advanceCondition + : 2000; // Default 2 seconds for auto + + setTimeout(() => { + if (isMounted) { + handleGuidanceEnd(); + } + }, delay); + } + } catch (error) { + console.error('Error evaluating IF condition:', error); + if (isMounted) { + // In case of error, use the false guidance as fallback + setConditionResult(false); + setCurrentGuidanceToRender(guidance.falseGuidance); + } + } + }; + + setupInitialState(); + + // Clean up interval on unmount + return () => { + isMounted = false; + if (functionAdvanceModeIntervalRef.current) { + clearInterval(functionAdvanceModeIntervalRef.current); + functionAdvanceModeIntervalRef.current = null; + } + }; + }, [guidance, handleGuidanceEnd]); + + // Handler for cursor animation completion + const handleCursorAnimationComplete = useCallback( + (clicked: boolean) => { + // Use type guards for different guidance types + if (currentGuidanceToRender?.type === 'VIRTUAL_CLICK') { + const clickGuidance = currentGuidanceToRender as VirtualClickGuidance; + if ( + clickGuidance.advanceMode !== 'external' && + typeof clickGuidance.advanceMode !== 'function' + ) { + return handleGuidanceEnd(); + } + } + + // For VIRTUAL_DRAG with external advance mode, loop the animation + if (currentGuidanceToRender?.type === 'VIRTUAL_DRAG') { + // CARE -> it should default to clickable + if (clicked && currentGuidanceToRender.advanceMode !== 'external') { + return handleGuidanceEnd(); + } + + // Start fade-out animation + setIsDragAnimatingOut(true); + + // After fade-out completes, increment iteration and restart animation + setTimeout(() => { + setDragIterationCount((prev) => prev + 1); + setIsDragAnimatingOut(false); + }, 300); // Duration of fadeout animation + } + }, + [ + handleGuidanceEnd, + currentGuidanceToRender, + setDragIterationCount, + setIsDragAnimatingOut, + ] + ); + + // If we haven't evaluated the condition yet, don't render anything + if (conditionResult === null || !currentGuidanceToRender) { + return null; + } + + const renderGuidanceContent = () => { + if (!currentGuidanceToRender) return null; + + switch (currentGuidanceToRender.type) { + case 'CURSOR_TAKEOVER': + return ( + + ); + + case 'VIRTUAL_CLICK': { + // Determine the start position + const resolvedStartPosition: PositionOrElement | undefined = + currentGuidanceToRender.startPosition || + (prevCursorPosition ? prevCursorPosition : undefined); + + // Determine the advanceMode from the guidance + const rawAdvanceMode = currentGuidanceToRender.advanceMode; + type CursorAdvanceMode = + | 'auto' + | 'external' + | 'default' + | number + | (() => boolean); + + const advanceMode: CursorAdvanceMode = + typeof rawAdvanceMode === 'function' && + ((rawAdvanceMode as (...args: unknown[]) => unknown).length ?? 0) >= 1 + ? 'default' + : (rawAdvanceMode as CursorAdvanceMode) || 'default'; + + return ( + + + + ); + } + + case 'VIRTUAL_DRAG': { + // Determine the start position + const resolvedStartPosition: PositionOrElement | undefined = + currentGuidanceToRender.startPosition || + (prevCursorPosition ? prevCursorPosition : undefined); + + return ( + + + + ); + } + + case 'MULTI_VIRTUAL_CLICK': { + // Determine the current click guidance + const currentClickGuidance = + currentGuidanceToRender.guidances[currentClickIndex]; + + // Determine the start position based on the click index + let startPosition: PositionOrElement | undefined; + if (currentClickIndex === 0) { + // For the first click, use the previous cursor position or the specified start position + startPosition = + currentClickGuidance.startPosition || + (prevCursorPosition ? prevCursorPosition : undefined); + } else { + // For subsequent clicks, always use their specified start position if available, + // otherwise fallback to the end position of the previous click + startPosition = + currentClickGuidance.startPosition || + currentGuidanceToRender.guidances[currentClickIndex - 1] + .endPosition; + } + + // Use the same advanceMode calculation as for VIRTUAL_CLICK + const rawAdvanceModeMulti = currentClickGuidance.advanceMode; + type CursorAdvanceModeMulti = + | 'auto' + | 'external' + | 'default' + | number + | (() => boolean); + + const advanceMode: CursorAdvanceModeMulti = + typeof rawAdvanceModeMulti === 'function' && + ((rawAdvanceModeMulti as (...args: unknown[]) => unknown).length ?? + 0) >= 1 + ? 'default' + : (rawAdvanceModeMulti as CursorAdvanceModeMulti) || 'default'; + + return ( + + ); + } + + case 'VIRTUAL_TYPING': { + // Determine the start position + const typingStartPosition: PositionOrElement | undefined = + currentGuidanceToRender.startPosition || + (prevCursorPosition ? prevCursorPosition : undefined); + + return ( + { + if (typeof currentGuidanceToRender.advanceMode === 'function') { + return 'default'; + } + return ( + (currentGuidanceToRender.advanceMode as + | 'auto' + | 'external' + | 'default' + | number + | undefined) || 'default' + ); + })()} + onAnimationComplete={handleGuidanceEnd} + blocking={currentGuidanceToRender.blocking} + /> + ); + } + + case 'CHAT_TOOLTIP': { + // Find the chat button to position the tooltip + const chatButton = + document.querySelector('.CedarChatButton') || + document.querySelector('[data-cedar-chat-button]'); + const chatButtonRect = chatButton?.getBoundingClientRect(); + + if (!chatButtonRect) { + // If chat button not found, complete this guidance and go to next + setTimeout(handleGuidanceEnd, 100); + return null; + } + + // Calculate centered position above chat button + const tooltipPosition = { + left: chatButtonRect.left + chatButtonRect.width / 2, + top: chatButtonRect.top - 15, + }; + + return ( + + handleGuidanceEnd()} + /> + + ); + } + + case 'DIALOGUE': + return ( + boolean) => { + if ( + typeof currentGuidanceToRender.advanceMode === 'function' && + (( + currentGuidanceToRender.advanceMode as ( + ...args: unknown[] + ) => unknown + ).length ?? 0) >= 1 + ) { + return 'default'; + } + return ( + (currentGuidanceToRender.advanceMode as + | 'auto' + | 'external' + | 'default' + | number + | (() => boolean)) || 'default' + ); + })()} + blocking={currentGuidanceToRender.blocking} + onComplete={handleGuidanceEnd} + /> + ); + + case 'EXECUTE_CLICK': { + // Only render cursor animation if showCursor is true (default) or undefined + const showCursor = currentGuidanceToRender.showCursor !== false; + + if (showCursor) { + // Determine the start position + const resolvedStartPosition: PositionOrElement | undefined = + currentGuidanceToRender.startPosition || + (prevCursorPosition ? prevCursorPosition : undefined); + + return ( + + + + ); + } + + // No visual component needed as the executeClick will be triggered by the useEffect + return null; + } + + case 'TOAST': { + return ( + nextGuidance(currentGuidanceToRender.id)} + /> + ); + } + + case 'EXECUTE_TYPING': { + return ( + + ); + } + + case 'IDLE': + // IDLE guidances typically don't render anything visible + return null; + + default: + console.error( + 'Unknown guidance type in IF condition:', + currentGuidanceToRender + ); + return null; + } + }; + + return renderGuidanceContent(); +}; + +export default IFGuidanceRenderer; diff --git a/packages/cedar-os/src/components/guidance/components/ProgressPoints.tsx b/packages/cedar-os/src/components/guidance/components/ProgressPoints.tsx new file mode 100644 index 00000000..507935f0 --- /dev/null +++ b/packages/cedar-os/src/components/guidance/components/ProgressPoints.tsx @@ -0,0 +1,232 @@ +import React from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { useStyling } from '@/store/CedarStore'; + +export interface ProgressPointsProps { + /** + * Total number of steps in the sequence + */ + totalSteps: number; + + /** + * Current active step (1-based) + */ + currentStep: number; + + /** + * Whether to show the component + */ + visible?: boolean; + + /** + * Optional custom label for each step + */ + labels?: string[]; + + /** + * Optional callback when a step is clicked + */ + onStepClick?: (step: number) => void; +} + +const ProgressPoints: React.FC = ({ + totalSteps, + currentStep, + visible = true, + labels = [], + onStepClick, +}) => { + const { styling } = useStyling(); + const primaryColor = styling?.color || '#319B72'; + + // Create array of steps + const steps = Array.from({ length: totalSteps }, (_, i) => ({ + number: i + 1, + label: labels[i] || `Step ${i + 1}`, + status: + i + 1 < currentStep + ? 'completed' + : i + 1 === currentStep + ? 'active' + : 'pending', + })); + + // Handle navigation + const handlePrevious = () => { + if (currentStep > 1 && onStepClick) { + onStepClick(currentStep - 1); + } + }; + + const handleNext = () => { + if (currentStep < totalSteps && onStepClick) { + onStepClick(currentStep + 1); + } + }; + + // Determine if navigation arrows should be enabled + const canGoPrevious = currentStep > 1; + const canGoNext = currentStep < totalSteps; + + // Simplified animation variants + const containerVariants = { + hidden: { opacity: 0, y: 20 }, + visible: { + opacity: 1, + y: 0, + transition: { + duration: 0.3, + ease: 'easeOut', + }, + }, + exit: { + opacity: 0, + y: 20, + transition: { + duration: 0.2, + ease: 'easeIn', + }, + }, + }; + + return ( + + {visible && ( +
+ +
+ {/* Previous arrow */} + + + + + + + {/* Steps */} +
+ {steps.map((step) => ( + onStepClick?.(step.number)}> + {/* Glow effect for active step */} + {step.status === 'active' && ( + + )} + + {/* Number indicator for completed steps */} + {step.status === 'completed' && ( + + ✓ + + )} + + + {step.label} + + + ))} +
+ + {/* Next arrow */} + + + + + +
+
+
+ )} +
+ ); +}; + +export default ProgressPoints; diff --git a/packages/cedar-os/src/components/guidance/components/RightClickIndicator.tsx b/packages/cedar-os/src/components/guidance/components/RightClickIndicator.tsx new file mode 100644 index 00000000..af0314a8 --- /dev/null +++ b/packages/cedar-os/src/components/guidance/components/RightClickIndicator.tsx @@ -0,0 +1,77 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; + +interface RightClickIndicatorProps { + /** automatically hide after milliseconds (optional) */ + duration?: number; + onComplete?: () => void; +} + +const OFFSET = { x: 12, y: -12 }; + +const RightClickIndicator: React.FC = ({ + duration, + onComplete, +}) => { + const [pos, setPos] = useState<{ x: number; y: number }>({ + x: -9999, + y: -9999, + }); + + // Track mouse position + useEffect(() => { + const handleMove = (e: MouseEvent) => { + setPos({ x: e.clientX + OFFSET.x, y: e.clientY + OFFSET.y }); + }; + window.addEventListener('mousemove', handleMove); + return () => window.removeEventListener('mousemove', handleMove); + }, []); + + // Auto-complete after duration + useEffect(() => { + if (!duration) return; + const t = setTimeout(() => onComplete?.(), duration); + return () => clearTimeout(t); + }, [duration, onComplete]); + + return ( +
+
+ {/* Mouse icon */} + + {/* outer shape */} + + {/* middle divider */} + + {/* right button highlight */} + + + Back +
+
+ ); +}; + +export default RightClickIndicator; diff --git a/packages/cedar-os/src/components/guidance/components/ToastCard.tsx b/packages/cedar-os/src/components/guidance/components/ToastCard.tsx new file mode 100644 index 00000000..dc0f6ea6 --- /dev/null +++ b/packages/cedar-os/src/components/guidance/components/ToastCard.tsx @@ -0,0 +1,465 @@ +'use client'; + +import { cn } from '@/styles/stylingUtils'; +import { Button } from '@/components/guidance/components/button'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { AnimatePresence, motion } from 'framer-motion'; +import { + AlertCircle, + ArrowRight, + Check, + CheckCircle2, + ExternalLink, + Info, + X, +} from 'lucide-react'; +import React, { useEffect, useRef, useState } from 'react'; + +// Define position variants using cva +const positionVariants = cva('fixed z-[100]', { + variants: { + position: { + 'bottom-right': 'bottom-4 right-4', + 'bottom-left': 'bottom-4 left-4', + bottom: 'bottom-4 left-1/2 -translate-x-1/2', + top: 'top-4 left-1/2 -translate-x-1/2', + }, + }, + defaultVariants: { + position: 'bottom-right', + }, +}); + +// Define animation variants for each position +const animationVariants = { + 'bottom-right': { + initial: { opacity: 0, x: 20, y: 0, scale: 0.95 }, + animate: { opacity: 1, x: 0, y: 0, scale: 1 }, + exit: { opacity: 0, x: 20, y: 0, scale: 0.95 }, + }, + 'bottom-left': { + initial: { opacity: 0, x: -20, y: 0, scale: 0.95 }, + animate: { opacity: 1, x: 0, y: 0, scale: 1 }, + exit: { opacity: 0, x: -20, y: 0, scale: 0.95 }, + }, + bottom: { + initial: { opacity: 0, y: 20, scale: 0.95 }, + animate: { opacity: 1, y: 0, scale: 1 }, + exit: { opacity: 0, y: 20, scale: 0.95 }, + }, + top: { + initial: { opacity: 0, y: -20, scale: 0.95 }, + animate: { opacity: 1, y: 0, scale: 1 }, + exit: { opacity: 0, y: -20, scale: 0.95 }, + }, +}; + +// Define CSS keyframes for the progress bar animation +const progressKeyframes = ` +@keyframes progress-animation { + from { + width: 100%; + } + to { + width: 0%; + } +} + +@keyframes glow-pulse { + 0% { + filter: brightness(1.05); + box-shadow: 0 0 6px 1px var(--glow-color), 0 0 2px 0px var(--glow-color); + } + 50% { + filter: brightness(1.1); + box-shadow: 0 0 8px 2px var(--glow-color), 0 0 4px 0px var(--glow-color); + } + 100% { + filter: brightness(1.05); + box-shadow: 0 0 6px 1px var(--glow-color), 0 0 2px 0px var(--glow-color); + } +}`; + +export interface ToastCardProps extends VariantProps { + title?: string; + description?: string; + variant?: 'default' | 'destructive' | 'success' | 'warning' | 'info'; + className?: string; + onClose?: () => void; + duration?: number; + toastMode?: boolean; + action?: { + icon?: 'link' | 'action' | 'check'; // Custom icon to display in the action button + label: string; + onClick?: () => void; + }; +} + +const ToastCard = React.forwardRef( + ( + { + title, + description, + position = 'bottom-right', + variant = 'default', + className, + onClose, + duration = 5000, + toastMode = false, + action, + }, + ref + ) => { + const [isVisible, setIsVisible] = useState(true); + const timeoutRef = useRef(null); + + // Get the animation variant based on position + const animation = animationVariants[position ?? 'bottom-right']; + + // Handle close with animation + const handleClose = () => { + setIsVisible(false); + // Wait for animation to complete before calling onClose + setTimeout(() => { + onClose?.(); + }, 300); // Match the duration of the exit animation + }; + + // Auto-dismiss after duration if specified + useEffect(() => { + if (duration) { + const fadeOutDuration = 300; // Animation duration for fade out + + // Set timeout to hide toast after duration + timeoutRef.current = setTimeout(() => { + setIsVisible(false); + // After fadeout completes, call onClose + setTimeout(() => { + onClose?.(); + }, fadeOutDuration); + }, duration - fadeOutDuration); + + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + } + }, [duration, onClose]); + + // Get icon based on variant + const getIcon = () => { + switch (variant) { + case 'success': + return ( +