The response has been limited to 50k tokens of the smallest files in the repo. You can remove this limitation by removing the max tokens filter.
├── .gitignore
├── .idea
    ├── .gitignore
    ├── eyesite.iml
    ├── material_theme_project_new.xml
    ├── modules.xml
    └── vcs.xml
├── README.md
├── eslint.config.mjs
├── jsconfig.json
├── next.config.mjs
├── package-lock.json
├── package.json
├── postcss.config.mjs
├── public
    ├── calibration.jpg
    ├── gazedebug.jpg
    ├── gazepage.jpg
    ├── inandout.gif
    ├── jitter.gif
    └── thumbnail.jpg
├── src
    ├── app
    │   ├── favicon.ico
    │   ├── globals.css
    │   ├── layout.js
    │   └── page.js
    └── components
    │   ├── Blog.js
    │   ├── ClickableSquare.js
    │   ├── GazeClickWrapper.js
    │   ├── GazeWrapper.js
    │   ├── LandingScreen.js
    │   ├── WrappedClickableSquare.js
    │   ├── WrappedSquare.js
    │   ├── calibrate.js
    │   ├── gaze.js
    │   ├── screenTooSmallWindow.js
    │   ├── seeableTexts
    │       ├── blogButton.js
    │       ├── lookAndClick.js
    │       └── playAGame.js
    │   ├── square.js
    │   ├── useGazeClick.js
    │   ├── useGazeHover.js
    │   └── webgazerProvider.js
└── tailwind.config.mjs


/.gitignore:
--------------------------------------------------------------------------------
 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
 2 | 
 3 | # dependencies
 4 | /node_modules
 5 | /.pnp
 6 | .pnp.*
 7 | .yarn/*
 8 | !.yarn/patches
 9 | !.yarn/plugins
10 | !.yarn/releases
11 | !.yarn/versions
12 | 
13 | # testing
14 | /coverage
15 | 
16 | # next.js
17 | /.next/
18 | /out/
19 | 
20 | # production
21 | /build
22 | 
23 | # misc
24 | .DS_Store
25 | *.pem
26 | 
27 | # debug
28 | npm-debug.log*
29 | yarn-debug.log*
30 | yarn-error.log*
31 | .pnpm-debug.log*
32 | 
33 | # env files (can opt-in for committing if needed)
34 | .env*
35 | 
36 | # vercel
37 | .vercel
38 | 
39 | # typescript
40 | *.tsbuildinfo
41 | next-env.d.ts
42 | 


--------------------------------------------------------------------------------
/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # Default ignored files
2 | /shelf/
3 | /workspace.xml
4 | # Editor-based HTTP Client requests
5 | /httpRequests/
6 | 


--------------------------------------------------------------------------------
/.idea/eyesite.iml:
--------------------------------------------------------------------------------
 1 | <?xml version="1.0" encoding="UTF-8"?>
 2 | <module type="WEB_MODULE" version="4">
 3 |   <component name="NewModuleRootManager">
 4 |     <content url="file://$MODULE_DIR
quot;>
 5 |       <excludeFolder url="file://$MODULE_DIR$/.tmp" />
 6 |       <excludeFolder url="file://$MODULE_DIR$/temp" />
 7 |       <excludeFolder url="file://$MODULE_DIR$/tmp" />
 8 |     </content>
 9 |     <orderEntry type="inheritedJdk" />
10 |     <orderEntry type="sourceFolder" forTests="false" />
11 |   </component>
12 | </module>


--------------------------------------------------------------------------------
/.idea/material_theme_project_new.xml:
--------------------------------------------------------------------------------
 1 | <?xml version="1.0" encoding="UTF-8"?>
 2 | <project version="4">
 3 |   <component name="MaterialThemeProjectNewConfig">
 4 |     <option name="metadata">
 5 |       <MTProjectMetadataState>
 6 |         <option name="migrated" value="true" />
 7 |         <option name="pristineConfig" value="false" />
 8 |         <option name="userId" value="-3d87ee0d:184b24f8319:-8000" />
 9 |         <option name="version" value="6.16.2" />
10 |       </MTProjectMetadataState>
11 |     </option>
12 |   </component>
13 | </project>


--------------------------------------------------------------------------------
/.idea/modules.xml:
--------------------------------------------------------------------------------
1 | <?xml version="1.0" encoding="UTF-8"?>
2 | <project version="4">
3 |   <component name="ProjectModuleManager">
4 |     <modules>
5 |       <module fileurl="file://$PROJECT_DIR$/.idea/eyesite.iml" filepath="$PROJECT_DIR$/.idea/eyesite.iml" />
6 |     </modules>
7 |   </component>
8 | </project>


--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 | <?xml version="1.0" encoding="UTF-8"?>
2 | <project version="4">
3 |   <component name="VcsDirectoryMappings">
4 |     <mapping directory="" vcs="Git" />
5 |   </component>
6 | </project>


--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
 1 | # Eyesite
 2 | 
 3 | An experimental website combining computer vision and web design.
 4 | 
 5 | This project primarily uses [webgazer](https://webgazer.cs.brown.edu/) for eye tracking.
 6 | 
 7 | - Control your cursor with your eyes
 8 | - Press space to click
 9 | - Press D to debug
10 | - Press R to recalibrate
11 | 
12 | ## Running the project localy
13 | 1. Clone the project
14 | 2. Install dependencies with `npm install`
15 | 3. Run next dev with `npm run dev`


--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
 1 | import { dirname } from "path";
 2 | import { fileURLToPath } from "url";
 3 | import { FlatCompat } from "@eslint/eslintrc";
 4 | 
 5 | const __filename = fileURLToPath(import.meta.url);
 6 | const __dirname = dirname(__filename);
 7 | 
 8 | const compat = new FlatCompat({
 9 |   baseDirectory: __dirname,
10 | });
11 | 
12 | const eslintConfig = [...compat.extends("next/core-web-vitals")];
13 | 
14 | export default eslintConfig;
15 | 


--------------------------------------------------------------------------------
/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 |   "compilerOptions": {
3 |     "paths": {
4 |       "@/*": ["./src/*"]
5 |     }
6 |   }
7 | }
8 | 


--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {};
3 | 
4 | export default nextConfig;
5 | 


--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "name": "eyesite",
 3 |   "version": "0.1.0",
 4 |   "private": true,
 5 |   "scripts": {
 6 |     "dev": "next dev --turbopack",
 7 |     "build": "next build",
 8 |     "start": "next start",
 9 |     "lint": "next lint"
10 |   },
11 |   "dependencies": {
12 |     "@headlessui/react": "^2.2.4",
13 |     "next": "15.1.8",
14 |     "react": "^19.0.0",
15 |     "react-dom": "^19.0.0",
16 |     "webgazer": "^3.3.0"
17 |   },
18 |   "devDependencies": {
19 |     "@eslint/eslintrc": "^3",
20 |     "eslint": "^9",
21 |     "eslint-config-next": "15.1.8",
22 |     "postcss": "^8",
23 |     "tailwindcss": "^3.4.1"
24 |   }
25 | }
26 | 


--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 |   plugins: {
4 |     tailwindcss: {},
5 |   },
6 | };
7 | 
8 | export default config;
9 | 


--------------------------------------------------------------------------------
/public/calibration.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akchro/eyesite/3ee7ba304350580a26c517b94d8d68333d6fec7c/public/calibration.jpg


--------------------------------------------------------------------------------
/public/gazedebug.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akchro/eyesite/3ee7ba304350580a26c517b94d8d68333d6fec7c/public/gazedebug.jpg


--------------------------------------------------------------------------------
/public/gazepage.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akchro/eyesite/3ee7ba304350580a26c517b94d8d68333d6fec7c/public/gazepage.jpg


--------------------------------------------------------------------------------
/public/inandout.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akchro/eyesite/3ee7ba304350580a26c517b94d8d68333d6fec7c/public/inandout.gif


--------------------------------------------------------------------------------
/public/jitter.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akchro/eyesite/3ee7ba304350580a26c517b94d8d68333d6fec7c/public/jitter.gif


--------------------------------------------------------------------------------
/public/thumbnail.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akchro/eyesite/3ee7ba304350580a26c517b94d8d68333d6fec7c/public/thumbnail.jpg


--------------------------------------------------------------------------------
/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akchro/eyesite/3ee7ba304350580a26c517b94d8d68333d6fec7c/src/app/favicon.ico


--------------------------------------------------------------------------------
/src/app/globals.css:
--------------------------------------------------------------------------------
  1 | @tailwind base;
  2 | @tailwind components;
  3 | @tailwind utilities;
  4 | 
  5 | :root {
  6 |   --background: #ffffff;
  7 |   --foreground: #171717;
  8 | }
  9 | 
 10 | @media (prefers-color-scheme: dark) {
 11 |   :root {
 12 |     --background: #0a0a0a;
 13 |     --foreground: #ededed;
 14 |   }
 15 | }
 16 | 
 17 | body {
 18 |   color: var(--foreground);
 19 |   background: var(--background);
 20 |   font-family: Arial, Helvetica, sans-serif;
 21 | }
 22 | 
 23 | /*!* WebGazer styles *!*/
 24 | #webgazerVideoContainer {
 25 |   display: block !important;
 26 |   position: fixed !important;
 27 |   top: 20px !important;
 28 |   left: 0px !important;
 29 |   width: 320px !important;
 30 |   height: 240px !important;
 31 |   z-index: 100;
 32 | }
 33 | 
 34 | 
 35 | 
 36 | /* Calibration point pulse animation */
 37 | @keyframes pulse {
 38 |   0% {
 39 |     transform: scale(0.95);
 40 |     box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.7);
 41 |   }
 42 |   
 43 |   70% {
 44 |     transform: scale(1);
 45 |     box-shadow: 0 0 0 10px rgba(59, 130, 246, 0);
 46 |   }
 47 |   
 48 |   100% {
 49 |     transform: scale(0.95);
 50 |     box-shadow: 0 0 0 0 rgba(59, 130, 246, 0);
 51 |   }
 52 | }
 53 | 
 54 | .calibration-point:not(.calibrated) {
 55 |   animation: pulse 2s infinite;
 56 | }
 57 | 
 58 | /* Gaze interaction styles */
 59 | .gaze-target {
 60 |   transition: all 0.3s ease;
 61 |   cursor: pointer;
 62 | }
 63 | 
 64 | .gaze-target:hover,
 65 | .gaze-target[data-gaze-hovered="true"] {
 66 |   transform: scale(1.05);
 67 |   box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
 68 | }
 69 | 
 70 | /* Gaze indicator dot */
 71 | .gaze-dot {
 72 |   position: fixed;
 73 |   width: 10px;
 74 |   height: 10px;
 75 |   background: rgba(255, 0, 0, 0.7);
 76 |   border-radius: 50%;
 77 |   pointer-events: none;
 78 |   z-index: 9999;
 79 |   transform: translate(-50%, -50%);
 80 | }
 81 | 
 82 | /* Gaze click flash effects */
 83 | @keyframes gazeClickFlash {
 84 |   0% {
 85 |     background-color: rgb(250, 204, 21); /* yellow-400 */
 86 |     transform: scale(1.1);
 87 |     box-shadow: 0 0 20px rgba(250, 204, 21, 0.6);
 88 |   }
 89 |   50% {
 90 |     background-color: rgb(251, 191, 36); /* yellow-300 */
 91 |     transform: scale(1.15);
 92 |     box-shadow: 0 0 30px rgba(251, 191, 36, 0.8);
 93 |   }
 94 |   100% {
 95 |     transform: scale(1);
 96 |     box-shadow: none;
 97 |   }
 98 | }
 99 | 
100 | .gaze-click-flash {
101 |   animation: gazeClickFlash 0.3s ease-out;
102 | }
103 | 
104 | /* Gaze hover state */
105 | [data-gaze-hovered="true"] {
106 |   border-color: rgb(239, 68, 68) !important; /* red-500 */
107 |   box-shadow: 0 0 10px rgba(239, 68, 68, 0.3);
108 | }
109 | 
110 | /* Spacebar instruction */
111 | .spacebar-instruction {
112 |   background: linear-gradient(45deg, #3b82f6, #1d4ed8);
113 |   color: white;
114 |   padding: 4px 8px;
115 |   border-radius: 4px;
116 |   font-size: 12px;
117 |   animation: pulse 2s infinite;
118 | }
119 | 
120 | /* Debug mode styles */
121 | .debug-mode-indicator {
122 |   background: linear-gradient(45deg, #d97706, #f59e0b);
123 |   color: white;
124 |   padding: 4px 8px;
125 |   border-radius: 4px;
126 |   font-weight: bold;
127 |   font-size: 12px;
128 |   animation: debugPulse 1.5s infinite;
129 |   box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
130 | }
131 | 
132 | @keyframes debugPulse {
133 |   0%, 100% {
134 |     opacity: 1;
135 |     transform: scale(1);
136 |   }
137 |   50% {
138 |     opacity: 0.8;
139 |     transform: scale(1.05);
140 |   }
141 | }
142 | 
143 | /* Debug mode overlay when camera is visible */
144 | .debug-camera-overlay {
145 |   position: fixed;
146 |   top: 0;
147 |   left: 200px;
148 |   background: rgba(217, 119, 6, 0.9);
149 |   color: white;
150 |   padding: 2px 6px;
151 |   font-size: 10px;
152 |   font-weight: bold;
153 |   z-index: 1004;
154 |   border-radius: 0 0 4px 0;
155 | }
156 | 


--------------------------------------------------------------------------------
/src/app/layout.js:
--------------------------------------------------------------------------------
 1 | import { Geist, Geist_Mono, Red_Hat_Text, Cormorant_Garamond } from "next/font/google";
 2 | import {WebgazerProvider} from "@/components/webgazerProvider";
 3 | import LandingScreen from "@/components/LandingScreen";
 4 | import "./globals.css";
 5 | 
 6 | const geistSans = Geist({
 7 |   variable: "--font-geist-sans",
 8 |   subsets: ["latin"],
 9 | });
10 | 
11 | const geistMono = Geist_Mono({
12 |   variable: "--font-geist-mono",
13 |   subsets: ["latin"],
14 | });
15 | 
16 | const redHatSans = Red_Hat_Text({
17 |     variable: "--font-red-hat",
18 |     subsets: ["latin"]
19 | })
20 | 
21 | const cormorantGaramond = Cormorant_Garamond({
22 |     variable: "--font-cormorant-garamond",
23 |     weight: ["300", "400", "500", "600", "700"],
24 |     subsets: ["latin"],
25 | });
26 | 
27 | export const metadata = {
28 |   title: "Eyesite",
29 |   description: "Eye tracking cursor experimental",
30 | };
31 | 
32 | export default function RootLayout({ children }) {
33 |   return (
34 |     <html lang="en">
35 |       <body
36 |         className={`${redHatSans.variable} ${geistMono.variable} ${cormorantGaramond.variable} max-h-screen max-w-screen overflow-hidden antialiased`}
37 |       >
38 |           <WebgazerProvider>
39 |               <LandingScreen>
40 |                   {children}
41 |               </LandingScreen>
42 |           </WebgazerProvider>
43 |       </body>
44 |     </html>
45 |   );
46 | }
47 | 


--------------------------------------------------------------------------------
/src/app/page.js:
--------------------------------------------------------------------------------
 1 | import Image from "next/image";
 2 | import Gaze from "@/components/gaze";
 3 | 
 4 | 
 5 | export const metadata = {
 6 |   title: "eyesite",
 7 |   description: "Check my stuff out.",
 8 |   openGraph: {
 9 |     type: "website",
10 |     url: "https://eyesite.andykhau.com/",
11 |     title: "eyesite",
12 |     description: "An experimental website combining computer vision and web design.",
13 |     images: [
14 |       {
15 |         url: "/thumbnail.jpg",
16 |         width: 1200,
17 |         height: 630,
18 |         alt: "eyesite",
19 |       },
20 |     ],
21 |   },
22 |   twitter: {
23 |     card: "summary_large_image",
24 |     title: "eyesite",
25 |     description: "An experimental website combining computer vision and web design.",
26 |     images: ["/thumbnail.jpg"],
27 |   },
28 | };
29 | 
30 | export default function Home() {
31 |   return (
32 |       <Gaze />
33 |   );
34 | }
35 | 


--------------------------------------------------------------------------------
/src/components/Blog.js:
--------------------------------------------------------------------------------
  1 | 'use client';
  2 | 
  3 | import { useRef, useState, useEffect, useCallback } from 'react';
  4 | import { useWebGazer } from './webgazerProvider';
  5 | 
  6 | const Blog = ({ debugMode, onExit }) => {
  7 |     const blogContentRef = useRef(null);
  8 |     const { currentGaze, isReady, addGazeListener } = useWebGazer();
  9 |     const [scrollSpeed, setScrollSpeed] = useState(0);
 10 |     const [scrollProgress, setScrollProgress] = useState(0);
 11 |     const scrollIntervalRef = useRef(null);
 12 | 
 13 |     // Handle gaze-based scrolling
 14 |     const checkGazePosition = useCallback((data) => {
 15 |         if (!data || !blogContentRef.current) {
 16 |             setScrollSpeed(0);
 17 |             return;
 18 |         }
 19 | 
 20 |         const { y } = data;
 21 |         const windowHeight = window.innerHeight;
 22 |         const bottomThreshold = windowHeight * 0.75; // Bottom 25% of screen
 23 |         const topThreshold = windowHeight * 0.15; // Top 15% of screen
 24 | 
 25 |         if (y >= bottomThreshold) {
 26 |             // Looking at bottom - scroll down
 27 |             setScrollSpeed(10);
 28 |         } else if (y <= topThreshold) {
 29 |             // Looking at top - scroll up
 30 |             setScrollSpeed(-10);
 31 |         } else {
 32 |             // Looking at middle - stop scrolling
 33 |             setScrollSpeed(0);
 34 |         }
 35 |     }, []);
 36 | 
 37 |     // Handle scroll progress tracking
 38 |     const handleScroll = useCallback(() => {
 39 |         if (!blogContentRef.current) return;
 40 | 
 41 |         const { scrollTop, scrollHeight, clientHeight } = blogContentRef.current;
 42 |         const maxScroll = scrollHeight - clientHeight;
 43 |         const progress = maxScroll > 0 ? (scrollTop / maxScroll) * 100 : 0;
 44 |         setScrollProgress(Math.min(100, Math.max(0, progress)));
 45 |     }, []);
 46 | 
 47 |     // Handle spacebar press for exit
 48 |     const handleKeyPress = useCallback((event) => {
 49 |         if (event.code === 'Space') {
 50 |             event.preventDefault();
 51 |             onExit();
 52 |         }
 53 |     }, [onExit]);
 54 | 
 55 |     // Set up gaze listener
 56 |     useEffect(() => {
 57 |         if (!isReady || !addGazeListener) return;
 58 | 
 59 |         const removeListener = addGazeListener(checkGazePosition);
 60 |         return removeListener;
 61 |     }, [isReady, addGazeListener, checkGazePosition]);
 62 | 
 63 |     // Set up keyboard listener
 64 |     useEffect(() => {
 65 |         window.addEventListener('keydown', handleKeyPress);
 66 |         return () => {
 67 |             window.removeEventListener('keydown', handleKeyPress);
 68 |         };
 69 |     }, [handleKeyPress]);
 70 | 
 71 |     // Set up scroll listener
 72 |     useEffect(() => {
 73 |         const blogElement = blogContentRef.current;
 74 |         if (!blogElement) return;
 75 | 
 76 |         blogElement.addEventListener('scroll', handleScroll);
 77 |         // Initial calculation
 78 |         handleScroll();
 79 | 
 80 |         return () => {
 81 |             blogElement.removeEventListener('scroll', handleScroll);
 82 |         };
 83 |     }, [handleScroll]);
 84 | 
 85 |     // Handle continuous scrolling
 86 |     useEffect(() => {
 87 |         if (scrollSpeed !== 0) {
 88 |             scrollIntervalRef.current = setInterval(() => {
 89 |                 if (blogContentRef.current) {
 90 |                     blogContentRef.current.scrollTop += scrollSpeed;
 91 |                 }
 92 |             }, 16); // ~60fps
 93 |         } else {
 94 |             if (scrollIntervalRef.current) {
 95 |                 clearInterval(scrollIntervalRef.current);
 96 |                 scrollIntervalRef.current = null;
 97 |             }
 98 |         }
 99 | 
100 |         return () => {
101 |             if (scrollIntervalRef.current) {
102 |                 clearInterval(scrollIntervalRef.current);
103 |             }
104 |         };
105 |     }, [scrollSpeed]);
106 | 
107 |     return (
108 |         <div className="fixed inset-0 bg-gray-950 z-50 flex flex-col">
109 |             {/* Custom Scroll Progress Bar */}
110 |             <div className="fixed right-6 top-1/2 transform -translate-y-1/2 z-10">
111 |                 <div className="relative">
112 |                     {/* Background track */}
113 |                     <div className="w-1 h-80 bg-gray-700/50 rounded-full"></div>
114 |                     {/* Progress fill */}
115 |                     <div 
116 |                         className="absolute top-0 left-0 w-1 bg-gradient-to-b from-blue-400 to-blue-600 rounded-full transition-all duration-200 ease-out"
117 |                         style={{ height: `${(scrollProgress / 100) * 320}px` }}
118 |                     ></div>
119 |                     {/* Progress indicator dot */}
120 |                     <div 
121 |                         className="absolute left-1/2 transform -translate-x-1/2 w-3 h-3 bg-blue-400 rounded-full border-2 border-gray-950 transition-all duration-200 ease-out"
122 |                         style={{ 
123 |                             top: `${(scrollProgress / 100) * 308}px`, // 320px - 12px for dot size
124 |                             opacity: scrollProgress > 0 ? 1 : 0.5
125 |                         }}
126 |                     ></div>
127 |                 </div>
128 |             </div>
129 | 
130 |             {/* Blog Content */}
131 |             <hr className={`border absolute top-[15%] w-screen ${debugMode ? '' : 'hidden'}`} />
132 |             <hr className={`border absolute bottom-1/4 w-screen ${debugMode ? '' : 'hidden'}`} />
133 |             <div
134 |                 ref={blogContentRef}
135 |                 className="flex-1 overflow-y-auto px-8 py-12 max-w-4xl mx-auto"
136 |                 style={{ scrollBehavior: 'auto' }}
137 |             >
138 |                 <div className="text-white space-y-8">
139 |                     <h1 className="text-6xl font-cor-gar text-center mb-12">Blog</h1>
140 |                     <blockquote className="border-l-4 border-blue-400 pl-6 py-4 mb-12 bg-gray-800/30 rounded-r-lg">
141 |                         <p className="text-lg font-red-hat leading-relaxed text-gray-300 italic">
142 |                             This is just a showcase of gaze reading. To read the blog in a more convenient way, check out{' '}
143 |                             <a
144 |                                 href="https://blog.andykhau.com/blog/eyesite"
145 |                                 className="text-blue-400 hover:text-blue-300 underline not-italic font-medium"
146 |                                 target="_blank"
147 |                                 rel="noopener noreferrer"
148 |                             >
149 |                                 blog.andykhau.com/blog/eyesite
150 |                             </a>
151 |                         </p>
152 |                     </blockquote>
153 |                     {/* Real Blog Content */}
154 |                     <article className="space-y-6">
155 |                         <p className="text-lg font-red-hat leading-relaxed">
156 |                             I wanted Apple Vision Pros, but I don't have $3,500 in my back pocket. So I made Apple Vision Pros at home.
157 |                         </p>
158 |                         
159 |                         <p className="text-lg font-red-hat leading-relaxed">
160 |                             I was interested in making a project that combined computer vision with web design—a website that users could <em>physically</em> interact with. This inspired me to make{' '}
161 |                             <a href="https://eyesite.andykhau.com/" className="text-blue-400 hover:text-blue-300 underline">
162 |                                 Eyesite
163 |                             </a>, because who needs a mouse when you have your eyes?
164 |                         </p>
165 | 
166 |                         <h2 className="text-4xl font-cor-gar mt-12 mb-6">Eye tracking</h2>
167 |                         
168 |                         <p className="text-lg font-red-hat leading-relaxed">
169 |                             Luckily, there is already a Javascript library for eye tracking called{' '}
170 |                             <a href="https://webgazer.cs.brown.edu/" className="text-blue-400 hover:text-blue-300 underline">
171 |                                 WebGazer.js
172 |                             </a>. We can achieve decent eye tracking through calibration:
173 |                         </p>
174 |                         
175 |                         <ol className="text-lg font-red-hat leading-relaxed ml-6 space-y-2">
176 |                             <li>1. Make the user look at a point and click. This maps the current gaze to a point on the screen.</li>
177 |                             <li>2. Feed the gaze/coordinate mapping into WebGazer to calibrate.</li>
178 |                             <li>3. Repeat 9x times on the corners, sides, and center to get good mapping data.</li>
179 |                         </ol>
180 |                         
181 |                         <p className="text-lg font-red-hat leading-relaxed">
182 |                             I found that it was best to get 5 mappings per point for better eye tracking accuracy.
183 |                         </p>
184 | 
185 |                         <div className="my-8">
186 |                             <img 
187 |                                 src="/calibration.jpg" 
188 |                                 alt="Calibration screen in debug mode" 
189 |                                 className="w-full max-w-2xl mx-auto rounded-lg shadow-lg"
190 |                             />
191 |                             <p className="text-sm font-red-hat text-gray-400 text-center mt-2 italic">
192 |                                 Calibration in debug mode. The top right shows how WebGazer tracks your eyes and face. The red dot is where it thinks I'm looking.
193 |                             </p>
194 |                         </div>
195 | 
196 |                         <h2 className="text-4xl font-cor-gar mt-12 mb-6">Website Interaction</h2>
197 |                         
198 |                         <p className="text-lg font-red-hat leading-relaxed">
199 |                             Now that we have eye tracking, we can make some cool things with it! I decided to use the user's gaze as a mouse and have them click with spacebar—kind of like how Apple Vision Pros have you look and pinch. Although I had the main functionality, it was far from finished. There were many considerations with making the experience as smooth and immersive as possible.
200 |                         </p>
201 | 
202 |                         <div className="my-8">
203 |                             <img 
204 |                                 src="/gazepage.jpg" 
205 |                                 alt="Main page" 
206 |                                 className="w-full max-w-2xl mx-auto rounded-lg shadow-lg"
207 |                             />
208 |                         </div>
209 | 
210 |                         <h2 className="text-4xl font-cor-gar mt-12 mb-6">The "Invisible" Mouse</h2>
211 |                         
212 |                         <p className="text-lg font-red-hat leading-relaxed">
213 |                             Initially, the user could see "where" they were looking at through a red dot.
214 |                         </p>
215 | 
216 |                         <div className="my-8">
217 |                             <img 
218 |                                 src="/gazedebug.jpg" 
219 |                                 alt="Gaze page in debug mode" 
220 |                                 className="w-full max-w-2xl mx-auto rounded-lg shadow-lg"
221 |                             />
222 |                             <p className="text-sm font-red-hat text-gray-400 text-center mt-2 italic">
223 |                                 Main page in debug mode.
224 |                             </p>
225 |                         </div>
226 |                         
227 |                         <p className="text-lg font-red-hat leading-relaxed">
228 |                             This created some problems. First, the red dot was distracting, and users would unconsciously look at it instead of my buttons. Second, the red dot revealed how inaccurate the eye tracking was, which ruined the immersion.
229 |                         </p>
230 |                         
231 |                         <p className="text-lg font-red-hat leading-relaxed">
232 |                             Ultimately, I decided to remove the "eye cursor" and also make the user's mouse invisible. It made you really feel like you were controlling the website with your eyes rather than moving a mouse around. You can turn on debug mode to see your eye cursor and mouse.
233 |                         </p>
234 | 
235 |                         <h2 className="text-4xl font-cor-gar mt-12 mb-6">User feedback</h2>
236 |                         
237 |                         <p className="text-lg font-red-hat leading-relaxed">
238 |                             Since we don't have a mouse, we need some way for the user to know they are looking at something. To do this… we track the user's gaze (how surprising). We hid the eye cursor, but we still have the x and y coordinates of the user's gaze. Each button component has checks to see if that gaze is within its borders. When the component detects the user is looking at it, it responds with a slight glow and pop.
239 |                         </p>
240 | 
241 |                         <div className="my-8">
242 |                             <img 
243 |                                 src="/inandout.gif" 
244 |                                 alt="Eye cursor going in and out" 
245 |                                 className="w-full max-w-2xl mx-auto rounded-lg shadow-lg"
246 |                             />
247 |                         </div>
248 | 
249 |                         <h2 className="text-4xl font-cor-gar mt-12 mb-6">Large UI</h2>
250 |                         
251 |                         <p className="text-lg font-red-hat leading-relaxed">
252 |                             Admittedly, the eye tracking is not the best. You can really see how jittery it is with debug mode on. So I decided to make the UI huge. I also added a screen size restriction so the site is only usable on displays that meet a minimum size threshold (Sorry mobile users! It wouldn't work on your phone anyway).
253 |                         </p>
254 | 
255 |                         <div className="my-8">
256 |                             <img 
257 |                                 src="/jitter.gif" 
258 |                                 alt="Eye cursor jittering" 
259 |                                 className="w-full max-w-2xl mx-auto rounded-lg shadow-lg"
260 |                             />
261 |                             <p className="text-sm font-red-hat text-gray-400 text-center mt-2 italic">
262 |                                 The large size of the button accounts for the jitteriness of the eye tracking.
263 |                             </p>
264 |                         </div>
265 | 
266 |                         <h2 className="text-4xl font-cor-gar mt-12 mb-6">Conclusion</h2>
267 |                         
268 |                         <p className="text-lg font-red-hat leading-relaxed">
269 |                             Those were a few details about Eyesite. If you are interested, you can see the source code. Small warning: this project was just a small demo and isn't a shining example of clean code or best practices.
270 |                         </p>
271 |                         
272 |                         <p className="text-lg font-red-hat leading-relaxed">
273 |                             This was a really fun project to make, and super cool to use too. If you want to make your own computer vision project or improve this one, I encourage you to do so! You can find the project at{' '}
274 |                             <a href="https://github.com/akchro/eyesite" className="text-blue-400 hover:text-blue-300 underline">
275 |                                 https://github.com/akchro/eyesite
276 |                             </a>.
277 |                         </p>
278 |                         
279 |                         <div className="h-32"></div> {/* Extra space for scrolling demonstration */}
280 |                     </article>
281 |                 </div>
282 |             </div>
283 | 
284 |             {/* Scroll Instructions at Bottom */}
285 |             <div className="relative">
286 |                 {/* Base gradient - always present */}
287 |                 <div className={`absolute bottom-0 left-0 right-0 h-32 bg-gradient-to-t from-blue-600/20 to-transparent pointer-events-none ${debugMode ? 'border-2' : ''}`} />
288 | 
289 |                 {/* Overlay gradient - fades in when scrolling */}
290 |                 <div
291 |                     className="absolute bottom-0 left-0 right-0 h-32 bg-gradient-to-t from-blue-600/40 to-transparent pointer-events-none transition-opacity duration-300"
292 |                     style={{
293 |                         opacity: scrollSpeed !== 0 ? 1 : 0
294 |                     }}
295 |                 />
296 | 
297 |                 <div className="absolute bottom-4 left-1/2 transform -translate-x-1/2">
298 |                     <p className="text-white text-3xl font-red-hat text-center bg-black/50 px-4 py-2 rounded">
299 |                         Look to Scroll
300 |                     </p>
301 |                 </div>
302 |             </div>
303 | 
304 |             {/* Exit Instructions */}
305 |             <div className="absolute top-6 right-6">
306 |                 <p className="text-white text-sm text-center font-red-hat px-4 py-2 rounded">
307 |                     Press Spacebar to Exit
308 |                 </p>
309 |                 <p className="text-white text-sm text-center font-red-hat px-4 py-2 rounded">
310 |                     Find the original blog at blog.andykhau.com
311 |                 </p>
312 |             </div>
313 | 
314 |         </div>
315 |     );
316 | };
317 | 
318 | export default Blog;


--------------------------------------------------------------------------------
/src/components/ClickableSquare.js:
--------------------------------------------------------------------------------
 1 | 'use client';
 2 | 
 3 | import { useRef, useState } from 'react';
 4 | import { useGazeClick } from './useGazeClick';
 5 | 
 6 | const ClickableSquare = () => {
 7 |     const squareRef = useRef(null);
 8 |     const [clickCount, setClickCount] = useState(0);
 9 |     const [isFlashing, setIsFlashing] = useState(false);
10 | 
11 |     const handleGazeClick = ({ gazeX, gazeY }) => {
12 |         console.log(`Gaze click at: ${gazeX}, ${gazeY}`);
13 |         setClickCount(prev => prev + 1);
14 |         
15 |         // Flash effect
16 |         setIsFlashing(true);
17 |         setTimeout(() => {
18 |             setIsFlashing(false);
19 |         }, 300);
20 |     };
21 | 
22 |     const { isGazeHovered, isClicked } = useGazeClick(squareRef, handleGazeClick);
23 | 
24 |     const getSquareColor = () => {
25 |         if (isClicked || isFlashing) return 'bg-yellow-400'; // Flash yellow when clicked
26 |         if (isGazeHovered) return 'bg-red-500'; // Red when hovered
27 |         return 'bg-blue-500'; // Default blue
28 |     };
29 | 
30 |     const getSquareScale = () => {
31 |         if (isClicked) return 'scale-110'; // Slightly larger when clicked
32 |         if (isGazeHovered) return 'scale-105'; // Slightly larger when hovered
33 |         return 'scale-100'; // Normal size
34 |     };
35 | 
36 |     return (
37 |         <div
38 |             ref={squareRef}
39 |             className={`w-[700px] h-80 border-2 border-gray-400 transition-all duration-200 ${getSquareColor()} ${getSquareScale()}`}
40 |             style={{
41 |                 position: 'absolute',
42 |                 top: '50%',
43 |                 left: '50%',
44 |                 transform: 'translate(-50%, -50%)',
45 |             }}
46 |         >
47 |             <div className="text-white text-center mt-10 space-y-2">
48 |                 <div className="text-lg font-bold">
49 |                     {isClicked ? 'CLICKED!' : isGazeHovered ? 'Press SPACE to click!' : 'Look at me'}
50 |                 </div>
51 |                 <div className="text-sm">
52 |                     Clicks: {clickCount}
53 |                 </div>
54 |                 {isGazeHovered && (
55 |                     <div className="text-xs animate-pulse">
56 |                         Press SPACEBAR to click
57 |                     </div>
58 |                 )}
59 |             </div>
60 |         </div>
61 |     );
62 | };
63 | 
64 | export default ClickableSquare; 


--------------------------------------------------------------------------------
/src/components/GazeClickWrapper.js:
--------------------------------------------------------------------------------
 1 | 'use client';
 2 | 
 3 | import React, { useRef, cloneElement, useEffect, useState } from 'react';
 4 | import { useGazeClick } from './useGazeClick';
 5 | 
 6 | const GazeClickWrapper = ({ 
 7 |     children, 
 8 |     onGazeClick, 
 9 |     onGazeHover,
10 |     onGazeLeave,
11 |     threshold = 20,
12 |     className = '',
13 |     style = {},
14 |     showClickEffect = true,
15 |     clickEffectDuration = 200,
16 |     ...props 
17 | }) => {
18 |     const elementRef = useRef(null);
19 |     const [isFlashing, setIsFlashing] = useState(false);
20 | 
21 |     const handleGazeClick = (clickData) => {
22 |         if (showClickEffect) {
23 |             setIsFlashing(true);
24 |             setTimeout(() => {
25 |                 setIsFlashing(false);
26 |             }, clickEffectDuration);
27 |         }
28 | 
29 |         if (onGazeClick) {
30 |             onGazeClick(clickData);
31 |         }
32 |     };
33 | 
34 |     const { isGazeHovered, isClicked } = useGazeClick(elementRef, handleGazeClick, threshold);
35 | 
36 |     // Handle gaze hover events
37 |     useEffect(() => {
38 |         if (isGazeHovered && onGazeHover) {
39 |             onGazeHover();
40 |         } else if (!isGazeHovered && onGazeLeave) {
41 |             onGazeLeave();
42 |         }
43 |     }, [isGazeHovered, onGazeHover, onGazeLeave]);
44 | 
45 |     const getAdditionalProps = () => {
46 |         const additionalProps = {
47 |             'data-gaze-hovered': isGazeHovered,
48 |             'data-gaze-clicked': isClicked,
49 |             'data-gaze-flashing': isFlashing,
50 |         };
51 | 
52 |         if (showClickEffect) {
53 |             additionalProps.className = `${className} ${isFlashing ? 'gaze-click-flash' : ''}`;
54 |         }
55 | 
56 |         return additionalProps;
57 |     };
58 | 
59 |     // If children is a single React element, clone it with the ref and additional props
60 |     if (React.isValidElement(children)) {
61 |         return cloneElement(children, {
62 |             ref: elementRef,
63 |             className: `${children.props.className || ''} ${className} ${isFlashing ? 'gaze-click-flash' : ''}`,
64 |             style: { ...children.props.style, ...style },
65 |             ...getAdditionalProps(),
66 |             ...props
67 |         });
68 |     }
69 | 
70 |     // Otherwise, wrap in a div
71 |     return (
72 |         <div
73 |             ref={elementRef}
74 |             className={`${className} ${isFlashing ? 'gaze-click-flash' : ''}`}
75 |             style={style}
76 |             {...getAdditionalProps()}
77 |             {...props}
78 |         >
79 |             {children}
80 |         </div>
81 |     );
82 | };
83 | 
84 | export default GazeClickWrapper; 


--------------------------------------------------------------------------------
/src/components/GazeWrapper.js:
--------------------------------------------------------------------------------
 1 | 'use client';
 2 | 
 3 | import React, { useRef, cloneElement, useEffect } from 'react';
 4 | import { useGazeHover } from './useGazeHover';
 5 | 
 6 | const GazeWrapper = ({ 
 7 |     children, 
 8 |     onGazeEnter, 
 9 |     onGazeLeave, 
10 |     threshold = 20,
11 |     className = '',
12 |     style = {},
13 |     ...props 
14 | }) => {
15 |     const elementRef = useRef(null);
16 |     const isGazeHovered = useGazeHover(elementRef, threshold);
17 | 
18 |     // Handle gaze enter/leave events
19 |     useEffect(() => {
20 |         if (isGazeHovered && onGazeEnter) {
21 |             onGazeEnter();
22 |         } else if (!isGazeHovered && onGazeLeave) {
23 |             onGazeLeave();
24 |         }
25 |     }, [isGazeHovered, onGazeEnter, onGazeLeave]);
26 | 
27 |     // If children is a single React element, clone it with the ref
28 |     if (React.isValidElement(children)) {
29 |         return cloneElement(children, {
30 |             ref: elementRef,
31 |             className: `${children.props.className || ''} ${className}`,
32 |             style: { ...children.props.style, ...style },
33 |             'data-gaze-hovered': isGazeHovered,
34 |             ...props
35 |         });
36 |     }
37 | 
38 |     // Otherwise, wrap in a div
39 |     return (
40 |         <div
41 |             ref={elementRef}
42 |             className={className}
43 |             style={style}
44 |             data-gaze-hovered={isGazeHovered}
45 |             {...props}
46 |         >
47 |             {children}
48 |         </div>
49 |     );
50 | };
51 | 
52 | export default GazeWrapper; 


--------------------------------------------------------------------------------
/src/components/LandingScreen.js:
--------------------------------------------------------------------------------
  1 | 'use client';
  2 | 
  3 | import { useState, useEffect } from 'react';
  4 | import { Transition } from '@headlessui/react';
  5 | import { useWebGazer } from './webgazerProvider';
  6 | 
  7 | export default function LandingScreen({ children }) {
  8 |     const [showLanding, setShowLanding] = useState(true);
  9 |     const [minWaitingTime, setMinWaitingTime] = useState(false);
 10 |     const [cameraPermission, setCameraPermission] = useState('checking'); // 'checking', 'granted', 'denied', 'prompt'
 11 |     const [cameraError, setCameraError] = useState(null);
 12 |     const { isReady } = useWebGazer();
 13 | 
 14 |     useEffect(() => {
 15 |         const timer = setTimeout(() => {
 16 |             setMinWaitingTime(true);
 17 |         }, 2000);
 18 | 
 19 |         return () => clearTimeout(timer);
 20 |     }, []);
 21 | 
 22 |     useEffect(() => {
 23 |         // Check camera permission on mount
 24 |         checkCameraPermission();
 25 |     }, []);
 26 | 
 27 |     const checkCameraPermission = async () => {
 28 |         try {
 29 |             if (navigator.permissions) {
 30 |                 const permission = await navigator.permissions.query({ name: 'camera' });
 31 |                 setCameraPermission(permission.state);
 32 |                 
 33 |                 // Listen for permission changes
 34 |                 permission.onchange = () => {
 35 |                     setCameraPermission(permission.state);
 36 |                 };
 37 |             } else {
 38 |                 // Fallback: try to access camera directly
 39 |                 setCameraPermission('prompt');
 40 |             }
 41 |         } catch (error) {
 42 |             console.error('Error checking camera permission:', error);
 43 |             setCameraPermission('prompt');
 44 |         }
 45 |     };
 46 | 
 47 |     const requestCameraAccess = async () => {
 48 |         try {
 49 |             setCameraError(null);
 50 |             const stream = await navigator.mediaDevices.getUserMedia({ video: true });
 51 |             
 52 |             // Stop the stream immediately - we just needed to request permission
 53 |             stream.getTracks().forEach(track => track.stop());
 54 |             
 55 |             setCameraPermission('granted');
 56 |         } catch (error) {
 57 |             console.error('Camera access denied:', error);
 58 |             setCameraError(error.message || 'Camera access was denied');
 59 |             setCameraPermission('denied');
 60 |         }
 61 |     };
 62 | 
 63 |     useEffect(() => {
 64 |         // Hide landing screen when all conditions are met
 65 |         if (minWaitingTime && isReady && cameraPermission === 'granted') {
 66 |             setShowLanding(false);
 67 |         }
 68 |     }, [minWaitingTime, isReady, cameraPermission]);
 69 | 
 70 |     const renderCameraStatus = () => {
 71 |         switch (cameraPermission) {
 72 |             case 'checking':
 73 |                 return (
 74 |                     <div className="text-center">
 75 |                         <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-4"></div>
 76 |                         <p className="text-xl text-gray-300">Checking camera access...</p>
 77 |                     </div>
 78 |                 );
 79 |             
 80 |             case 'prompt':
 81 |             case 'denied':
 82 |                 return (
 83 |                     <div className="text-center max-w-md">
 84 |                         <div className="mb-6">
 85 |                             <svg className="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
 86 |                                 <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
 87 |                             </svg>
 88 |                         </div>
 89 |                         <h2 className="text-2xl font-semibold text-white mb-4">Camera Access Required</h2>
 90 |                         <p className="text-gray-300 mb-6">
 91 |                             Eyesite needs access to your camera to track your eye movements for calibration and gaze detection.
 92 |                         </p>
 93 |                         {cameraError && (
 94 |                             <div className="bg-red-900/50 border border-red-700 rounded-lg p-3 mb-4">
 95 |                                 <p className="text-red-200 text-sm">{cameraError}</p>
 96 |                             </div>
 97 |                         )}
 98 |                         <button
 99 |                             onClick={requestCameraAccess}
100 |                             className="bg-blue-600 hover:bg-blue-700 text-white font-semibold py-3 px-6 rounded-lg transition-colors duration-200"
101 |                         >
102 |                             Enable Camera
103 |                         </button>
104 |                         <p className="text-sm text-gray-400 mt-4">
105 |                             You may need to click "Allow" in your browser's permission dialog
106 |                         </p>
107 |                     </div>
108 |                 );
109 |             
110 |             case 'granted':
111 |                 if (!minWaitingTime || !isReady) {
112 |                     return (
113 |                         <div className="text-center">
114 |                             <h2 className="text-7xl font-red-hat text-white mb-4">Eyesite</h2>
115 |                             <p className="text-gray-300 mb-4">Initializing eye tracking...</p>
116 |                             <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto"></div>
117 |                         </div>
118 |                     );
119 |                 }
120 |                 break;
121 |             
122 |             default:
123 |                 return null;
124 |         }
125 |     };
126 | 
127 |     return (
128 |         <>
129 |             <Transition
130 |                 show={showLanding}
131 |                 as="div"
132 |                 leave="transition-opacity duration-500"
133 |                 leaveFrom="opacity-100"
134 |                 leaveTo="opacity-0"
135 |                 className="fixed inset-0 bg-gray-950 flex flex-col items-center justify-center z-50 p-8"
136 |             >
137 | 
138 |                 {renderCameraStatus()}
139 |             </Transition>
140 | 
141 |             {!showLanding && children}
142 |         </>
143 |     );
144 | } 


--------------------------------------------------------------------------------
/src/components/WrappedClickableSquare.js:
--------------------------------------------------------------------------------
 1 | 'use client';
 2 | 
 3 | import { useState } from 'react';
 4 | import GazeClickWrapper from './GazeClickWrapper';
 5 | 
 6 | const WrappedClickableSquare = () => {
 7 |     const [clickCount, setClickCount] = useState(0);
 8 |     const [isHovered, setIsHovered] = useState(false);
 9 | 
10 |     const handleGazeClick = ({ gazeX, gazeY }) => {
11 |         console.log(`Wrapped gaze click at: ${gazeX}, ${gazeY}`);
12 |         setClickCount(prev => prev + 1);
13 |     };
14 | 
15 |     return (
16 |         <GazeClickWrapper
17 |             onGazeClick={handleGazeClick}
18 |             onGazeHover={() => setIsHovered(true)}
19 |             onGazeLeave={() => setIsHovered(false)}
20 |             style={{
21 |                 position: 'absolute',
22 |                 top: '50%',
23 |                 left: '50%',
24 |                 transform: 'translate(-50%, -50%)',
25 |             }}
26 |         >
27 |             <div
28 |                 className={`w-[500px] h-80 border-2 border-gray-400 transition-all duration-300 ${
29 |                     isHovered ? 'bg-red-500' : 'bg-blue-500'
30 |                 }`}
31 |             >
32 |                 <div className="text-white text-center mt-10 space-y-2">
33 |                     <div className="text-lg font-bold">
34 |                         {isHovered ? 'Press SPACE to click!' : 'Look at me'}
35 |                     </div>
36 |                     <div className="text-sm">
37 |                         Wrapper Clicks: {clickCount}
38 |                     </div>
39 |                     {isHovered && (
40 |                         <div className="spacebar-instruction">
41 |                             SPACEBAR to click
42 |                         </div>
43 |                     )}
44 |                 </div>
45 |             </div>
46 |         </GazeClickWrapper>
47 |     );
48 | };
49 | 
50 | export default WrappedClickableSquare; 


--------------------------------------------------------------------------------
/src/components/WrappedSquare.js:
--------------------------------------------------------------------------------
 1 | 'use client';
 2 | 
 3 | import { useState } from 'react';
 4 | import GazeWrapper from './GazeWrapper';
 5 | 
 6 | const WrappedSquare = () => {
 7 |     const [isGazed, setIsGazed] = useState(false);
 8 | 
 9 |     return (
10 |         <GazeWrapper
11 |             onGazeEnter={() => setIsGazed(true)}
12 |             onGazeLeave={() => setIsGazed(false)}
13 |             style={{
14 |                 position: 'absolute',
15 |                 top: '50%',
16 |                 left: '50%',
17 |                 transform: 'translate(-50%, -50%)',
18 |             }}
19 |         >
20 |             <div
21 |                 className={`w-[500px] h-80 border-2 border-gray-400 transition-colors duration-300 ${
22 |                     isGazed ? 'bg-red-500' : 'bg-blue-500'
23 |                 }`}
24 |             >
25 |                 <div className="text-white text-center mt-10">
26 |                     {isGazed ? 'Looking!' : 'Look at me'}
27 |                 </div>
28 |             </div>
29 |         </GazeWrapper>
30 |     );
31 | };
32 | 
33 | export default WrappedSquare; 


--------------------------------------------------------------------------------
/src/components/calibrate.js:
--------------------------------------------------------------------------------
  1 | 'use client';
  2 | 
  3 | import { useWebGazer } from "@/components/webgazerProvider";
  4 | import { useState, useEffect, useCallback } from 'react';
  5 | 
  6 | export default function Calibrate({calibrationComplete, setCalibrationComplete, calibrationPoints, setCalibrationPoints, introShown, setIntroShown}) {
  7 |     const { calibrateAt, isReady } = useWebGazer();
  8 |     
  9 |     // Calibration state
 10 |     const [currentPointIndex, setCurrentPointIndex] = useState(0);
 11 |     const [pressCount, setPressCount] = useState(0);
 12 |     const [isTransitioning, setIsTransitioning] = useState(false);
 13 |     
 14 |     // Introduction sequence state
 15 |     const [introStep, setIntroStep] = useState(0);
 16 |     const [showCalibration, setShowCalibration] = useState(false);
 17 |     const [encouragementMessage, setEncouragementMessage] = useState('');
 18 |     const [showEncouragement, setShowEncouragement] = useState(false);
 19 |     const [usedEncouragements, setUsedEncouragements] = useState([]);
 20 |     const [introFadingOut, setIntroFadingOut] = useState(false);
 21 |     const [calibrationFadingIn, setCalibrationFadingIn] = useState(false);
 22 |     const [calibrationFadingOut, setCalibrationFadingOut] = useState(false);
 23 | 
 24 |     // Introduction messages
 25 |     const introMessages = [
 26 |         "Hello",
 27 |         "Let's calibrate",
 28 |         "Try not to blink",
 29 |         "Look at the point. Press spacebar to calibrate"
 30 |     ];
 31 | 
 32 |     // Encouraging messages
 33 |     const encouragingWords = [
 34 |         "Amazing.", "Incredible.", "Wonderful.", "Perfect.", "Excellent.", 
 35 |         "Outstanding.", "Brilliant.", "Fantastic.", "Superb.", "Magnificent."
 36 |     ];
 37 | 
 38 |     // Define calibration points in order
 39 |     const calibrationSequence = [
 40 |         { id: 'Pt1', position: { top: '70px', left: '2vw' } },
 41 |         { id: 'Pt2', position: { top: '70px', left: '50%', transform: 'translateX(-50%)' } },
 42 |         { id: 'Pt3', position: { top: '70px', right: '2vw' } },
 43 |         { id: 'Pt4', position: { top: '50%', left: '2vw', transform: 'translateY(-50%)' } },
 44 |         { id: 'Pt6', position: { top: '50%', right: '2vw', transform: 'translateY(-50%)' } },
 45 |         { id: 'Pt7', position: { bottom: '2vw', left: '2vw' } },
 46 |         { id: 'Pt8', position: { bottom: '2vw', left: '50%', transform: 'translateX(-50%)' } },
 47 |         { id: 'Pt9', position: { bottom: '2vw', right: '2vw' } },
 48 |         { id: 'Pt5', position: { top: '50%', left: '50%', transform: 'translate(-50%, -50%)' } }, // Center point last
 49 |     ];
 50 | 
 51 |     const currentPoint = calibrationSequence[currentPointIndex];
 52 | 
 53 |     // Introduction sequence effect
 54 |     useEffect(() => {
 55 |         if (!isReady) return;
 56 | 
 57 |         // If intro has already been shown, skip directly to calibration
 58 |         if (introShown) {
 59 |             setShowCalibration(true);
 60 |             setTimeout(() => {
 61 |                 setCalibrationFadingIn(true);
 62 |             }, 50);
 63 |             return;
 64 |         }
 65 | 
 66 |         const timeouts = [];
 67 |         
 68 |         // Step through introduction messages
 69 |         introMessages.forEach((message, index) => {
 70 |             const timeout = setTimeout(() => {
 71 |                 setIntroStep(index);
 72 |             }, index * 2500); // 2 seconds between each message
 73 |             timeouts.push(timeout);
 74 |         });
 75 | 
 76 |         // Start fade out of final message
 77 |         const fadeOutTimeout = setTimeout(() => {
 78 |             setIntroFadingOut(true);
 79 |         }, (introMessages.length * 2500) - 500); // Start fade 500ms before showing calibration
 80 |         timeouts.push(fadeOutTimeout);
 81 | 
 82 |         // Show calibration after intro
 83 |         const finalTimeout = setTimeout(() => {
 84 |             setShowCalibration(true);
 85 |             // Mark intro as shown
 86 |             setIntroShown(true);
 87 |             // Start fade-in animation for calibration elements
 88 |             setTimeout(() => {
 89 |                 setCalibrationFadingIn(true);
 90 |             }, 50); // Small delay to ensure showCalibration state is set
 91 |         }, introMessages.length * 2500);
 92 |         timeouts.push(finalTimeout);
 93 | 
 94 |         return () => timeouts.forEach(clearTimeout);
 95 |     }, [isReady, introShown, setIntroShown]);
 96 | 
 97 |     // Show encouragement after each point
 98 |     const showEncouragementMessage = () => {
 99 |         // Get available words (not used yet)
100 |         let availableWords = encouragingWords.filter(word => !usedEncouragements.includes(word));
101 |         
102 |         // If all words used, reset the list
103 |         if (availableWords.length === 0) {
104 |             availableWords = [...encouragingWords];
105 |             setUsedEncouragements([]);
106 |         }
107 |         
108 |         const randomMessage = availableWords[Math.floor(Math.random() * availableWords.length)];
109 |         setEncouragementMessage(randomMessage);
110 |         
111 |         // Add to used list
112 |         setUsedEncouragements(prev => [...prev, randomMessage]);
113 |         
114 |         // Fade in
115 |         setShowEncouragement(true);
116 | 
117 |         // Fade out after 1 second
118 |         setTimeout(() => {
119 |             setShowEncouragement(false);
120 |         }, 1000);
121 |     };
122 | 
123 |     // Handle spacebar press and R for restart
124 |     const handleKeyPress = useCallback((event) => {
125 |         // Handle R key for restart calibration
126 |         if (event.code === 'KeyR' && !event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey) {
127 |             event.preventDefault();
128 |             // Reset calibration state
129 |             setCurrentPointIndex(0);
130 |             setPressCount(0);
131 |             setIsTransitioning(false);
132 |             setCalibrationPoints({});
133 |             setCalibrationFadingOut(false);
134 |             setCalibrationFadingIn(false);
135 |             
136 |             // Clear webgazer data
137 |             if (window.webgazer) {
138 |                 window.webgazer.clearData();
139 |             }
140 |             
141 |             // Restart calibration (skip intro since it's already been shown)
142 |             setShowCalibration(true);
143 |             setTimeout(() => {
144 |                 setCalibrationFadingIn(true);
145 |             }, 100);
146 |             return;
147 |         }
148 | 
149 |         // Handle spacebar for calibration
150 |         if (event.code === 'Space' && !isTransitioning && showCalibration) {
151 |             event.preventDefault();
152 |             
153 |             if (currentPoint) {
154 |                 // Calculate point position for calibration - CALIBRATE ON EVERY PRESS
155 |                 const pointElement = document.getElementById(currentPoint.id);
156 |                 if (pointElement) {
157 |                     const rect = pointElement.getBoundingClientRect();
158 |                     const x = rect.left + rect.width / 2;
159 |                     const y = rect.top + rect.height / 2;
160 |                     calibrateAt(x, y); // Call calibrateAt for every press
161 |                 }
162 | 
163 |                 const newPressCount = pressCount + 1;
164 |                 setPressCount(newPressCount);
165 | 
166 |                 // Move to next point after 5 presses
167 |                 if (newPressCount >= 5) {
168 |                     // Mark current point as calibrated
169 |                     setCalibrationPoints(prev => ({
170 |                         ...prev,
171 |                         [currentPoint.id]: true
172 |                     }));
173 | 
174 |                     // Show encouragement message
175 |                     showEncouragementMessage();
176 | 
177 |                     // Move to next point or complete calibration
178 |                     if (currentPointIndex < calibrationSequence.length - 1) {
179 |                         setIsTransitioning(true);
180 |                         
181 |                         // Smooth transition with fade out and fade in
182 |                         setTimeout(() => {
183 |                             setCurrentPointIndex(prev => prev + 1);
184 |                             setPressCount(0);
185 |                             setIsTransitioning(false);
186 |                         }, 1500); // Reduced wait time for encouragement message
187 |                     } else {
188 |                         // Calibration complete - start fade out
189 |                         setIsTransitioning(true);
190 |                         setCalibrationFadingOut(true);
191 |                         setTimeout(() => {
192 |                             setCalibrationComplete(true);
193 |                         }, 1500); // Wait for fade out animation
194 |                     }
195 |                 }
196 |             }
197 |         }
198 |     }, [currentPoint, pressCount, currentPointIndex, isTransitioning, showCalibration, calibrateAt, setCalibrationPoints, setCalibrationComplete]);
199 | 
200 |     // Set up spacebar listener
201 |     useEffect(() => {
202 |         window.addEventListener('keydown', handleKeyPress);
203 |         return () => window.removeEventListener('keydown', handleKeyPress);
204 |     }, [handleKeyPress]);
205 | 
206 |     if (!isReady) return <p className="text-center mt-10 text-xl">Loading eye tracker...</p>;
207 | 
208 |     // Calculate opacity based on press count - but maintain minimum visibility to prevent jumping
209 |     const pointOpacity = Math.max(0.6, Math.min((pressCount + 1) * 0.08 + 0.6, 1));
210 | 
211 |     return (
212 |         <div className="relative w-full h-screen bg-gray-950 overflow-hidden">
213 |             {/* Introduction Sequence */}
214 |             {!showCalibration && (
215 |                 <div className="absolute inset-0 flex items-center justify-center z-30">
216 |                     <div className="text-center">
217 |                         {introMessages.map((message, index) => {
218 |                             const isCurrentStep = introStep === index;
219 |                             const isPastStep = introStep > index;
220 |                             const isFinalMessage = index === introMessages.length - 1;
221 |                             const shouldFadeOut = isFinalMessage && introFadingOut;
222 |                             
223 |                             return (
224 |                                 <div
225 |                                     key={index}
226 |                                     className={`absolute left-1/2 top-[45%] transform -translate-x-1/2 -translate-y-1/2 transition-all duration-1000 ease-out ${
227 |                                         shouldFadeOut
228 |                                             ? 'opacity-0 scale-95'
229 |                                             : isCurrentStep
230 |                                                 ? 'opacity-100 scale-100 translate-y-0'
231 |                                                 : isPastStep
232 |                                                     ? 'opacity-0 scale-95 -translate-y-8'
233 |                                                     : 'opacity-0 scale-105 translate-y-8'
234 |                                     }`}
235 |                                     style={{
236 |                                         fontSize: index === 0 ? '4rem' : index === 1 ? '3rem' : '2rem',
237 |                                         fontWeight: index === 0 ? '300' : index === 1 ? '400' : '500',
238 |                                         letterSpacing: index === 0 ? '0.1em' : index === 1 ? '0.05em' : '0.02em',
239 |                                         color: index === 2 ? '#fbbf24' : '#ffffff'
240 |                                     }}
241 |                                 >
242 |                                     {message}
243 |                                     {index === 0 && <span className="animate-pulse">.</span>}
244 |                                     {index === 2 && <span className="text-2xl ml-2">⚠️</span>}
245 |                                 </div>
246 |                             );
247 |                         })}
248 |                     </div>
249 |                 </div>
250 |             )}
251 | 
252 |             {/* Encouragement Message */}
253 |             <div className="absolute inset-0 flex items-center justify-center z-40 pointer-events-none">
254 |                 <div 
255 |                     className={`text-center transition-all duration-500 ease-in-out ${
256 |                         showEncouragement ? 'opacity-100 scale-100 translate-y-0' : 'opacity-0 scale-90 translate-y-4'
257 |                     }`}
258 |                     style={{
259 |                         fontSize: '3rem',
260 |                         fontWeight: '300',
261 |                         letterSpacing: '0.1em',
262 |                         color: '#10b981',
263 |                         textShadow: '0 0 30px rgba(16, 185, 129, 0.5)'
264 |                     }}
265 |                 >
266 |                     {encouragementMessage}
267 |                 </div>
268 |             </div>
269 | 
270 |             {/* Progress Indicator */}
271 |             {showCalibration && (
272 |                 <div className={`absolute top-4 left-1/2 transform -translate-x-1/2 z-20 transition-all duration-1000 ease-out ${
273 |                     calibrationFadingOut ? 'opacity-0 translate-y-4' : 
274 |                     calibrationFadingIn ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-4'
275 |                 }`}>
276 |                     <div className="px-4 py-2 rounded-lg flex flex-col items-center ">
277 |                         <div className="text-white text-sm text-center font-red-hat">
278 |                             Point {currentPointIndex + 1} of {calibrationSequence.length} | Press {pressCount}/5
279 |                         </div>
280 |                         <div className={'text-white text-sm center font-red-hat'}>
281 |                             Press R to restart
282 |                         </div>
283 |                         <div className={'text-white text-sm center font-red-hat'}>
284 |                             Look at the point. Press spacebar to calibrate
285 |                         </div>
286 |                     </div>
287 |                 </div>
288 |             )}
289 | 
290 |             {/* Current calibration point */}
291 |             {currentPoint && showCalibration && (
292 |                 <div
293 |                     id={currentPoint.id}
294 |                     className={`absolute w-12 h-12 rounded-full bg-blue-600 border-4 border-blue-400 transition-all duration-1000 ease-in-out shadow-lg ${
295 |                         calibrationFadingOut ? 'opacity-0 scale-75 translate-y-4' :
296 |                         isTransitioning ? 'opacity-0 scale-75' : 
297 |                         calibrationFadingIn ? 'opacity-100 scale-100' : 'opacity-0 scale-75 -translate-y-4'
298 |                     }`}
299 |                     style={{
300 |                         ...currentPoint.position,
301 |                         opacity: calibrationFadingOut ? 0 : (isTransitioning ? 0 : (calibrationFadingIn ? pointOpacity : 0)),
302 |                         transform: currentPoint.position.transform 
303 |                             ? `${currentPoint.position.transform} ${
304 |                                 calibrationFadingOut ? 'scale(0.75) translateY(1rem)' :
305 |                                 isTransitioning ? 'scale(0.75)' : 
306 |                                 calibrationFadingIn ? 'scale(1)' : 'scale(0.75) translateY(-1rem)'
307 |                               }`
308 |                             : calibrationFadingOut ? 'scale(0.75) translateY(1rem)' :
309 |                               isTransitioning ? 'scale(0.75)' : 
310 |                               calibrationFadingIn ? 'scale(1)' : 'scale(0.75) translateY(-1rem)',
311 |                         boxShadow: `0 0 ${20 + pressCount * 8}px rgba(59, 130, 246, 0.8)`,
312 |                         zIndex: 10
313 |                     }}
314 |                 >
315 |                     {/* Inner progress indicator */}
316 |                     <div 
317 |                         className="absolute inset-2 rounded-full bg-blue-300 transition-all duration-200"
318 |                         style={{ opacity: pressCount * 0.15 + 0.1 }}
319 |                     />
320 |                     
321 |                     {/* Progress ring */}
322 |                     <svg className="absolute inset-0 w-full h-full transform -rotate-90" viewBox="0 0 48 48">
323 |                         <circle
324 |                             cx="24"
325 |                             cy="24"
326 |                             r="20"
327 |                             stroke="rgba(59, 130, 246, 0.3)"
328 |                             strokeWidth="2"
329 |                             fill="transparent"
330 |                         />
331 |                         <circle
332 |                             cx="24"
333 |                             cy="24"
334 |                             r="20"
335 |                             stroke="rgb(147, 197, 253)"
336 |                             strokeWidth="2"
337 |                             fill="transparent"
338 |                             strokeDasharray={`${(pressCount / 5) * 125.6} 125.6`}
339 |                             className="transition-all duration-200 ease-out"
340 |                         />
341 |                     </svg>
342 |                     
343 |                     {/* Completion flash effect */}
344 |                     {pressCount === 5 && (
345 |                         <div className="absolute inset-0 rounded-full bg-green-400 opacity-60 animate-ping" />
346 |                     )}
347 |                 </div>
348 |             )}
349 | 
350 |             {/* Completed points (fade out) */}
351 |             {showCalibration && calibrationSequence.slice(0, currentPointIndex).map((point, index) => (
352 |                 <div
353 |                     key={point.id}
354 |                     className={`absolute w-6 h-6 rounded-full bg-green-600 border-2 border-green-400 transition-all duration-1000 ${
355 |                         calibrationFadingOut ? 'opacity-0 translate-y-4' : 'opacity-40'
356 |                     }`}
357 |                     style={{
358 |                         ...point.position,
359 |                         transform: point.position.transform 
360 |                             ? `${point.position.transform} translate(-50%, -50%)`
361 |                             : 'translate(-50%, -50%)',
362 |                         zIndex: 8
363 |                     }}
364 |                 />
365 |             ))}
366 | 
367 |             <style jsx>{`
368 |                 @keyframes ping {
369 |                     75%, 100% {
370 |                         transform: scale(2);
371 |                         opacity: 0;
372 |                     }
373 |                 }
374 |             `}</style>
375 |         </div>
376 |     );
377 | }
378 | 


--------------------------------------------------------------------------------
/src/components/gaze.js:
--------------------------------------------------------------------------------
  1 | 'use client';
  2 | 
  3 | import {useCallback, useEffect, useState} from 'react';
  4 | import { Transition } from '@headlessui/react';
  5 | import {useWebGazer} from './webgazerProvider';
  6 | import Calibrate from "@/components/calibrate";
  7 | import LookAndClick from "@/components/seeableTexts/lookAndClick";
  8 | import PlayAGame from "@/components/seeableTexts/playAGame";
  9 | import BlogButton from "@/components/seeableTexts/blogButton";
 10 | import Blog from "@/components/Blog";
 11 | import ScreenTooSmallWindow from "@/components/screenTooSmallWindow";
 12 | 
 13 | const Gaze = () => {
 14 |     const [calibrationComplete, setCalibrationComplete] = useState(false);
 15 |     const [calibrationPoints, setCalibrationPoints] = useState({});
 16 |     const [debugMode, setDebugMode] = useState(false);
 17 |     const [introShown, setIntroShown] = useState(false);
 18 |     const { currentGaze, isReady, setVideoVisible, setPredictionPointsVisible } = useWebGazer();
 19 | 
 20 |     // Game state management
 21 |     const [gameActive, setGameActive] = useState(false);
 22 |     const [gameProgress, setGameProgress] = useState(0);
 23 | 
 24 |     // Blog state management
 25 |     const [isReadingBlog, setIsReadingBlog] = useState(false);
 26 | 
 27 |     // Screen size validation
 28 |     const [screenSize, setScreenSize] = useState({ width: 0, height: 0 });
 29 |     const [isScreenSizeValid, setIsScreenSizeValid] = useState(true);
 30 | 
 31 |     const MIN_WIDTH = 1200;
 32 |     const MIN_HEIGHT = 728;
 33 | 
 34 |     // Check screen size
 35 |     const checkScreenSize = useCallback(() => {
 36 |         const width = window.innerWidth;
 37 |         const height = window.innerHeight;
 38 |         setScreenSize({ width, height });
 39 |         setIsScreenSizeValid(width >= MIN_WIDTH && height >= MIN_HEIGHT);
 40 |     }, []);
 41 | 
 42 |     // Handle screen resize
 43 |     useEffect(() => {
 44 |         checkScreenSize();
 45 |         window.addEventListener('resize', checkScreenSize);
 46 |         return () => window.removeEventListener('resize', checkScreenSize);
 47 |     }, [checkScreenSize]);
 48 | 
 49 |     // Handle debug mode toggle and recalibration
 50 |     const handleKeyPress = useCallback((event) => {
 51 |         if (event.code === 'KeyD') {
 52 |             setDebugMode(prev => !prev);
 53 |         }
 54 |         // Only handle R for recalibration when calibration is complete and not reading blog
 55 |         // During calibration, the Calibrate component handles R key
 56 |         // During blog reading, spacebar is handled by Blog component for exit
 57 |         if (event.code === 'KeyR' && !event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey && calibrationComplete && !isReadingBlog) {
 58 |             handleRecalibrate();
 59 |         }
 60 |     }, [calibrationComplete, isReadingBlog]);
 61 | 
 62 |     // Set up keyboard listener for debug mode
 63 |     useEffect(() => {
 64 |         window.addEventListener('keydown', handleKeyPress);
 65 |         return () => {
 66 |             window.removeEventListener('keydown', handleKeyPress);
 67 |         };
 68 |     }, [handleKeyPress]);
 69 | 
 70 |     // Control video visibility based on calibration and debug mode
 71 |     useEffect(() => {
 72 |         if (setVideoVisible) {
 73 |             setVideoVisible(debugMode);
 74 |         }
 75 |         if (setPredictionPointsVisible) {
 76 |             setPredictionPointsVisible(debugMode)
 77 |         }
 78 |     }, [calibrationComplete, debugMode, setVideoVisible, setPredictionPointsVisible]);
 79 | 
 80 |     const handleRecalibrate = () => {
 81 |         if (window.webgazer) {
 82 |             window.webgazer.clearData();
 83 |             setCalibrationPoints({});
 84 |             setCalibrationComplete(false);
 85 |             // Keep introShown as true so intro doesn't replay
 86 |             // Video visibility will be handled by the useEffect above
 87 |         }
 88 |     };
 89 | 
 90 |     const toggleDebugMode = () => {
 91 |         setDebugMode(prev => !prev);
 92 |     };
 93 | 
 94 |     // Game handlers
 95 |     const handleGameStart = () => {
 96 |         setGameActive(true);
 97 |         setGameProgress(0);
 98 |     };
 99 | 
100 |     const handleGameEnd = () => {
101 |         setGameActive(false);
102 |         setGameProgress(0);
103 |     };
104 | 
105 |     const handleGameProgress = (progress) => {
106 |         setGameProgress(progress);
107 |     };
108 | 
109 |     // Blog handlers
110 |     const handleBlogOpen = () => {
111 |         setIsReadingBlog(true);
112 |     };
113 | 
114 |     const handleBlogClose = () => {
115 |         setIsReadingBlog(false);
116 |     };
117 | 
118 |     // Show screen size warning if dimensions are too small
119 |     if (!isScreenSizeValid) {
120 |         return (
121 |             <ScreenTooSmallWindow screenSize={screenSize} min_width={MIN_WIDTH} min_height={MIN_HEIGHT}/>
122 |         )
123 |     }
124 | 
125 |     return (
126 |         <main className={debugMode ? '' : 'cursor-none'}>
127 |             {/* Debug camera overlay */}
128 |             {debugMode && calibrationComplete && (
129 |                 <div className="debug-camera-overlay">
130 |                     DEBUG CAM
131 |                 </div>
132 |             )}
133 | 
134 |             {/* Calibration Screen with Transition */}
135 |             <Transition
136 |                 show={!calibrationComplete}
137 |                 enter="transition-all duration-1000 ease-out"
138 |                 enterFrom="opacity-0"
139 |                 enterTo="opacity-100"
140 |                 leave="transition-all duration-1000 ease-out"
141 |                 leaveFrom="opacity-100"
142 |                 leaveTo="opacity-0"
143 |                 as="div"
144 |             >
145 |                 <Calibrate 
146 |                     calibrationComplete={calibrationComplete}
147 |                     setCalibrationComplete={setCalibrationComplete}
148 |                     calibrationPoints={calibrationPoints}
149 |                     setCalibrationPoints={setCalibrationPoints}
150 |                     introShown={introShown}
151 |                     setIntroShown={setIntroShown}
152 |                 />
153 |             </Transition>
154 | 
155 |             {/* Blog Screen with Transition */}
156 |             <Transition
157 |                 show={calibrationComplete && isReadingBlog}
158 |                 enter="transition-all duration-500 ease-out"
159 |                 enterFrom="opacity-0"
160 |                 enterTo="opacity-100"
161 |                 leave="transition-all duration-500 ease-out"
162 |                 leaveFrom="opacity-100"
163 |                 leaveTo="opacity-0"
164 |                 as="div"
165 |             >
166 |                 <Blog
167 |                     debugMode={debugMode}
168 |                     onExit={handleBlogClose}
169 |                 />
170 |             </Transition>
171 | 
172 |             {/* Main Screen with Transition */}
173 |             <Transition
174 |                 show={calibrationComplete && !isReadingBlog}
175 |                 enter="transition-all duration-1000 ease-out delay-500"
176 |                 enterFrom="opacity-0 scale-95"
177 |                 enterTo="opacity-100 scale-100"
178 |                 leave="transition-all duration-500 ease-out"
179 |                 leaveFrom="opacity-100 scale-100"
180 |                 leaveTo="opacity-0 scale-95"
181 |                 as="div"
182 |             >
183 |                 <div className="w-full h-screen relative bg-gray-950">
184 |                     {/* PlayAGame component - always visible */}
185 |                     <PlayAGame 
186 |                         debugMode={debugMode}
187 |                         gameActive={gameActive}
188 |                         onGameStart={handleGameStart}
189 |                         onGameEnd={handleGameEnd}
190 |                         onGameProgress={handleGameProgress}
191 |                     />
192 | 
193 |                     {/* Other interactive components - hidden during game */}
194 |                     <Transition
195 |                         show={!gameActive}
196 |                         enter="transition-opacity duration-500"
197 |                         enterFrom="opacity-0"
198 |                         enterTo="opacity-100"
199 |                         leave="transition-opacity duration-500"
200 |                         leaveFrom="opacity-100"
201 |                         leaveTo="opacity-0"
202 |                     >
203 |                         <div style={{ position: 'absolute', top: '20%', left: '18%' }}>
204 |                             <BlogButton 
205 |                                 debugMode={debugMode} 
206 |                                 onBlogClick={handleBlogOpen}
207 |                             />
208 |                         </div>
209 |                     </Transition>
210 |                     
211 |                     <Transition
212 |                         show={!gameActive}
213 |                         enter="transition-opacity duration-500"
214 |                         enterFrom="opacity-0"
215 |                         enterTo="opacity-100"
216 |                         leave="transition-opacity duration-500"
217 |                         leaveFrom="opacity-100"
218 |                         leaveTo="opacity-0"
219 |                     >
220 |                         <div style={{ position: 'absolute', top: '20%', right: '20%' }}>
221 |                             <LookAndClick debugMode={debugMode}/>
222 |                         </div>
223 |                     </Transition>
224 |                     
225 |                     {/* Instructions */}
226 |                     <Transition
227 |                         show={!gameActive}
228 |                         enter="transition-opacity duration-500"
229 |                         enterFrom="opacity-0"
230 |                         enterTo="opacity-100"
231 |                         leave="transition-opacity duration-500"
232 |                         leaveFrom="opacity-100"
233 |                         leaveTo="opacity-0"
234 |                     >
235 |                         <div className="absolute bottom-20 right-10 p-4 rounded shadow max-w-lg flex flex-col">
236 |                             <h3 className="font-bold font-red-hat mb-2 text-center text-white">Gaze Interaction Demo</h3>
237 |                             <p className="text-s font-red-hat text-gray-200 mt-2 text-center">
238 |                                 Use your eyes to control, press Spacebar to click.
239 |                             </p>
240 |                             <p className="text-s font-red-hat text-gray-200 mt-1 text-center font-medium">
241 |                                 Press D to toggle debug mode
242 |                             </p>
243 |                             <p className="text-s font-red-hat text-gray-200 mt-1 text-center font-medium">
244 |                                 Press R to recalibrate
245 |                             </p>
246 |                             {debugMode && (
247 |                                 <p className="text-xs text-yellow-600 mt-1 text-center font-bold">
248 |                                     🔧 Debug Mode Active - Camera {calibrationComplete ? 'Visible' : 'Hidden'}
249 |                                 </p>
250 |                             )}
251 |                         </div>
252 |                     </Transition>
253 |                 </div>
254 |             </Transition>
255 |         </main>
256 |     );
257 | };
258 | 
259 | export default Gaze;


--------------------------------------------------------------------------------
/src/components/screenTooSmallWindow.js:
--------------------------------------------------------------------------------
 1 | import React from 'react';
 2 | 
 3 | const ScreenTooSmallWindow = ({screenSize, min_width, min_height}) => {
 4 | 
 5 | 
 6 |     return (
 7 |         <div className="fixed inset-0 bg-gray-900 flex items-center justify-center z-50">
 8 |             <div className="text-center p-8 bg-gray-800 rounded-lg shadow-2xl max-w-md mx-4">
 9 |                 <div className="text-red-500 text-6xl mb-4">⚠️</div>
10 |                 <h1 className="text-2xl font-bold text-white mb-4">Screen Too Small</h1>
11 |                 <p className="text-gray-300 mb-4">
12 |                     This application requires a minimum screen size to function properly.
13 |                 </p>
14 |                 <div className="text-sm text-gray-400 mb-4">
15 |                     <p><strong>Minimum Required:</strong></p>
16 |                     <p>Width: {min_width}px | Height: {min_height}px</p>
17 |                 </div>
18 |                 <div className="text-sm text-gray-500">
19 |                     <p><strong>Current Screen:</strong></p>
20 |                     <p>Width: {screenSize.width}px | Height: {screenSize.height}px</p>
21 |                 </div>
22 |                 <p className="text-yellow-400 text-sm mt-4">
23 |                     Please resize your window or use a larger screen.
24 |                 </p>
25 |             </div>
26 |         </div>
27 |     );
28 | };
29 | 
30 | export default ScreenTooSmallWindow;


--------------------------------------------------------------------------------
/src/components/seeableTexts/blogButton.js:
--------------------------------------------------------------------------------
 1 | 'use client';
 2 | 
 3 | import { useRef, useState } from 'react';
 4 | import { useGazeClick } from '../useGazeClick';
 5 | 
 6 | const BlogButton = ({ debugMode, onBlogClick }) => {
 7 |     const seeableTextRef = useRef(null);
 8 |     const [isActive, setIsActive] = useState(false);
 9 | 
10 |     const handleGazeClick = ({ gazeX, gazeY }) => {
11 |         // Flash effect
12 |         setIsActive(true);
13 |         setTimeout(() => {
14 |             setIsActive(false);
15 |         }, 1000);
16 | 
17 |         // Call the blog click handler if provided
18 |         if (onBlogClick) {
19 |             onBlogClick();
20 |         }
21 |     };
22 | 
23 |     const { isGazeHovered, isClicked } = useGazeClick(seeableTextRef, handleGazeClick);
24 | 
25 |     const getTextGlow = () => {
26 |         if (isGazeHovered) {
27 |             return {
28 |                 textShadow: '0 0 20px rgba(255, 255, 255, 0.8), 0 0 40px rgba(255, 255, 255, 0.4), 0 0 60px rgba(255, 255, 255, 0.2)'
29 |             };
30 |         }
31 |         return {};
32 |     };
33 | 
34 |     return (
35 |         <div
36 |             ref={seeableTextRef}
37 |             className={`w-[550px] h-[400px] ${debugMode ? 'border-2' : ''}`}
38 |             style={{
39 |                 position: 'absolute',
40 |                 top: '50%',
41 |                 left: '50%',
42 |                 transform: 'translate(-50%, -50%)',
43 |             }}
44 |         >
45 |             <div className={`text-white text-center h-full flex justify-center 
46 |             items-center mt-10 space-y-2 transition-all duration-500 ${isGazeHovered ? 'scale-110' : 'scale-100'}`}>
47 |                 <div
48 |                     className="text-8xl font-cor-gar transition-all duration-300"
49 |                     style={getTextGlow()}
50 |                 >
51 |                     {isActive ? 'Opening...' : 'Blog'}
52 |                 </div>
53 |             </div>
54 |         </div>
55 |     );
56 | };
57 | 
58 | export default BlogButton;


--------------------------------------------------------------------------------
/src/components/seeableTexts/lookAndClick.js:
--------------------------------------------------------------------------------
 1 | 'use client';
 2 | 
 3 | import { useRef, useState } from 'react';
 4 | import { useGazeClick } from '../useGazeClick';
 5 | 
 6 | const LookAndClick = ({debugMode}) => {
 7 |     const seeableTextRef = useRef(null);
 8 |     const [isActive, setIsActive] = useState(false);
 9 | 
10 |     const handleGazeClick = ({ gazeX, gazeY }) => {
11 | 
12 |         // Flash effect
13 |         setIsActive(true);
14 |         setTimeout(() => {
15 |             setIsActive(false);
16 |         }, 1000);
17 |     };
18 | 
19 |     const { isGazeHovered, isClicked } = useGazeClick(seeableTextRef, handleGazeClick);
20 | 
21 |     const getTextGlow = () => {
22 |         if (isGazeHovered) {
23 |             return {
24 |                 textShadow: '0 0 20px rgba(255, 255, 255, 0.8), 0 0 40px rgba(255, 255, 255, 0.4), 0 0 60px rgba(255, 255, 255, 0.2)'
25 |             };
26 |         }
27 |         return {};
28 |     };
29 | 
30 |     return (
31 |         <div
32 |             ref={seeableTextRef}
33 |             className={`w-[550px] h-[400px] ${debugMode ? 'border-2' : ''}`}
34 |             style={{
35 |                 position: 'absolute',
36 |                 top: '50%',
37 |                 left: '50%',
38 |                 transform: 'translate(-50%, -50%)',
39 |             }}
40 |         >
41 |             <div className={`text-white text-center h-full flex justify-center 
42 |             items-center mt-10 space-y-2 transition-all duration-500 ${isGazeHovered ? 'scale-110' : 'scale-100'}`}>
43 |                 <div 
44 |                     className="text-8xl font-cor-gar transition-all duration-300"
45 |                     style={getTextGlow()}
46 |                 >
47 |                     {isActive ? 'Click!' : 'Look at me'}
48 |                 </div>
49 |             </div>
50 |         </div>
51 |     );
52 | };
53 | 
54 | export default LookAndClick;


--------------------------------------------------------------------------------
/src/components/seeableTexts/playAGame.js:
--------------------------------------------------------------------------------
  1 | 'use client';
  2 | 
  3 | import { useRef, useState, useEffect } from 'react';
  4 | import { useGazeClick } from '../useGazeClick';
  5 | 
  6 | const PlayAGame = ({ debugMode, gameActive, onGameStart, onGameEnd, onGameProgress }) => {
  7 |     const seeableTextRef = useRef(null);
  8 |     const [isActive, setIsActive] = useState(false);
  9 |     const [clickCount, setClickCount] = useState(0);
 10 |     const [position, setPosition] = useState({ top: '50%', left: '10px' });
 11 |     const [isMoving, setIsMoving] = useState(false);
 12 | 
 13 |     const handleGazeClick = ({ gazeX, gazeY }) => {
 14 |         if (!gameActive) {
 15 |             // Start the game
 16 |             onGameStart();
 17 |             moveToRandomPosition();
 18 |             return;
 19 |         }
 20 | 
 21 |         // Flash effect
 22 |         setIsActive(true);
 23 |         setTimeout(() => {
 24 |             setIsActive(false);
 25 |         }, 500);
 26 | 
 27 |         const newClickCount = clickCount + 1;
 28 |         setClickCount(newClickCount);
 29 |         onGameProgress(newClickCount);
 30 | 
 31 |         if (newClickCount >= 3) {
 32 |             // Game finished, return to center
 33 |             setTimeout(() => {
 34 |                 setIsMoving(true);
 35 |                 setPosition({ top: '50%', left: '10px' });
 36 |                 setTimeout(() => {
 37 |                     setIsMoving(false);
 38 |                     setClickCount(0);
 39 |                     onGameEnd();
 40 |                 }, 1000);
 41 |             }, 500);
 42 |         } else {
 43 |             // Move to next random position
 44 |             setTimeout(() => {
 45 |                 moveToRandomPosition();
 46 |             }, 500);
 47 |         }
 48 |     };
 49 | 
 50 |     const { isGazeHovered, isClicked } = useGazeClick(seeableTextRef, handleGazeClick);
 51 | 
 52 |     const moveToRandomPosition = () => {
 53 |         setIsMoving(true);
 54 |         
 55 |         // Calculate safe bounds to ensure component stays in viewable area
 56 |         // Component is 550px wide, 400px tall
 57 |         const margin = 50;
 58 |         const minTop = margin;
 59 |         const maxTop = window.innerHeight - 400 - margin;
 60 |         const minLeft = margin;
 61 |         const maxLeft = window.innerWidth - 550 - margin;
 62 |         
 63 |         const randomTop = Math.random() * (maxTop - minTop) + minTop;
 64 |         const randomLeft = Math.random() * (maxLeft - minLeft) + minLeft;
 65 |         
 66 |         setPosition({ 
 67 |             top: `${randomTop}px`, 
 68 |             left: `${randomLeft}px` 
 69 |         });
 70 |         
 71 |         setTimeout(() => {
 72 |             setIsMoving(false);
 73 |         }, 1000);
 74 |     };
 75 | 
 76 |     const getTextGlow = () => {
 77 |         if (isGazeHovered) {
 78 |             return {
 79 |                 textShadow: '0 0 20px rgba(255, 255, 255, 0.8), 0 0 40px rgba(255, 255, 255, 0.4), 0 0 60px rgba(255, 255, 255, 0.2)'
 80 |             };
 81 |         }
 82 |         return {};
 83 |     };
 84 | 
 85 |     const getText = () => {
 86 |         if (!gameActive) {
 87 |             return "Let's play a game";
 88 |         }
 89 |         if (isActive) {
 90 |             return 'Good!';
 91 |         }
 92 |         if (clickCount === 0) {
 93 |             return 'Click me!';
 94 |         }
 95 |         if (clickCount === 3) {
 96 |             return "Incredible."
 97 |         }
 98 |         return `${3 - clickCount} more!`;
 99 |     };
100 | 
101 |     return (
102 |         <div
103 |             ref={seeableTextRef}
104 |             className={`w-[550px] h-[400px] ${debugMode ? 'border-2' : ''} transition-all duration-1000 ease-in-out ${
105 |                 isMoving ? 'scale-95' : 'scale-100'
106 |             }`}
107 |             style={{
108 |                 position: 'absolute',
109 |                 top: position.top,
110 |                 left: position.left,
111 | 
112 |                 zIndex: gameActive ? 60 : 10,
113 |             }}
114 |         >
115 |             <div className={`text-white text-center h-full flex justify-center 
116 |             items-center mt-10 space-y-2 transition-all duration-500 ${isGazeHovered ? 'scale-110' : 'scale-100'}`}>
117 |                 <div
118 |                     className="text-8xl font-cor-gar transition-all duration-300"
119 |                     style={getTextGlow()}
120 |                 >
121 |                     {getText()}
122 |                 </div>
123 |             </div>
124 |         </div>
125 |     );
126 | };
127 | 
128 | export default PlayAGame;


--------------------------------------------------------------------------------
/src/components/square.js:
--------------------------------------------------------------------------------
 1 | 'use client';
 2 | 
 3 | import { useRef } from 'react';
 4 | import { useGazeHover } from './useGazeHover';
 5 | 
 6 | const Square = () => {
 7 |     const squareRef = useRef(null);
 8 |     const isGazeHovered = useGazeHover(squareRef);
 9 | 
10 |     return (
11 |         <div
12 |             ref={squareRef}
13 |             className={`w-[500px] h-80 border-2 border-gray-400 transition-colors duration-300 ${
14 |                 isGazeHovered ? 'bg-red-500' : 'bg-blue-500'
15 |             }`}
16 |             style={{
17 |                 position: 'absolute',
18 |                 top: '50%',
19 |                 left: '50%',
20 |                 transform: 'translate(-50%, -50%)',
21 |             }}
22 |         >
23 |             <div className="text-white text-center mt-10 font-red-hat">
24 |                 {isGazeHovered ? 'Looking!' : 'Look at me'}
25 |             </div>
26 |         </div>
27 |     );
28 | };
29 | 
30 | export default Square; 


--------------------------------------------------------------------------------
/src/components/useGazeClick.js:
--------------------------------------------------------------------------------
 1 | 'use client';
 2 | 
 3 | import { useState, useEffect, useCallback } from 'react';
 4 | import { useWebGazer } from './webgazerProvider';
 5 | 
 6 | export const useGazeClick = (elementRef, onGazeClick, threshold = 20) => {
 7 |     const [isGazeHovered, setIsGazeHovered] = useState(false);
 8 |     const [isClicked, setIsClicked] = useState(false);
 9 |     const { isReady, addGazeListener, currentGaze } = useWebGazer();
10 | 
11 |     // Check if gaze is within element bounds
12 |     const checkGazePosition = useCallback((data) => {
13 |         if (!data || !elementRef.current) return;
14 | 
15 |         const rect = elementRef.current.getBoundingClientRect();
16 |         const { x, y } = data;
17 | 
18 |         const isWithinBounds = 
19 |             x >= rect.left - threshold &&
20 |             x <= rect.right + threshold &&
21 |             y >= rect.top - threshold &&
22 |             y <= rect.bottom + threshold;
23 | 
24 |         setIsGazeHovered(isWithinBounds);
25 |     }, [elementRef, threshold]);
26 | 
27 |     // Handle spacebar press for gaze clicking
28 |     const handleKeyPress = useCallback((event) => {
29 |         if (event.code === 'Space' && isGazeHovered && onGazeClick) {
30 |             event.preventDefault(); // Prevent page scroll
31 |             
32 |             // Simulate click effect
33 |             setIsClicked(true);
34 |             
35 |             // Call the click handler
36 |             onGazeClick({
37 |                 gazeX: currentGaze.x,
38 |                 gazeY: currentGaze.y,
39 |                 element: elementRef.current
40 |             });
41 | 
42 |             // Reset click state after animation
43 |             setTimeout(() => {
44 |                 setIsClicked(false);
45 |             }, 200);
46 |         }
47 |     }, [isGazeHovered, onGazeClick, currentGaze, elementRef]);
48 | 
49 |     // Set up gaze listener
50 |     useEffect(() => {
51 |         if (!isReady || !elementRef.current || !addGazeListener) return;
52 | 
53 |         const removeListener = addGazeListener(checkGazePosition);
54 |         return removeListener;
55 |     }, [isReady, elementRef, addGazeListener, checkGazePosition]);
56 | 
57 |     // Set up keyboard listener
58 |     useEffect(() => {
59 |         window.addEventListener('keydown', handleKeyPress);
60 |         return () => {
61 |             window.removeEventListener('keydown', handleKeyPress);
62 |         };
63 |     }, [handleKeyPress]);
64 | 
65 |     return {
66 |         isGazeHovered,
67 |         isClicked
68 |     };
69 | }; 


--------------------------------------------------------------------------------
/src/components/useGazeHover.js:
--------------------------------------------------------------------------------
 1 | 'use client';
 2 | 
 3 | import { useState, useEffect } from 'react';
 4 | import { useWebGazer } from './webgazerProvider';
 5 | 
 6 | export const useGazeHover = (elementRef, threshold = 20) => {
 7 |     const [isHovered, setIsHovered] = useState(false);
 8 |     const { isReady, addGazeListener } = useWebGazer();
 9 | 
10 |     useEffect(() => {
11 |         if (!isReady || !elementRef.current || !addGazeListener) return;
12 | 
13 |         const checkGazePosition = (data) => {
14 |             if (!data || !elementRef.current) return;
15 | 
16 |             const rect = elementRef.current.getBoundingClientRect();
17 |             const { x, y } = data;
18 | 
19 |             // Check if gaze is within the element bounds with a threshold
20 |             const isWithinBounds = 
21 |                 x >= rect.left - threshold &&
22 |                 x <= rect.right + threshold &&
23 |                 y >= rect.top - threshold &&
24 |                 y <= rect.bottom + threshold;
25 | 
26 |             setIsHovered(isWithinBounds);
27 |         };
28 | 
29 |         // Add this component's gaze listener
30 |         const removeListener = addGazeListener(checkGazePosition);
31 | 
32 |         return () => {
33 |             // Clean up this listener
34 |             removeListener();
35 |         };
36 |     }, [isReady, elementRef, threshold, addGazeListener]);
37 | 
38 |     return isHovered;
39 | }; 


--------------------------------------------------------------------------------
/src/components/webgazerProvider.js:
--------------------------------------------------------------------------------
  1 | 'use client';
  2 | 
  3 | import { createContext, useContext, useEffect, useRef, useState } from 'react';
  4 | 
  5 | const WebGazerContext = createContext(null);
  6 | 
  7 | export function WebgazerProvider({ children }) {
  8 |     const webgazerRef = useRef(null);
  9 |     const [isReady, setIsReady] = useState(false);
 10 |     const [currentGaze, setCurrentGaze] = useState({ x: 0, y: 0 });
 11 |     const gazeListenersRef = useRef(new Set());
 12 | 
 13 |     useEffect(() => {
 14 |         let instance;
 15 |         (async () => {
 16 |             try {
 17 |                 const webgazerMod = await import('webgazer');
 18 |                 // assign globally for any internals that expect window.webgazer:
 19 |                 window.webgazer = webgazerMod.default;
 20 |                 instance = webgazerMod.default;
 21 |                 
 22 |                 // Configure WebGazer
 23 |                 instance
 24 |                     .setGazeListener((data) => { 
 25 |                         if (data) {
 26 |                             setCurrentGaze({ x: data.x, y: data.y });
 27 |                             // Call all registered listeners
 28 |                             gazeListenersRef.current.forEach(listener => {
 29 |                                 try {
 30 |                                     listener(data);
 31 |                                 } catch (error) {
 32 |                                     console.error('Error in gaze listener:', error);
 33 |                                 }
 34 |                             });
 35 |                         }
 36 |                     })
 37 |                     .begin();
 38 |                 
 39 |                 // Set WebGazer options
 40 |                 instance.showVideo(false); // Hide video initially
 41 |                 instance.showPredictionPoints(false); // Hide prediction points
 42 |                 instance.showFaceOverlay(false) // Hide face overlay initially
 43 |                 instance.showFaceFeedbackBox(false) // Hide boundary box initially
 44 |                 
 45 |                 // Store the instance
 46 |                 webgazerRef.current = instance;
 47 |                 setIsReady(true);
 48 |                 
 49 |                 console.log("WebGazer initialized successfully");
 50 |             } catch (error) {
 51 |                 console.error("Error initializing WebGazer:", error);
 52 |             }
 53 |         })();
 54 | 
 55 |         return () => {
 56 |             if (instance) {
 57 |                 try {
 58 |                     instance.end();
 59 |                 } catch (error) {
 60 |                     console.error("Error ending WebGazer:", error);
 61 |                 }
 62 |             }
 63 |         };
 64 |     }, []);
 65 | 
 66 |     // Function to add gaze listeners
 67 |     const addGazeListener = (listener) => {
 68 |         gazeListenersRef.current.add(listener);
 69 |         return () => {
 70 |             gazeListenersRef.current.delete(listener);
 71 |         };
 72 |     };
 73 | 
 74 |     // Function to control video display
 75 |     const setVideoVisible = (visible) => {
 76 |         if (webgazerRef.current) {
 77 |             try {
 78 |                 webgazerRef.current.showVideo(visible);
 79 |                 webgazerRef.current.showFaceOverlay(visible)
 80 |                 webgazerRef.current.showFaceFeedbackBox(visible)
 81 |                 console.log(`Video display set to: ${visible}`);
 82 |             } catch (error) {
 83 |                 console.error("Error controlling video display:", error);
 84 |             }
 85 |         }
 86 |     };
 87 | 
 88 |     // Function to control prediction points
 89 |     const setPredictionPointsVisible = (visible) => {
 90 |         if (webgazerRef.current) {
 91 |             try {
 92 |                 webgazerRef.current.showPredictionPoints(visible);
 93 |                 console.log(`Prediction points set to: ${visible}`);
 94 |             } catch (error) {
 95 |                 console.error("Error controlling prediction points:", error);
 96 |             }
 97 |         }
 98 |     };
 99 | 
100 |     // Function to apply/remove Kalman filter
101 |     const setKalmanFilter = (enabled) => {
102 |         if (webgazerRef.current) {
103 |             try {
104 |                 webgazerRef.current.applyKalmanFilter(enabled);
105 |                 console.log(`Kalman filter set to: ${enabled}`);
106 |             } catch (error) {
107 |                 console.error("Error controlling Kalman filter:", error);
108 |             }
109 |         }
110 |     };
111 | 
112 |     // Expose whatever helpers you need, e.g. calibration dots:
113 |     const calibrateAt = (x, y) => {
114 |         if (!webgazerRef.current) {
115 |             console.warn("WebGazer not initialized yet");
116 |             return;
117 |         }
118 |         
119 |         try {
120 |             // Add data point for calibration
121 |             webgazerRef.current.recordScreenPosition(x, y, 'click');
122 |             console.log(`Calibration point added at: ${x}, ${y}`);
123 |         } catch (error) {
124 |             console.error("Error during calibration:", error);
125 |         }
126 |     };
127 | 
128 |     return (
129 |         <WebGazerContext.Provider value={{ 
130 |             calibrateAt, 
131 |             instance: webgazerRef.current,
132 |             isReady,
133 |             currentGaze,
134 |             addGazeListener,
135 |             setVideoVisible,
136 |             setPredictionPointsVisible,
137 |             setKalmanFilter
138 |         }}>
139 |             {children}
140 |         </WebGazerContext.Provider>
141 |     );
142 | }
143 | 
144 | // custom hook for convenience
145 | export const useWebGazer = () => {
146 |     const ctx = useContext(WebGazerContext);
147 |     if (!ctx) throw new Error("useWebGazer must be inside WebGazerProvider");
148 |     return ctx;
149 | };
150 | 


--------------------------------------------------------------------------------
/tailwind.config.mjs:
--------------------------------------------------------------------------------
 1 | /** @type {import('tailwindcss').Config} */
 2 | export default {
 3 |   content: [
 4 |     "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
 5 |     "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
 6 |     "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
 7 |   ],
 8 |   theme: {
 9 |     extend: {
10 |       colors: {
11 |         background: "var(--background)",
12 |         foreground: "var(--foreground)",
13 |       },
14 |       keyframes: {
15 |         grow: {
16 |           '0%': { transform: 'scale(1)' },
17 |           '100%': { transform: 'scale(1.2)' },
18 |         },
19 |       },
20 |       animation: {
21 |         'grow-once': 'grow 5s ease-in forwards',
22 |       },
23 |       fontFamily: {
24 |         'red-hat': ['var(--font-red-hat)'],
25 |         'cor-gar': ['var(--font-cormorant-garamond)']
26 |       }
27 |     },
28 |   },
29 |   plugins: [],
30 | };
31 | 


--------------------------------------------------------------------------------