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 */}
+
+ {text}
+
+
+ {/* Visible animated text positioned absolutely within the sized container */}
+
+
+ {/* Left jutting part of toast */}
+
+
+ {/* Right jutting part of toast */}
+
+
+ {/* Dotted texture pattern for bottom edge */}
+
+
+ {/* Additional dotted texture for right side */}
+
+
+ {/* Content */}
+
+ {onClose && (
+
+ )}
+
+ {title && (
+
+ {title}
+
+ )}
+ {description && (
+
+ {description}
+
+ )}
+
+ {action && (
+
+
+
+ )}
+
+
+
+ ) : (
+
+ {/* Top content */}
+
+
+ {/* Status icon column - vertically centered with title and description only */}
+ {getIcon() && (
+
+ {getIcon()}
+ {action && (
+
+
+
+ )}
+
+ )}
+
+ {/* Content column */}
+
+ {/* Text content container */}
+
+ {/* Title and description group - icon will align with this section */}
+
+ {title && (
+
+ {title}
+
+ )}
+ {description && (
+
+ {description}
+
+ )}
+
+
+ {/* Action button - kept separate from the text alignment */}
+ {action && (
+