From 8c3179852853f080aeb05c8ff174d2c23fb4791c Mon Sep 17 00:00:00 2001 From: lukitasxue Date: Wed, 3 Jun 2026 18:46:43 +1000 Subject: [PATCH 1/3] Onboarding flow updates: added new onboarding steps, updated styles, and removed old assets and components. The new onboarding steps include Camera Setup, Distraction Options, Focus Environment, and Welcome. The styles for the onboarding flow have been updated to enhance the visual appeal and user experience. Old assets such as hero.png, react.svg, and vite.svg have been removed to streamline the design. Additionally, the Onboarding component has been deleted in favor of a more modular approach with individual components for each onboarding step. --- electron/src/main/index.ts | 4 +- electron/src/renderer/App.css | 186 --- electron/src/renderer/App.tsx | 6 +- electron/src/renderer/assets/hero.png | Bin 13057 -> 0 bytes electron/src/renderer/assets/react.svg | 1 - electron/src/renderer/assets/vite.svg | 1 - .../src/renderer/components/Onboarding.tsx | 4 - .../components/onboarding/CameraSetupStep.tsx | 68 ++ .../onboarding/DistractionOptionsStep.tsx | 60 + .../onboarding/FocusEnvironmentStep.tsx | 95 ++ .../components/onboarding/WelcomeStep.tsx | 31 + electron/src/renderer/index.css | 1026 ++++++++++++++++- electron/src/renderer/pages/MenuPage.tsx | 10 + .../src/renderer/pages/OnboardingPage.tsx | 112 ++ 14 files changed, 1406 insertions(+), 198 deletions(-) delete mode 100644 electron/src/renderer/App.css delete mode 100644 electron/src/renderer/assets/hero.png delete mode 100644 electron/src/renderer/assets/react.svg delete mode 100644 electron/src/renderer/assets/vite.svg delete mode 100644 electron/src/renderer/components/Onboarding.tsx create mode 100644 electron/src/renderer/components/onboarding/CameraSetupStep.tsx create mode 100644 electron/src/renderer/components/onboarding/DistractionOptionsStep.tsx create mode 100644 electron/src/renderer/components/onboarding/FocusEnvironmentStep.tsx create mode 100644 electron/src/renderer/components/onboarding/WelcomeStep.tsx create mode 100644 electron/src/renderer/pages/MenuPage.tsx create mode 100644 electron/src/renderer/pages/OnboardingPage.tsx diff --git a/electron/src/main/index.ts b/electron/src/main/index.ts index 96d0dce..ae80b61 100644 --- a/electron/src/main/index.ts +++ b/electron/src/main/index.ts @@ -29,6 +29,8 @@ function createWindow() { const win = new BrowserWindow({ width: 1200, height: 800, + minWidth: 1000, + minHeight: 700, webPreferences: { nodeIntegration: false, contextIsolation: true, @@ -41,4 +43,4 @@ function createWindow() { app.whenReady().then(() => { createWindow() createTray() -}) \ No newline at end of file +}) diff --git a/electron/src/renderer/App.css b/electron/src/renderer/App.css deleted file mode 100644 index 75e194e..0000000 --- a/electron/src/renderer/App.css +++ /dev/null @@ -1,186 +0,0 @@ -/* Currently leftover from Vite scaffold. Can be deleted once you confirm it's not used. */ - -.counter { - font-size: 16px; - padding: 5px 10px; - border-radius: 5px; - color: var(--accent); - background: var(--accent-bg); - border: 2px solid transparent; - transition: border-color 0.3s; - margin-bottom: 24px; - - &:hover { - border-color: var(--accent-border); - } - &:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - } -} - -.hero { - position: relative; - - .base, - .framework, - .vite { - inset-inline: 0; - margin: 0 auto; - } - - .base { - width: 170px; - position: relative; - z-index: 0; - } - - .framework, - .vite { - position: absolute; - } - - .framework { - z-index: 1; - top: 34px; - height: 28px; - transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) - scale(1.4); - } - - .vite { - z-index: 0; - top: 107px; - height: 26px; - width: auto; - transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) - scale(0.8); - } -} - -#center { - display: flex; - flex-direction: column; - gap: 25px; - place-content: center; - place-items: center; - flex-grow: 1; - - @media (max-width: 1024px) { - padding: 32px 20px 24px; - gap: 18px; - } -} - -#next-steps { - display: flex; - border-top: 1px solid var(--border); - text-align: left; - - & > div { - flex: 1 1 0; - padding: 32px; - @media (max-width: 1024px) { - padding: 24px 20px; - } - } - - .icon { - margin-bottom: 16px; - width: 22px; - height: 22px; - } - - @media (max-width: 1024px) { - flex-direction: column; - text-align: center; - } -} - -#docs { - border-right: 1px solid var(--border); - - @media (max-width: 1024px) { - border-right: none; - border-bottom: 1px solid var(--border); - } -} - -#next-steps ul { - list-style: none; - padding: 0; - display: flex; - gap: 8px; - margin: 32px 0 0; - - .logo { - height: 18px; - } - - a { - color: var(--text-h); - font-size: 16px; - border-radius: 6px; - background: var(--social-bg); - display: flex; - padding: 6px 12px; - align-items: center; - gap: 8px; - text-decoration: none; - transition: box-shadow 0.3s; - - &:hover { - box-shadow: var(--shadow); - } - .button-icon { - height: 18px; - width: 18px; - } - } - - @media (max-width: 1024px) { - margin-top: 20px; - flex-wrap: wrap; - justify-content: center; - - li { - flex: 1 1 calc(50% - 8px); - } - - a { - width: 100%; - justify-content: center; - box-sizing: border-box; - } - } -} - -#spacer { - height: 88px; - border-top: 1px solid var(--border); - @media (max-width: 1024px) { - height: 48px; - } -} - -.ticks { - position: relative; - width: 100%; - - &::before, - &::after { - content: ''; - position: absolute; - top: -4.5px; - border: 5px solid transparent; - } - - &::before { - left: 0; - border-left-color: var(--border); - } - &::after { - right: 0; - border-right-color: var(--border); - } -} diff --git a/electron/src/renderer/App.tsx b/electron/src/renderer/App.tsx index bc4c9be..31fcba1 100644 --- a/electron/src/renderer/App.tsx +++ b/electron/src/renderer/App.tsx @@ -5,7 +5,7 @@ import Dashboard from './components/Dashboard' import SessionControls from './components/SessionControls' import Settings from './components/Settings' import SessionHistory from './components/SessionHistory' -import Onboarding from './components/Onboarding' +import OnboardingPage from './pages/OnboardingPage' function Sidebar() { return ( @@ -36,7 +36,7 @@ export default function App() { return ( - } /> + } /> } /> } /> } /> @@ -44,4 +44,4 @@ export default function App() { ) -} \ No newline at end of file +} diff --git a/electron/src/renderer/assets/hero.png b/electron/src/renderer/assets/hero.png deleted file mode 100644 index 02251f4b956c55af2d76fd0788124d7eee2b45eb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13057 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 P00000NkvXXu0mjft=yBf diff --git a/electron/src/renderer/assets/react.svg b/electron/src/renderer/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/electron/src/renderer/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/electron/src/renderer/assets/vite.svg b/electron/src/renderer/assets/vite.svg deleted file mode 100644 index 5101b67..0000000 --- a/electron/src/renderer/assets/vite.svg +++ /dev/null @@ -1 +0,0 @@ -Vite diff --git a/electron/src/renderer/components/Onboarding.tsx b/electron/src/renderer/components/Onboarding.tsx deleted file mode 100644 index ba7fea5..0000000 --- a/electron/src/renderer/components/Onboarding.tsx +++ /dev/null @@ -1,4 +0,0 @@ -// Fullscreen first-run flow — welcome, camera permission prompt, calibration, allowed apps picker. -export default function Onboarding() { - return
Onboarding
-} \ No newline at end of file diff --git a/electron/src/renderer/components/onboarding/CameraSetupStep.tsx b/electron/src/renderer/components/onboarding/CameraSetupStep.tsx new file mode 100644 index 0000000..c61019a --- /dev/null +++ b/electron/src/renderer/components/onboarding/CameraSetupStep.tsx @@ -0,0 +1,68 @@ +type CameraSetupStepProps = { + onBack: () => void + onContinue: () => void +} + +export default function CameraSetupStep({ + onBack, + onContinue, +}: CameraSetupStepProps) { + return ( +
+

Step 2

+
+ +
+
+ + + +
+
+

Camera setup

+

+ Choose the camera Taskmaster will use during focus sessions. +

+
+

+ Taskmaster uses your camera to estimate whether you are present + during a focus session. +

+
+ + +
+ + +
+
+
+ ) +} diff --git a/electron/src/renderer/components/onboarding/DistractionOptionsStep.tsx b/electron/src/renderer/components/onboarding/DistractionOptionsStep.tsx new file mode 100644 index 0000000..bebc18a --- /dev/null +++ b/electron/src/renderer/components/onboarding/DistractionOptionsStep.tsx @@ -0,0 +1,60 @@ +type DistractionOptionsStepProps = { + onBack: () => void + onFinish: () => void +} + +export default function DistractionOptionsStep({ + onBack, + onFinish, +}: DistractionOptionsStepProps) { + return ( +
+

Step 4

+
+
+
+

+ Distraction options +

+

+ Choose how Taskmaster should respond when your focus session + starts to drift. +

+
+

+ These options are placeholders for future app and browser behavior. +

+
+ +
+
+

Session guardrails

+

Future rules for focus session behavior.

+
+ +
+ + + +
+
+ +
+ + +
+
+
+ ) +} diff --git a/electron/src/renderer/components/onboarding/FocusEnvironmentStep.tsx b/electron/src/renderer/components/onboarding/FocusEnvironmentStep.tsx new file mode 100644 index 0000000..1fa4964 --- /dev/null +++ b/electron/src/renderer/components/onboarding/FocusEnvironmentStep.tsx @@ -0,0 +1,95 @@ +type FocusEnvironmentStepProps = { + onBack: () => void + onContinue: () => void +} + +const browserItems = [ + { label: 'Chrome: GitHub', allowed: true }, + { label: 'Chrome: YouTube', allowed: false }, + { label: 'Chrome: OnTrack', allowed: true }, +] + +const appItems = [ + { label: 'VS Code', allowed: true }, + { label: 'Discord', allowed: false }, +] + +export default function FocusEnvironmentStep({ + onBack, + onContinue, +}: FocusEnvironmentStepProps) { + return ( +
+

Step 3

+
+
+
+

+ Focus environment +

+

+ Choose which apps or tabs are allowed during your deep work + sessions. +

+
+

+ Taskmaster will use this list to understand when you are working and + when you might be drifting. +

+
+ +
+
+ + + +
+ +
+
+

Browser tabs

+
+ {browserItems.map((item) => ( + + ))} +
+
+ +
+

Apps

+
+ {appItems.map((item) => ( + + ))} +
+
+
+
+ +
+ + +
+
+
+ ) +} diff --git a/electron/src/renderer/components/onboarding/WelcomeStep.tsx b/electron/src/renderer/components/onboarding/WelcomeStep.tsx new file mode 100644 index 0000000..56c24b8 --- /dev/null +++ b/electron/src/renderer/components/onboarding/WelcomeStep.tsx @@ -0,0 +1,31 @@ +type WelcomeStepProps = { + onStartSetup: () => void +} + +export default function WelcomeStep({ onStartSetup }: WelcomeStepProps) { + return ( +
+
+
+

Taskmaster

+
+

Protect your deep work.

+

+ Taskmaster helps you notice distractions before they take over your + focus session. +

+
+

+ A private focus assistant that runs locally and helps you stay + present while working. +

+
+ +
+
+
+
+ ) +} diff --git a/electron/src/renderer/index.css b/electron/src/renderer/index.css index 9aaae3f..3800e3d 100644 --- a/electron/src/renderer/index.css +++ b/electron/src/renderer/index.css @@ -1,2 +1,1024 @@ -/*Imports Tailwind. Global styles.*/ -@import "tailwindcss"; \ No newline at end of file +/* Imports Tailwind. Global styles and design tokens. */ +@import "tailwindcss"; + +:root { + color-scheme: dark; + + --color-bg-main: #0B0B0B; + --color-bg-sidebar: #111111; + --color-bg-card: #171717; + --color-bg-elevated: #202020; + + --color-text-main: #F5F1E8; + --color-text-muted: #A8A29A; + --color-text-disabled: #6B6660; + + --color-accent: #D6A935; + --color-accent-bright: #F2C94C; + --color-accent-muted: #2A2414; + + --color-border: #2D2D2D; + --color-border-accent: #3A3424; + + --color-focused: #7FA66A; + --color-distracted: #D66A4A; + --color-neutral: #8AA4C8; + + --radius-sm: 8px; + --radius-md: 14px; + --radius-lg: 22px; + + --space-xs: 0.5rem; + --space-sm: 0.75rem; + --space-md: 1rem; + --space-lg: 1.5rem; + --space-xl: 2rem; + --space-2xl: 3rem; + + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-size: clamp(15px, 0.9vw + 0.55rem, 17px); + line-height: 1.5; + font-weight: 400; + color: var(--color-text-main); + background: var(--color-bg-main); + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + min-width: 0; + background: var(--color-bg-main); +} + +body { + min-width: 0; + min-height: 100vh; + margin: 0; + overflow-x: hidden; + color: var(--color-text-main); + background: + radial-gradient(circle at 16% 0%, rgba(214, 169, 53, 0.08), transparent 28rem), + linear-gradient(180deg, #101010 0%, var(--color-bg-main) 38%); +} + +#root { + min-height: 100vh; + isolation: isolate; +} + +button, +input, +textarea, +select { + font: inherit; +} + +button { + border: 0; +} + +button:not(:disabled) { + cursor: pointer; +} + +button:disabled, +input:disabled, +textarea:disabled, +select:disabled { + cursor: not-allowed; +} + +a { + color: inherit; +} + +::selection { + color: var(--color-bg-main); + background: var(--color-accent-bright); +} + +:focus-visible { + outline: 2px solid var(--color-accent-bright); + outline-offset: 3px; +} + +.app-shell { + display: grid; + min-height: 100vh; + grid-template-columns: minmax(14rem, 18rem) minmax(0, 1fr); + color: var(--color-text-main); + background: var(--color-bg-main); +} + +.onboarding-screen { + position: relative; + display: grid; + min-height: 100vh; + place-items: center; + padding: clamp(var(--space-lg), 4vw, var(--space-2xl)); + overflow: hidden; + color: var(--color-text-main); + background: var(--color-bg-main); +} + +.onboarding-card { + width: min(100%, 44rem); + padding: clamp(var(--space-lg), 3vw, var(--space-2xl)); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-bg-card); + box-shadow: 0 1.5rem 4rem rgba(0, 0, 0, 0.34); +} + +.onboarding-header { + display: grid; + gap: var(--space-sm); + margin-bottom: clamp(var(--space-lg), 4vw, var(--space-2xl)); +} + +.onboarding-title { + max-width: 12ch; + margin: 0; + color: var(--color-text-main); + font-size: clamp(2.25rem, 5vw, 4.75rem); + line-height: 0.95; + font-weight: 750; +} + +.onboarding-subtitle { + max-width: 42rem; + margin: 0; + color: var(--color-text-muted); + font-size: clamp(1rem, 1.2vw, 1.18rem); +} + +.onboarding-body { + display: grid; + gap: var(--space-lg); + color: var(--color-text-main); +} + +.onboarding-actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-sm); + align-items: center; + margin-top: clamp(var(--space-lg), 3vw, var(--space-xl)); +} + +.primary-button, +.secondary-button { + display: inline-flex; + min-height: 2.75rem; + align-items: center; + justify-content: center; + gap: var(--space-xs); + padding: 0.75rem 1rem; + border-radius: var(--radius-sm); + font-weight: 700; + line-height: 1; + text-decoration: none; + transition: + background-color 160ms ease, + border-color 160ms ease, + color 160ms ease, + transform 160ms ease; +} + +.primary-button { + color: #161207; + background: var(--color-accent); +} + +.primary-button:hover { + background: var(--color-accent-bright); + transform: translateY(-1px); +} + +.primary-button:disabled { + color: var(--color-text-disabled); + background: var(--color-accent-muted); + transform: none; +} + +.secondary-button { + color: var(--color-text-main); + border: 1px solid var(--color-border); + background: var(--color-bg-elevated); +} + +.secondary-button:hover { + border-color: var(--color-border-accent); + background: var(--color-bg-card); +} + +.secondary-button:disabled { + color: var(--color-text-disabled); + border-color: var(--color-border); + background: var(--color-bg-card); +} + +.status-pill { + display: inline-flex; + min-height: 2rem; + align-items: center; + gap: var(--space-xs); + padding: 0.35rem 0.75rem; + border: 1px solid var(--color-border-accent); + border-radius: 999px; + color: var(--color-accent-bright); + background: var(--color-accent-muted); + font-size: clamp(0.78rem, 0.7vw, 0.9rem); + font-weight: 700; +} + +.onboarding-step-pill { + position: absolute; + top: clamp(var(--space-lg), 4vw, var(--space-2xl)); + right: clamp(var(--space-lg), 4vw, var(--space-2xl)); + z-index: 3; +} + +.surface-card { + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg-card); + box-shadow: 0 1rem 2.5rem rgba(0, 0, 0, 0.24); +} + +.muted-text { + color: var(--color-text-muted); +} + +.accent-text { + color: var(--color-accent-bright); +} + +.onboarding-flow { + position: relative; + min-height: 100vh; + overflow: hidden; + color: var(--color-text-main); + background: + radial-gradient(circle at 50% 30%, color-mix(in srgb, var(--color-accent) 7%, transparent), transparent 28rem), + linear-gradient(180deg, #050505 0%, var(--color-bg-main) 45%, #070707 100%); +} + +.onboarding-flow .onboarding-screen, +.onboarding-flow .menu-placeholder-screen { + background: transparent; +} + +.onboarding-light { + position: absolute; + top: 0; + left: 50%; + z-index: 0; + width: clamp(44rem, 58vw, 60rem); + height: 100vh; + pointer-events: none; + background: + radial-gradient(ellipse at 50% 46%, color-mix(in srgb, var(--color-accent-bright) 20%, transparent), transparent 44%), + linear-gradient(180deg, color-mix(in srgb, var(--color-accent-bright) 26%, transparent), color-mix(in srgb, var(--color-accent) 13%, transparent) 62%, transparent 100%); + clip-path: polygon(39% 0%, 61% 0%, 100% 100%, 0% 100%); + filter: blur(0.12rem); + opacity: 0.9; + transform: translateX(-50%) rotate(0deg); + transform-origin: 50% 0%; + transition: + left 650ms cubic-bezier(0.22, 1, 0.36, 1), + top 650ms cubic-bezier(0.22, 1, 0.36, 1), + width 650ms cubic-bezier(0.22, 1, 0.36, 1), + height 650ms cubic-bezier(0.22, 1, 0.36, 1), + opacity 650ms cubic-bezier(0.22, 1, 0.36, 1), + filter 650ms cubic-bezier(0.22, 1, 0.36, 1), + border-radius 650ms cubic-bezier(0.22, 1, 0.36, 1), + clip-path 650ms cubic-bezier(0.22, 1, 0.36, 1), + transform 650ms cubic-bezier(0.22, 1, 0.36, 1); +} + +.onboarding-light::after { + position: absolute; + inset: 0; + content: ""; + background: + repeating-linear-gradient( + 90deg, + transparent 0 1.25rem, + color-mix(in srgb, var(--color-accent-bright) 17%, transparent) 1.25rem 1.32rem, + transparent 1.32rem 2.25rem + ), + radial-gradient(ellipse at 50% 82%, color-mix(in srgb, var(--color-accent) 15%, transparent), transparent 54%); + opacity: 0.5; +} + +.onboarding-light--hero { + left: 50%; + top: 0; + width: clamp(44rem, 62vw, 70rem); + height: 100vh; + border-radius: 0; + clip-path: polygon(39% 0%, 61% 0%, 100% 100%, 0% 100%); + opacity: 0.9; + transform: translateX(-50%) rotate(0deg); +} + +.onboarding-light--top-right { + left: 71%; + top: -4vh; + width: clamp(38rem, 54vw, 62rem); + height: 230vh; + border-radius: 0; + clip-path: polygon(40% -15%, 57% 0%, 160% 100%, -50% 140%); + opacity: 0.36; + transform: translateX(-50%) rotate(15deg); +} + +.onboarding-light--top-left { + left: 15%; + top: -4vh; + width: clamp(38rem, 54vw, 62rem); + height: 200vh; + border-radius: 0; + clip-path: polygon(43% 0%, 57% 0%, 105% 100%, -10% 100%); + opacity: 0.34; + transform: translateX(-50%) rotate(-30deg); +} + +.onboarding-light--ambient { + left: 50%; + top: 14vh; + width: clamp(34rem, 48vw, 56rem); + height: clamp(26rem, 54vh, 40rem); + border-radius: 999px; + background: + radial-gradient(ellipse at 50% 50%, color-mix(in srgb, var(--color-accent-bright) 20%, transparent), transparent 34%), + radial-gradient(ellipse at 50% 56%, color-mix(in srgb, var(--color-accent) 18%, transparent), transparent 68%); + clip-path: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%); + filter: blur(1.2rem); + opacity: 0.42; + transform: translateX(-50%) rotate(0deg); +} + +.onboarding-light--ambient::after, +.onboarding-light--off::after { + opacity: 0; +} + +.onboarding-light--off { + left: 50%; + top: 18vh; + width: clamp(28rem, 40vw, 44rem); + height: clamp(20rem, 42vh, 32rem); + border-radius: 999px; + clip-path: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%); + opacity: 0; + transform: translateX(-50%) rotate(0deg); +} + +.onboarding-step { + position: absolute; + inset: 0; + z-index: 1; +} + +.onboarding-step--exit { + pointer-events: none; +} + +.onboarding-step--enter-forward { + animation: onboarding-enter-forward 620ms cubic-bezier(0.22, 1, 0.36, 1) both; +} + +.onboarding-step--exit-forward { + animation: onboarding-exit-forward 620ms cubic-bezier(0.22, 1, 0.36, 1) both; +} + +.onboarding-step--enter-backward { + animation: onboarding-enter-backward 620ms cubic-bezier(0.22, 1, 0.36, 1) both; +} + +.onboarding-step--exit-backward { + animation: onboarding-exit-backward 620ms cubic-bezier(0.22, 1, 0.36, 1) both; +} + +@keyframes onboarding-enter-forward { + from { + opacity: 0; + transform: translateX(1.35rem); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes onboarding-exit-forward { + from { + opacity: 1; + transform: translateX(0); + } + to { + opacity: 0; + transform: translateX(-1.1rem); + } +} + +@keyframes onboarding-enter-backward { + from { + opacity: 0; + transform: translateX(-1.35rem); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes onboarding-exit-backward { + from { + opacity: 1; + transform: translateX(0); + } + to { + opacity: 0; + transform: translateX(1.1rem); + } +} + +.onboarding-welcome-screen { + padding: 0; +} + +.onboarding-welcome-card { + --hero-content-width: clamp(30rem, 42vw, 38rem); + + position: relative; + display: grid; + width: 100%; + min-height: 100vh; + align-content: center; + justify-items: center; + padding: clamp(var(--space-xl), 6vh, 4rem) clamp(var(--space-xl), 5vw, 4rem); + overflow: hidden; + text-align: center; +} + +.onboarding-copy { + position: relative; + z-index: 1; + display: grid; + align-content: center; + max-width: 34rem; +} + +.onboarding-welcome-content { + position: relative; + z-index: 1; + display: grid; + width: min(100%, var(--hero-content-width)); + justify-items: center; +} + +.onboarding-app-name { + position: relative; + margin: 0 0 var(--space-md); + font-size: clamp(0.78rem, 0.72vw, 0.92rem); + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.onboarding-welcome-card .onboarding-app-name { + color: var(--color-accent-bright); + text-shadow: 0 0 1.25rem color-mix(in srgb, var(--color-accent) 42%, transparent); +} + +.onboarding-welcome-card .onboarding-header { + justify-items: center; + margin-bottom: clamp(var(--space-md), 3vh, var(--space-xl)); +} + +.onboarding-welcome-card .onboarding-title { + max-width: 10ch; + text-wrap: balance; + text-shadow: 0 0.18rem 1.7rem rgba(0, 0, 0, 0.72); +} + +.onboarding-welcome-card .onboarding-subtitle { + max-width: min(100%, 34rem); + color: color-mix(in srgb, var(--color-text-main) 88%, var(--color-text-muted)); + text-wrap: pretty; + text-shadow: 0 0.12rem 1.2rem rgba(0, 0, 0, 0.76); +} + +.onboarding-privacy-line { + position: relative; + max-width: min(100%, 34rem); + margin: 0; + font-size: clamp(0.92rem, 0.9vw, 1rem); +} + +.onboarding-welcome-card .onboarding-privacy-line { + max-width: min(100%, 32rem); + color: color-mix(in srgb, var(--color-text-main) 72%, var(--color-text-muted)); + text-shadow: 0 0.12rem 1rem rgba(0, 0, 0, 0.72); +} + +.onboarding-welcome-card .onboarding-actions { + justify-content: center; +} + +.onboarding-placeholder-card { + display: grid; + gap: var(--space-lg); +} + +.camera-setup-screen { + background: + radial-gradient(circle at 50% 18%, color-mix(in srgb, var(--color-accent) 10%, transparent), transparent 24rem), + linear-gradient(180deg, #080808 0%, var(--color-bg-main) 48%, #070707 100%); +} + +.camera-setup-layout { + display: grid; + width: min(100%, 66rem); + grid-template-columns: minmax(20rem, 0.86fr) minmax(28rem, 1.14fr); + gap: clamp(var(--space-xl), 5vw, 4.5rem); + align-items: center; +} + +.camera-setup-header { + display: grid; + gap: var(--space-lg); +} + +.camera-setup-header .onboarding-header { + margin-bottom: 0; +} + +.camera-setup-header .onboarding-title { + max-width: 11ch; +} + +.camera-setup-explainer { + max-width: 30rem; + margin: 0; + font-size: clamp(0.98rem, 1vw, 1.08rem); +} + +.camera-setup-panel { + display: grid; + gap: var(--space-lg); + padding: clamp(var(--space-lg), 3vw, var(--space-xl)); +} + +.camera-preview-card { + position: relative; + display: grid; + width: min(100%, 38rem); + aspect-ratio: 16 / 9; + place-items: center; + overflow: hidden; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: + radial-gradient(circle at 50% 38%, color-mix(in srgb, var(--color-accent-muted) 54%, transparent), transparent 16rem), + linear-gradient(145deg, var(--color-bg-elevated), var(--color-bg-card)); +} + +.camera-preview-card::before { + position: absolute; + inset: 0; + content: ""; + background: + linear-gradient(90deg, transparent, color-mix(in srgb, var(--color-accent) 10%, transparent), transparent), + repeating-linear-gradient( + 0deg, + transparent 0 1.4rem, + color-mix(in srgb, var(--color-border) 50%, transparent) 1.4rem 1.45rem + ); + opacity: 0.34; +} + +.camera-preview-placeholder { + position: relative; + z-index: 1; + display: grid; + width: clamp(5.5rem, 12vw, 8rem); + aspect-ratio: 1; + place-items: center; + border: 1px solid var(--color-border-accent); + border-radius: 999px; + background: color-mix(in srgb, var(--color-bg-main) 68%, transparent); + box-shadow: 0 1rem 2.5rem rgba(0, 0, 0, 0.32); +} + +.camera-placeholder-lens { + width: 38%; + aspect-ratio: 1; + border: 0.25rem solid var(--color-accent); + border-radius: 999px; + background: var(--color-bg-sidebar); +} + +.camera-placeholder-base { + position: absolute; + bottom: 19%; + width: 52%; + height: 0.34rem; + border-radius: 999px; + background: var(--color-border-accent); +} + +.camera-preview-label { + position: absolute; + right: var(--space-md); + bottom: var(--space-sm); + z-index: 1; + margin: 0; + font-size: clamp(0.78rem, 0.72vw, 0.88rem); +} + +.camera-select-field { + display: grid; + gap: var(--space-xs); + color: var(--color-text-main); + font-size: clamp(0.9rem, 0.8vw, 1rem); + font-weight: 700; +} + +.camera-select-field select { + width: 100%; + min-height: 2.85rem; + padding: 0.72rem 0.85rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + color: var(--color-text-main); + background: var(--color-bg-elevated); +} + +.camera-select-field select:hover { + border-color: var(--color-border-accent); +} + +.camera-status-line { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + color: var(--color-text-main); + font-weight: 700; +} + +.camera-status-dot { + width: 0.65rem; + aspect-ratio: 1; + border-radius: 999px; + background: var(--color-focused); + box-shadow: 0 0 1rem color-mix(in srgb, var(--color-focused) 58%, transparent); +} + +.camera-privacy-note { + margin: 0; + padding-top: var(--space-sm); + border-top: 1px solid var(--color-border); + font-size: clamp(0.88rem, 0.82vw, 0.98rem); +} + +.onboarding-fixed-actions { + position: fixed; + right: clamp(var(--space-lg), 4vw, var(--space-2xl)); + bottom: clamp(var(--space-lg), 4vh, var(--space-xl)); + z-index: 4; + justify-content: flex-end; + margin-top: 0; +} + +.onboarding-fixed-actions .primary-button, +.onboarding-fixed-actions .secondary-button { + flex: 0 0 auto; + min-width: 9.5rem; +} + +.focus-environment-screen { + background: + radial-gradient(circle at 62% 18%, color-mix(in srgb, var(--color-accent) 9%, transparent), transparent 24rem), + linear-gradient(180deg, #080808 0%, var(--color-bg-main) 50%, #070707 100%); +} + +.focus-environment-layout, +.distraction-options-layout { + display: grid; + width: min(100%, 66rem); + grid-template-columns: minmax(20rem, 0.84fr) minmax(28rem, 1.16fr); + gap: clamp(var(--space-xl), 5vw, 4.5rem); + align-items: center; +} + +.focus-environment-header, +.distraction-options-header { + display: grid; + max-width: 31rem; + gap: var(--space-md); +} + +.focus-environment-header .onboarding-header, +.distraction-options-header .onboarding-header { + margin-bottom: 0; +} + +.focus-environment-title { + max-width: 14ch; + font-size: clamp(2.2rem, 4.2vw, 4.1rem); +} + +.focus-environment-explainer { + max-width: 30rem; + margin: 0; + font-size: clamp(0.96rem, 0.9vw, 1.05rem); +} + +.focus-settings-card, +.allowed-environment-panel { + display: grid; + align-content: start; + gap: var(--space-md); + padding: clamp(var(--space-lg), 2.4vw, var(--space-xl)); +} + +.allowed-environment-panel { + min-height: 24rem; + align-content: space-between; +} + +.focus-card-header { + display: grid; + gap: var(--space-xs); +} + +.focus-card-header h2 { + margin: 0; + color: var(--color-text-main); + font-size: clamp(1.1rem, 1.4vw, 1.35rem); +} + +.focus-card-header p { + margin: 0; + font-size: clamp(0.86rem, 0.78vw, 0.95rem); +} + +.distraction-options { + display: grid; + gap: var(--space-sm); +} + +.distraction-option { + display: grid; + min-height: 2.65rem; + align-items: center; + gap: var(--space-sm); + padding: 0.62rem 0.75rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-bg-elevated); + color: var(--color-text-main); +} + +.distraction-option { + grid-template-columns: auto minmax(0, 1fr) auto; +} + +.distraction-option input { + width: 1rem; + aspect-ratio: 1; + accent-color: var(--color-accent); +} + +.coming-soon-pill { + justify-self: end; + white-space: nowrap; + font-size: clamp(0.74rem, 0.68vw, 0.84rem); + font-weight: 800; +} + +.coming-soon-pill { + padding: 0.2rem 0.5rem; + border: 1px solid var(--color-border-accent); + border-radius: 999px; + color: var(--color-accent-bright); + background: var(--color-accent-muted); +} + +.focus-select-field { + display: grid; + gap: var(--space-xs); + color: var(--color-text-main); + font-weight: 700; +} + +.allowed-panel-top { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: var(--space-md); + align-items: end; +} + +.allowed-panel-top .secondary-button { + min-width: 11rem; +} + +.allowed-groups { + display: grid; + gap: var(--space-lg); +} + +.allowed-group { + display: grid; + gap: var(--space-xs); +} + +.allowed-group p { + margin: 0; + color: var(--color-text-muted); + font-size: clamp(0.84rem, 0.76vw, 0.94rem); + font-weight: 800; +} + +.allowed-compact-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); + gap: var(--space-xs) var(--space-sm); +} + +.allowed-compact-row { + display: grid; + min-height: 2.25rem; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: var(--space-sm); + padding: 0.45rem 0.6rem; + border: 1px solid color-mix(in srgb, var(--color-border) 78%, transparent); + border-radius: var(--radius-sm); + color: var(--color-text-main); + background: color-mix(in srgb, var(--color-bg-elevated) 70%, transparent); +} + +.allowed-compact-row span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.allowed-compact-row input { + width: 1rem; + aspect-ratio: 1; + accent-color: var(--color-accent); +} + +.focus-select-field select { + width: 100%; + min-height: 2.75rem; + padding: 0.68rem 0.85rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + color: var(--color-text-main); + background: var(--color-bg-elevated); +} + +.focus-select-field select:hover { + border-color: var(--color-border-accent); +} + +.distraction-options-screen { + background: + radial-gradient(circle at 48% 18%, color-mix(in srgb, var(--color-accent) 9%, transparent), transparent 24rem), + linear-gradient(180deg, #080808 0%, var(--color-bg-main) 50%, #070707 100%); +} + +.distraction-card { + min-height: 18rem; +} + +.menu-placeholder-screen { + display: grid; + min-height: 100vh; + place-items: center; + padding: var(--space-2xl); + color: var(--color-text-main); + background: + radial-gradient(circle at 50% 30%, color-mix(in srgb, var(--color-accent) 9%, transparent), transparent 24rem), + var(--color-bg-main); +} + +.menu-placeholder-content { + display: grid; + justify-items: center; + gap: var(--space-sm); + text-align: center; +} + +.menu-placeholder-content h1 { + margin: 0; + font-size: clamp(3rem, 7vw, 6rem); + line-height: 0.95; +} + +.menu-placeholder-content p { + margin: 0; + color: var(--color-text-muted); + font-size: clamp(1rem, 1.2vw, 1.2rem); +} + +@media (max-width: 1000px) { + .app-shell { + grid-template-columns: minmax(0, 1fr); + } + + .onboarding-card { + border-radius: var(--radius-md); + } + + .onboarding-welcome-card { + --hero-content-width: clamp(29rem, 52vw, 34rem); + } + + .primary-button, + .secondary-button { + flex: 1 1 12rem; + } + + .focus-environment-layout { + gap: var(--space-md); + } + + .distraction-options-layout { + gap: var(--space-md); + } + + .focus-settings-card { + padding: var(--space-md); + } + + .allowed-environment-panel { + min-height: 21rem; + } +} + +@media (max-width: 900px) { + .camera-setup-layout { + width: min(100%, 48rem); + grid-template-columns: minmax(0, 1fr); + gap: var(--space-xl); + } + + .camera-setup-header { + gap: var(--space-md); + text-align: center; + justify-items: center; + } + + .camera-setup-header .onboarding-title { + max-width: 100%; + } + + .focus-environment-layout, + .distraction-options-layout { + width: min(100%, 48rem); + grid-template-columns: minmax(0, 1fr); + gap: var(--space-lg); + } + + .focus-environment-header, + .distraction-options-header { + text-align: center; + justify-items: center; + max-width: 100%; + } + + .focus-environment-title { + max-width: 100%; + } + + .allowed-panel-top { + grid-template-columns: minmax(0, 1fr); + } + + .onboarding-fixed-actions { + left: var(--space-md); + right: var(--space-md); + bottom: var(--space-md); + } + + .onboarding-fixed-actions .primary-button, + .onboarding-fixed-actions .secondary-button { + flex: 1 1 0; + } +} diff --git a/electron/src/renderer/pages/MenuPage.tsx b/electron/src/renderer/pages/MenuPage.tsx new file mode 100644 index 0000000..f88a076 --- /dev/null +++ b/electron/src/renderer/pages/MenuPage.tsx @@ -0,0 +1,10 @@ +export default function MenuPage() { + return ( +
+
+

Menu

+

Main focus dashboard coming soon.

+
+
+ ) +} diff --git a/electron/src/renderer/pages/OnboardingPage.tsx b/electron/src/renderer/pages/OnboardingPage.tsx new file mode 100644 index 0000000..da988a9 --- /dev/null +++ b/electron/src/renderer/pages/OnboardingPage.tsx @@ -0,0 +1,112 @@ +import { useEffect, useRef, useState } from 'react' +import CameraSetupStep from '../components/onboarding/CameraSetupStep' +import DistractionOptionsStep from '../components/onboarding/DistractionOptionsStep' +import FocusEnvironmentStep from '../components/onboarding/FocusEnvironmentStep' +import MenuPage from './MenuPage' +import WelcomeStep from '../components/onboarding/WelcomeStep' + +type Direction = 'forward' | 'backward' + +const lightStateByStep = [ + 'hero', + 'top-right', + 'top-left', + 'ambient', + 'off', +] as const + +export default function OnboardingPage() { + const [step, setStep] = useState(0) + const [previousStep, setPreviousStep] = useState(null) + const [direction, setDirection] = useState('forward') + const transitionTimer = useRef(null) + + useEffect(() => { + return () => { + if (transitionTimer.current !== null) { + window.clearTimeout(transitionTimer.current) + } + } + }, []) + + function goToStep(nextStep: number) { + if (nextStep === step) { + return + } + + setDirection(nextStep > step ? 'forward' : 'backward') + setPreviousStep(step) + setStep(nextStep) + + if (transitionTimer.current !== null) { + window.clearTimeout(transitionTimer.current) + } + + transitionTimer.current = window.setTimeout(() => { + setPreviousStep(null) + transitionTimer.current = null + }, 700) + } + + function renderStep(stepToRender: number) { + if (stepToRender === 0) { + return goToStep(1)} /> + } + + if (stepToRender === 1) { + return ( + goToStep(0)} + onContinue={() => goToStep(2)} + /> + ) + } + + if (stepToRender === 2) { + return ( + goToStep(1)} + onContinue={() => goToStep(3)} + /> + ) + } + + if (stepToRender === 3) { + return ( + goToStep(2)} + onFinish={() => goToStep(4)} + /> + ) + } + + return + } + + const lightState = lightStateByStep[step] ?? 'off' + + return ( +
+ + ) +} From 29bad4889858e196a707f5f1e9fc781a1b69c840 Mon Sep 17 00:00:00 2001 From: lukitasxue Date: Thu, 4 Jun 2026 04:43:57 +1000 Subject: [PATCH 2/3] changes on onboarding file names and added camera status and camera selection in camera setup step --- ....tsx => OnboardingAdditionalFunctions.tsx} | 0 ...etupStep.tsx => OnboardingCameraSetup.tsx} | 61 +++++++++- ...{WelcomeStep.tsx => OnboardingWelcome.tsx} | 0 ...entStep.tsx => WhitelistSelectionStep.tsx} | 0 .../src/renderer/hooks/useCameraDevices.ts | 105 ++++++++++++++++++ electron/src/renderer/index.css | 18 +++ .../src/renderer/pages/OnboardingPage.tsx | 8 +- 7 files changed, 183 insertions(+), 9 deletions(-) rename electron/src/renderer/components/onboarding/{DistractionOptionsStep.tsx => OnboardingAdditionalFunctions.tsx} (100%) rename electron/src/renderer/components/onboarding/{CameraSetupStep.tsx => OnboardingCameraSetup.tsx} (55%) rename electron/src/renderer/components/onboarding/{WelcomeStep.tsx => OnboardingWelcome.tsx} (100%) rename electron/src/renderer/components/onboarding/{FocusEnvironmentStep.tsx => WhitelistSelectionStep.tsx} (100%) create mode 100644 electron/src/renderer/hooks/useCameraDevices.ts diff --git a/electron/src/renderer/components/onboarding/DistractionOptionsStep.tsx b/electron/src/renderer/components/onboarding/OnboardingAdditionalFunctions.tsx similarity index 100% rename from electron/src/renderer/components/onboarding/DistractionOptionsStep.tsx rename to electron/src/renderer/components/onboarding/OnboardingAdditionalFunctions.tsx diff --git a/electron/src/renderer/components/onboarding/CameraSetupStep.tsx b/electron/src/renderer/components/onboarding/OnboardingCameraSetup.tsx similarity index 55% rename from electron/src/renderer/components/onboarding/CameraSetupStep.tsx rename to electron/src/renderer/components/onboarding/OnboardingCameraSetup.tsx index c61019a..c0e9ce1 100644 --- a/electron/src/renderer/components/onboarding/CameraSetupStep.tsx +++ b/electron/src/renderer/components/onboarding/OnboardingCameraSetup.tsx @@ -1,12 +1,44 @@ +// === camera setup === +import { useEffect, useRef, useState, } from "react"; +import { useCameraDevices } from "../../hooks/useCameraDevices"; + type CameraSetupStepProps = { onBack: () => void onContinue: () => void } +// === UI for camera onboarding page === + export default function CameraSetupStep({ onBack, onContinue, }: CameraSetupStepProps) { + const videoRef = useRef(null); + + const { + cameras, + selectedCameraId, + selectCamera, + stream, + cameraStatus, + } = useCameraDevices(); + + const cameraStatusMessage = { + checking: "Status: checking camera", + connected: "Status: camera connected", + "no-camera": "Status: no camera detected", + "permission-denied": "Status: camera permission denied", + error: "Status: camera error", + }[cameraStatus]; + +const isCameraConnected = cameraStatus === "connected"; + + useEffect(() => { + if (videoRef.current && stream) { + videoRef.current.srcObject = stream; + } + }, [stream]); + return (

Step 2

@@ -14,6 +46,12 @@ export default function CameraSetupStep({
+
+ ) +} + +/** + * Onboarding step for configuring desktop app focus rules. + * + * The user can: + * - Pick their main browser. + * - Decide whether the selected browser should be blocked entirely. + * - Mark detected desktop apps as allowed or blocked. + * + * Saving happens when the user presses Back or Continue. + */ export default function FocusEnvironmentStep({ onBack, onContinue, }: FocusEnvironmentStepProps) { + const { + settings, + browserOptions, + productivityApps, + distractionApps, + shouldSplitAppRules, + setSelectedBrowserId, + setBlockSelectedBrowser, + updateAppStatus, + saveFocusEnvironmentSettings, + } = useFocusEnvironmentSettings() + + function handleBack() { + saveFocusEnvironmentSettings() + onBack() + } + + function handleContinue() { + saveFocusEnvironmentSettings() + onContinue() + } + return (

Step 3

+
@@ -28,64 +131,83 @@ export default function FocusEnvironmentStep({ Focus environment

- Choose which apps or tabs are allowed during your deep work - sessions. + Choose which apps are allowed during your deep work sessions.

+

- Taskmaster will use this list to understand when you are working and - when you might be drifting. + Taskmaster checks the active app during focus sessions. Apps not + recognised yet will be marked as unknown and can be reviewed after + the session.

- + +
+ +
+

+ Taskmaster will also learn from apps you open during sessions. + Unknown apps can be reviewed after each session. +

-
-
-

Browser tabs

-
- {browserItems.map((item) => ( - - ))} -
-
- -
-

Apps

-
- {appItems.map((item) => ( - - ))} -
-
+
+ + +
- -
diff --git a/electron/src/renderer/hooks/useCameraDevices.ts b/electron/src/renderer/hooks/useCameraDevices.ts index 35406f6..ab4cf74 100644 --- a/electron/src/renderer/hooks/useCameraDevices.ts +++ b/electron/src/renderer/hooks/useCameraDevices.ts @@ -1,5 +1,20 @@ -// detect camera devices and manage camera stream for onboarding camera setup step -import { useEffect, useState } from "react"; +/** + * Camera device hook for the onboarding camera setup step. + * + * This hook is responsible for: + * - requesting camera permission + * - listing available video input devices + * - remembering the selected camera in localStorage + * - opening a preview stream for the selected camera + * - stopping the preview stream when the camera step unmounts + * + * Important: + * This hook should only be used by the camera setup screen. When that screen is + * no longer mounted, the cleanup effect stops the camera so the webcam light + * turns off. + */ + +import { useCallback, useEffect, useRef, useState } from "react"; type CameraStatus = | "checking" @@ -8,98 +23,193 @@ type CameraStatus = | "permission-denied" | "error"; +const SELECTED_CAMERA_KEY = "taskmaster:selectedCameraId"; + export function useCameraDevices() { const [cameras, setCameras] = useState([]); const [selectedCameraId, setSelectedCameraId] = useState(""); const [stream, setStream] = useState(null); const [cameraStatus, setCameraStatus] = useState("checking"); - const SELECTED_CAMERA_KEY = "taskmaster:selectedCameraId"; + /** + * Keep the active stream in a ref so cleanup functions can stop the latest + * stream without depending on React state timing. + */ + const streamRef = useRef(null); + + /** + * Stops the currently active camera stream. + * + * This is used when: + * - switching from one camera to another + * - leaving the camera setup step + * - cancelling an async camera request after the component unmounts + */ + const stopCurrentStream = useCallback(() => { + if (!streamRef.current) { + return; + } - async function selectCamera(cameraId: string): Promise { + streamRef.current.getTracks().forEach((track) => track.stop()); + streamRef.current = null; + setStream(null); + }, []); + + /** + * Updates the selected camera and persists the choice. + * + * The actual camera stream is opened by the selectedCameraId effect below. + */ + const selectCamera = useCallback((cameraId: string): void => { setSelectedCameraId(cameraId); localStorage.setItem(SELECTED_CAMERA_KEY, cameraId); - } + }, []); - async function loadCameras(): Promise { - try { - setCameraStatus("checking"); - const permissionStream = await navigator.mediaDevices.getUserMedia({ - video: true, - audio: false, - }); +/** + * On mount, request permission and load the list of available cameras. + * + * getUserMedia is called first because some browsers/Electron builds do not + * reveal camera labels until permission has been granted. + */ +useEffect(() => { + let isCancelled = false; + + async function detectCameras() { + try { + const permissionStream = + await navigator.mediaDevices.getUserMedia({ + video: true, + audio: false, + }); + + /** + * This stream is only used to unlock permission/device labels. + * Stop it immediately because the selected camera preview is opened in + * the next effect. + */ + permissionStream.getTracks().forEach((track) => track.stop()); + + const devices = await navigator.mediaDevices.enumerateDevices(); + const videoDevices = devices.filter( + (device) => device.kind === "videoinput", + ); + + if (isCancelled) { + return; + } + + setCameras(videoDevices); + + if (videoDevices.length === 0) { + setCameraStatus("no-camera"); + return; + } + + const savedCameraId = localStorage.getItem(SELECTED_CAMERA_KEY); + + const savedCameraStillExists = videoDevices.some( + (camera) => camera.deviceId === savedCameraId, + ); + + const cameraIdToUse = + savedCameraId && savedCameraStillExists + ? savedCameraId + : videoDevices[0].deviceId; + + setSelectedCameraId(cameraIdToUse); + localStorage.setItem(SELECTED_CAMERA_KEY, cameraIdToUse); + } catch (error) { + console.error("Error accessing cameras:", error); + + if (isCancelled) { + return; + } + + if ( + error instanceof DOMException && + error.name === "NotAllowedError" + ) { + setCameraStatus("permission-denied"); + } else { + setCameraStatus("error"); + } + } + } - permissionStream.getTracks().forEach((track) => track.stop()); + void detectCameras(); - const devices = await navigator.mediaDevices.enumerateDevices(); - const videoDevices = devices.filter( - (device) => device.kind === "videoinput", - ); + return () => { + isCancelled = true; + }; + }, []); + + /** + * Open the preview stream whenever the selected camera changes. + * + * Leaving the camera step unmounts the component, which triggers the final + * cleanup effect below and turns the camera off. + */ + useEffect(() => { + if (!selectedCameraId) { + return; + } - setCameras(videoDevices); + let isCancelled = false; - if (videoDevices.length === 0) { - setCameraStatus("no-camera"); - return; - } + async function openSelectedCamera() { + try { + stopCurrentStream(); - const savedCameraId = localStorage.getItem(SELECTED_CAMERA_KEY); + const newStream = await navigator.mediaDevices.getUserMedia({ + video: { deviceId: { exact: selectedCameraId } }, + audio: false, + }); - const savedCameraStillExists = videoDevices.some( - (camera) => camera.deviceId === savedCameraId, - ); + if (isCancelled) { + newStream.getTracks().forEach((track) => track.stop()); + return; + } - if (savedCameraId && savedCameraStillExists) { - await selectCamera(savedCameraId); - } else { - await selectCamera(videoDevices[0].deviceId); - } - } catch (error) { - console.error("Error accessing cameras:", error); + streamRef.current = newStream; + setStream(newStream); + setCameraStatus("connected"); + } catch (error) { + console.error("Error starting camera:", error); - if (error instanceof DOMException && error.name === "NotAllowedError") { - setCameraStatus("permission-denied"); - } else { - setCameraStatus("error"); + if (!isCancelled) { + setCameraStatus("error"); + } } } - } - - async function startCamera(deviceId: string): Promise { - try { - if (stream) { - stream.getTracks().forEach((track) => track.stop()); - } - const newStream = await navigator.mediaDevices.getUserMedia({ - video: { deviceId: { exact: deviceId } }, - audio: false, - }); + void openSelectedCamera(); - setStream(newStream); - setCameraStatus("connected"); - } catch (error) { - console.error(error); - setCameraStatus("error"); - } - } + return () => { + isCancelled = true; + }; + }, [selectedCameraId, stopCurrentStream]); + /** + * Final unmount cleanup. + * + * This is an important part for the onboarding flow: + * when the user leaves the camera setup step, the preview stream stops and + * the webcam is released. + */ useEffect(() => { - loadCameras(); + return () => { + if (streamRef.current) { + streamRef.current.getTracks().forEach((track) => track.stop()); + } + }; }, []); - useEffect(() => { - if (selectedCameraId) { - startCamera(selectedCameraId); - } - }, [selectedCameraId]); - return { cameras, selectedCameraId, selectCamera, stream, - cameraStatus, + cameraStatus, }; -} +} \ No newline at end of file diff --git a/electron/src/renderer/hooks/useFocusEnvironmentSettings.ts b/electron/src/renderer/hooks/useFocusEnvironmentSettings.ts new file mode 100644 index 0000000..73c4bdb --- /dev/null +++ b/electron/src/renderer/hooks/useFocusEnvironmentSettings.ts @@ -0,0 +1,288 @@ +/** + * Shared focus environment settings hook. + * + * This hook owns the onboarding settings for: + * - selected main browser + * - whether the selected browser is blocked during focus sessions + * - detected desktop app rules + * - common browser activity rules + * + * UI components should stay mostly presentational and call this hook instead + * of owning local copies of the settings logic. + */ + + +import { useEffect, useState } from 'react' +import { getDefaultBrowserOptions, getDefaultFocusApps, } from '../../shared/appDetection/commonApps.ts' +import { + getDefaultBrowserActivityRules, + type BrowserActivityRule, + type BrowserActivityRuleStatus, +} from '../../shared/browserActivity/commonBrowserActivityRules.ts' + +export type AppCategory = 'productivity' | 'distraction' +export type AppRuleStatus = 'allowed' | 'blocked' + +export type FocusApp = { + id: string + name: string + category: AppCategory + status: AppRuleStatus +} + +export type BrowserOption = { + id: string + name: string +} + +export type FocusEnvironmentSettings = { + selectedBrowserId: string + blockSelectedBrowser: boolean + appRules: FocusApp[] + browserActivityRules: BrowserActivityRule[] +} + +export type { + BrowserActivityRule, + BrowserActivityRuleStatus, +} from '../../shared/browserActivity/commonBrowserActivityRules.ts' + +type DetectedCommonApp = { + id: string + displayName: string + category: 'productivity' | 'distraction' | 'browser' + executablePath: string + defaultStatus: 'allowed' | 'blocked' +} + +const FOCUS_ENVIRONMENT_SETTINGS_KEY = 'taskmaster:focusEnvironmentSettings' + +const defaultBrowserOptions: BrowserOption[] = getDefaultBrowserOptions() +const defaultFocusApps: FocusApp[] = getDefaultFocusApps() +const defaultBrowserActivityRules: BrowserActivityRule[] = getDefaultBrowserActivityRules() + +/** + * Creates the fallback settings used before the real app detector returns data. + * + * Desktop apps can later be replaced by detected installed apps. + * Browser activity rules are static defaults because websites are not installed + * programs. + */ +function createDefaultSettings(): FocusEnvironmentSettings { + return { + selectedBrowserId: defaultBrowserOptions[0]?.id ?? '', + blockSelectedBrowser: false, + appRules: defaultFocusApps, + browserActivityRules: defaultBrowserActivityRules, + } +} + + +function loadFocusEnvironmentSettings(): FocusEnvironmentSettings | null { + const savedSettings = localStorage.getItem(FOCUS_ENVIRONMENT_SETTINGS_KEY) + + if (!savedSettings) { + return null + } + + try { + return JSON.parse(savedSettings) as FocusEnvironmentSettings + } catch { + localStorage.removeItem(FOCUS_ENVIRONMENT_SETTINGS_KEY) + return null + } +} + + +/** + * Narrows detected apps to desktop app rules. + * + * Browser apps are handled separately as browser options, so this prevents + * TypeScript from treating the category as "browser" after filtering. + */ +function isDetectedFocusApp( + app: DetectedCommonApp +): app is DetectedCommonApp & { category: AppCategory } { + return app.category === 'productivity' || app.category === 'distraction' +} +// ====== \\ + + +function convertDetectedAppsToFocusApps( + detectedApps: DetectedCommonApp[] +): FocusApp[] { + return detectedApps.filter(isDetectedFocusApp).map((app) => ({ + id: app.id, + name: app.displayName, + category: app.category, + status: app.defaultStatus, + })) +} + +function convertDetectedAppsToBrowserOptions( + detectedApps: DetectedCommonApp[] +): BrowserOption[] { + return detectedApps + .filter((app) => app.category === 'browser') + .map((app) => ({ + id: app.id, + name: app.displayName, + })) +} + +export function useFocusEnvironmentSettings() { + const [hasSavedSettings] = useState(() => { + return loadFocusEnvironmentSettings() !== null + }) + + const [settings, setSettings] = useState(() => { + return loadFocusEnvironmentSettings() ?? createDefaultSettings() + }) + + const [browserOptions, setBrowserOptions] = + useState(defaultBrowserOptions) + + + + /** + * On first load, ask Electron main process to detect installed desktop apps. + * + * This only runs when there are no saved settings, so the user's previous + * allowed/blocked choices are not overwritten. + */ + useEffect(() => { + async function loadDetectedApps() { + if (hasSavedSettings) { + return + } + + if (!window.taskmaster?.detectCommonApps) { + console.warn('Taskmaster preload API is not available') + return + } + + const detectedApps = await window.taskmaster.detectCommonApps() + + const detectedFocusApps = convertDetectedAppsToFocusApps(detectedApps) + const detectedBrowserOptions = + convertDetectedAppsToBrowserOptions(detectedApps) + + if (detectedBrowserOptions.length > 0) { + setBrowserOptions(detectedBrowserOptions) + } + + setSettings((currentSettings) => ({ + ...currentSettings, + selectedBrowserId: + detectedBrowserOptions[0]?.id ?? currentSettings.selectedBrowserId, + appRules: + detectedFocusApps.length > 0 + ? detectedFocusApps + : currentSettings.appRules, + })) + } + + loadDetectedApps() + }, [hasSavedSettings]) + // ===== \\ + + /** + * Derived desktop app groups for the desktop app whitelist UI. + */ + const productivityApps = settings.appRules.filter( + (app) => app.category === 'productivity' + ) + + const distractionApps = settings.appRules.filter( + (app) => app.category === 'distraction' + ) + // ===== \\ + + /** + * Browser activity groups shown by BrowserActivitySelectionStep. + * + * These are website/page rules, not installed desktop apps. + * AI tools are separated because they can be productive or distracting + * depending on the user's work. + */ + const blockedBrowserActivityRules = settings.browserActivityRules.filter( + (rule) => rule.id !== 'ai-tools' + ) + + const flexibleBrowserActivityRules = settings.browserActivityRules.filter( + (rule) => rule.id === 'ai-tools' + ) + + const shouldSplitAppRules = settings.appRules.length > 6 + // ===== \\ + + /** + * Setting update helpers used by onboarding UI components. + */ + function setSelectedBrowserId(selectedBrowserId: string) { + setSettings((currentSettings) => ({ + ...currentSettings, + selectedBrowserId, + })) + } + + function setBlockSelectedBrowser(blockSelectedBrowser: boolean) { + setSettings((currentSettings) => ({ + ...currentSettings, + blockSelectedBrowser, + })) + } + + function updateAppStatus(appId: string, status: AppRuleStatus) { + setSettings((currentSettings) => ({ + ...currentSettings, + appRules: currentSettings.appRules.map((app) => + app.id === appId + ? { + ...app, + status, + } + : app + ), + })) + } + + function updateBrowserActivityRuleStatus( + ruleId: string, + status: BrowserActivityRuleStatus + ) { + setSettings((currentSettings) => ({ + ...currentSettings, + browserActivityRules: currentSettings.browserActivityRules.map((rule) => + rule.id === ruleId + ? { + ...rule, + status, + } + : rule + ), + })) + } + + function saveFocusEnvironmentSettings() { + localStorage.setItem( + FOCUS_ENVIRONMENT_SETTINGS_KEY, + JSON.stringify(settings) + ) + } + + return { + settings, + browserOptions, + productivityApps, + distractionApps, + shouldSplitAppRules, + setSelectedBrowserId, + setBlockSelectedBrowser, + updateAppStatus, + saveFocusEnvironmentSettings, + blockedBrowserActivityRules, + flexibleBrowserActivityRules, + updateBrowserActivityRuleStatus, + } +} \ No newline at end of file diff --git a/electron/src/renderer/index.css b/electron/src/renderer/index.css index 4000e00..36855e3 100644 --- a/electron/src/renderer/index.css +++ b/electron/src/renderer/index.css @@ -751,7 +751,7 @@ a { display: grid; align-content: start; gap: var(--space-md); - padding: clamp(var(--space-lg), 2.4vw, var(--space-xl)); + padding: clamp(var(--space-lg), 2vw, var(--space-xl)); } .allowed-environment-panel { @@ -1021,6 +1021,15 @@ a { .onboarding-fixed-actions .secondary-button { flex: 1 1 0; } + + .browser-block-toggle { + max-width: none; + } + + .focus-app-rules--split { + grid-template-columns: minmax(0, 1fr); + } + } @@ -1039,4 +1048,225 @@ a { .camera-status-dot--error { background: var(--color-distracted); +} + + + + +/* app sections */ + +.focus-app-rules { + display: grid; + gap: var(--space-md); +} + +.focus-app-rules--split { + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: start; +} + +.focus-app-rule-section { + display: grid; + align-content: start; + gap: var(--space-sm); + padding: 0.70rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--color-bg-elevated) 48%, transparent); +} + +.focus-app-rule-section-header { + display: grid; + gap: 0.12rem; +} + +.focus-app-rule-section-header h2 { + margin: 0; + color: var(--color-text-main); + font-size: clamp(0.92rem, 0.86vw, 1rem); + font-weight: 850; +} + +.focus-app-rule-section-header p { + margin: 0; + color: var(--color-text-muted); + font-size: clamp(0.72rem, 0.66vw, 0.8rem); + line-height: 1.35; +} + +.focus-app-rule-list { + display: grid; + gap: 0.4rem; +} + +.focus-app-rule-row { + display: grid; + min-height: 2.25rem; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: var(--space-sm); + padding: 0.30rem 0.48rem; + border: 1px solid color-mix(in srgb, var(--color-border) 76%, transparent); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--color-bg-card) 66%, transparent); +} + +.focus-app-rule-name { + overflow: hidden; + color: var(--color-text-main); + font-size: clamp(0.82rem, 0.76vw, 0.9rem); + font-weight: 760; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Simple allow/block toggle */ + +.focus-app-toggle { + position: relative; + display: inline-block; + width: 2.5rem; + height: 1.25rem; + border-radius: 999px; + cursor: pointer; +} + +.focus-app-toggle input { + position: absolute; + inset: 0; + z-index: 2; + width: 100%; + height: 100%; + margin: 0; + opacity: 0; + cursor: pointer; +} + +.focus-app-toggle span { + position: absolute; + inset: 0; + border: 1px solid color-mix(in srgb, var(--color-focused) 46%, var(--color-border)); + border-radius: 999px; + background: + linear-gradient( + 90deg, + color-mix(in srgb, var(--color-focused) 34%, var(--color-bg-elevated)), + color-mix(in srgb, var(--color-focused) 18%, var(--color-bg-card)) + ); + transition: + background 220ms ease, + border-color 220ms ease, + box-shadow 220ms ease; +} + +.focus-app-toggle span::before { + position: absolute; + top: 0.13rem; + left: 0.14rem; + width: 0.94rem; + height: 0.94rem; + content: ""; + border-radius: 999px; + background: linear-gradient(145deg, #f6f2e8, #bfb8aa); + box-shadow: 0 0.22rem 0.48rem rgba(0, 0, 0, 0.36); + transition: + left 220ms cubic-bezier(0.22, 1, 0.36, 1), + transform 220ms cubic-bezier(0.22, 1, 0.36, 1); +} + +.focus-app-toggle input:checked + span { + border-color: color-mix(in srgb, var(--color-distracted) 52%, var(--color-border)); + background: + linear-gradient( + 90deg, + color-mix(in srgb, var(--color-distracted) 20%, var(--color-bg-card)), + color-mix(in srgb, var(--color-distracted) 40%, var(--color-bg-elevated)) + ); + box-shadow: 0 0 0.7rem color-mix(in srgb, var(--color-distracted) 14%, transparent); +} + +.focus-app-toggle input:checked + span::before { + left: 1.32rem; +} + +.focus-app-toggle input:focus-visible + span { + outline: 2px solid var(--color-accent-bright); + outline-offset: 3px; +} + + +/* browser activity rules */ +.browser-activity-rule-copy { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.browser-activity-rule-description { + color: var(--color-text-muted); + font-size: 0.82rem; + line-height: 1.35; +} + +/* Browser activity onboarding step + This layout uses one column because website rules need more description text + than desktop app rules. */ + +.browser-activity-panel { + max-width: 620px; +} + +.browser-activity-rules { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.browser-activity-rule-section { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.browser-activity-rule-list { + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +.browser-activity-rule-row { + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + min-height: 64px; + padding: 0.70rem 0.80rem; + border: 1px solid var(--color-border); + border-radius: 0.85rem; + background: rgba(255, 255, 255, 0.035); +} + +.browser-activity-rule-copy { + display: flex; + flex-direction: column; + gap: 0rem; + min-width: 0; +} + +.browser-activity-rule-description { + max-width: 440px; + color: var(--color-text-muted); + font-size: 0.82rem; + line-height: 1.35; + opacity: 0.80; +} + +@media (max-width: 760px) { + .browser-activity-panel { + max-width: 100%; + } + + .browser-activity-rule-row { + grid-template-columns: 1fr; + align-items: flex-start; + } } \ No newline at end of file diff --git a/electron/src/renderer/pages/OnboardingPage.tsx b/electron/src/renderer/pages/OnboardingPage.tsx index c2843c1..49f9709 100644 --- a/electron/src/renderer/pages/OnboardingPage.tsx +++ b/electron/src/renderer/pages/OnboardingPage.tsx @@ -1,9 +1,17 @@ +/** + * Main onboarding flow controller. + * + * This page owns the current onboarding step, transition direction, and screen + * animations. Individual onboarding screens should keep their own UI focused + * and receive only navigation callbacks from this page. + */ import { useEffect, useRef, useState } from 'react' import CameraSetupStep from '../components/onboarding/OnboardingCameraSetup' import DistractionOptionsStep from '../components/onboarding/OnboardingAdditionalFunctions' import FocusEnvironmentStep from '../components/onboarding/WhitelistSelectionStep' import MenuPage from './MenuPage' import WelcomeStep from '../components/onboarding/OnboardingWelcome' +import BrowserActivitySelectionStep from '../components/onboarding/BrowserActivitySelectionStep' type Direction = 'forward' | 'backward' @@ -12,6 +20,7 @@ const lightStateByStep = [ 'top-right', 'top-left', 'ambient', + 'ambient', 'off', ] as const @@ -48,6 +57,7 @@ export default function OnboardingPage() { }, 700) } + function renderStep(stepToRender: number) { if (stepToRender === 0) { return goToStep(1)} /> @@ -73,9 +83,18 @@ export default function OnboardingPage() { if (stepToRender === 3) { return ( - goToStep(2)} - onFinish={() => goToStep(4)} + onContinue={() => goToStep(4)} + /> + ) + } + + if (stepToRender === 4) { + return ( + goToStep(3)} + onFinish={() => goToStep(5)} /> ) } diff --git a/electron/src/renderer/vite-env.d.ts b/electron/src/renderer/vite-env.d.ts new file mode 100644 index 0000000..c33d10f --- /dev/null +++ b/electron/src/renderer/vite-env.d.ts @@ -0,0 +1,19 @@ +/// + +type DetectedCommonApp = { + id: string + displayName: string + category: 'productivity' | 'distraction' | 'browser' + executablePath: string + defaultStatus: 'allowed' | 'blocked' +} + +declare global { + interface Window { + taskmaster: { + detectCommonApps: () => Promise + } + } +} + +export {} \ No newline at end of file diff --git a/electron/src/shared/appDetection/commonApps.ts b/electron/src/shared/appDetection/commonApps.ts new file mode 100644 index 0000000..5459bfa --- /dev/null +++ b/electron/src/shared/appDetection/commonApps.ts @@ -0,0 +1,164 @@ +/** + * Common desktop app catalogue used by Taskmaster onboarding. + * + * These definitions describe known Windows apps that Taskmaster can try to + * detect on the user's computer. + * + * Browser websites/pages do not belong here. Browser activity rules live in: + * shared/browserActivity/commonBrowserActivityRules.ts + */ + +export type CommonAppCategory = 'productivity' | 'distraction' | 'browser' + +export type CommonAppDefinition = { + id: string + displayName: string + category: CommonAppCategory + executableNames: string[] + commonWindowsPaths: string[] + defaultStatus: 'allowed' | 'blocked' +} + +export const COMMON_APPS: CommonAppDefinition[] = [ + { + id: 'vscode', + displayName: 'Visual Studio Code', + category: 'productivity', + executableNames: ['Code.exe'], + commonWindowsPaths: [ + '%LOCALAPPDATA%\\Programs\\Microsoft VS Code\\Code.exe', + '%PROGRAMFILES%\\Microsoft VS Code\\Code.exe', + '%PROGRAMFILES(X86)%\\Microsoft VS Code\\Code.exe', + ], + defaultStatus: 'allowed', + }, + { + id: 'windows-terminal', + displayName: 'Windows Terminal', + category: 'productivity', + executableNames: ['WindowsTerminal.exe', 'wt.exe'], + commonWindowsPaths: [ + '%LOCALAPPDATA%\\Microsoft\\WindowsApps\\wt.exe', + ], + defaultStatus: 'allowed', + }, + { + id: 'notion', + displayName: 'Notion', + category: 'productivity', + executableNames: ['Notion.exe'], + commonWindowsPaths: [ + '%LOCALAPPDATA%\\Programs\\Notion\\Notion.exe', + ], + defaultStatus: 'allowed', + }, + { + id: 'chrome', + displayName: 'Google Chrome', + category: 'browser', + executableNames: ['chrome.exe'], + commonWindowsPaths: [ + '%PROGRAMFILES%\\Google\\Chrome\\Application\\chrome.exe', + '%PROGRAMFILES(X86)%\\Google\\Chrome\\Application\\chrome.exe', + '%LOCALAPPDATA%\\Google\\Chrome\\Application\\chrome.exe', + ], + defaultStatus: 'allowed', + }, + { + id: 'edge', + displayName: 'Microsoft Edge', + category: 'browser', + executableNames: ['msedge.exe'], + commonWindowsPaths: [ + '%PROGRAMFILES(X86)%\\Microsoft\\Edge\\Application\\msedge.exe', + '%PROGRAMFILES%\\Microsoft\\Edge\\Application\\msedge.exe', + ], + defaultStatus: 'allowed', + }, + { + id: 'opera-gx', + displayName: 'Opera GX', + category: 'browser', + executableNames: ['opera.exe', 'launcher.exe'], + commonWindowsPaths: [ + '%LOCALAPPDATA%\\Programs\\Opera GX\\launcher.exe', + '%LOCALAPPDATA%\\Programs\\Opera GX\\opera.exe', + ], + defaultStatus: 'allowed', + }, + { + id: 'discord', + displayName: 'Discord', + category: 'distraction', + executableNames: ['Discord.exe'], + commonWindowsPaths: [ + '%LOCALAPPDATA%\\Discord\\Update.exe', + '%LOCALAPPDATA%\\Discord\\app-*\\Discord.exe', + ], + defaultStatus: 'blocked', + }, + { + id: 'spotify', + displayName: 'Spotify', + category: 'distraction', + executableNames: ['Spotify.exe'], + commonWindowsPaths: [ + '%APPDATA%\\Spotify\\Spotify.exe', + '%LOCALAPPDATA%\\Microsoft\\WindowsApps\\Spotify.exe', + ], + defaultStatus: 'blocked', + }, + { + id: 'steam', + displayName: 'Steam', + category: 'distraction', + executableNames: ['steam.exe'], + commonWindowsPaths: [ + '%PROGRAMFILES(X86)%\\Steam\\steam.exe', + '%PROGRAMFILES%\\Steam\\steam.exe', + ], + defaultStatus: 'blocked', + }, +] + + +/** + * Converts the common app catalogue into the desktop app rules shown during + * onboarding before real detection results are available. + */ +export type DefaultFocusApp = { + id: string + name: string + category: 'productivity' | 'distraction' + status: 'allowed' | 'blocked' +} + +export type DefaultBrowserOption = { + id: string + name: string +} + +export function getDefaultFocusApps(): DefaultFocusApp[] { + return COMMON_APPS + .filter((app) => app.category !== 'browser') + .map((app) => ({ + id: app.id, + name: app.displayName, + category: app.category as 'productivity' | 'distraction', + status: app.defaultStatus, + })) +} + + +/** + * Converts detected/common browser apps into options for the main browser + * dropdown in onboarding. + */ +export function getDefaultBrowserOptions(): DefaultBrowserOption[] { + return COMMON_APPS + .filter((app) => app.category === 'browser') + .map((app) => ({ + id: app.id, + name: app.displayName, + })) +} \ No newline at end of file diff --git a/electron/src/shared/browserActivity/commonBrowserActivityRules.ts b/electron/src/shared/browserActivity/commonBrowserActivityRules.ts new file mode 100644 index 0000000..cb6f8ce --- /dev/null +++ b/electron/src/shared/browserActivity/commonBrowserActivityRules.ts @@ -0,0 +1,112 @@ +/** + * Common browser activity rules used during onboarding. + * + * These rules are not installed programs. They are common website/page patterns + * that Taskmaster can later match against the active browser window title. + * + * Example: + * - Active window title: "YouTube - Google Chrome" + * - Rule matchText: ["youtube"] + * + * Later, a browser extension can replace this weak title-matching approach + * with accurate tab URL detection. + */ + +export type BrowserActivityRuleStatus = 'allowed' | 'blocked' | 'ignored' + +export type BrowserActivityCategory = + | 'entertainment' + | 'music' + | 'messaging' + | 'communication' + | 'ai' + | 'social' + | 'shopping' + +export type BrowserActivityRule = { + id: string + label: string + description: string + matchText: string[] + category: BrowserActivityCategory + status: BrowserActivityRuleStatus +} + +export const COMMON_BROWSER_ACTIVITY_RULES: BrowserActivityRule[] = [ + { + id: 'youtube', + label: 'YouTube', + description: 'Videos, recommendations, shorts, and general browsing.', + matchText: ['youtube'], + category: 'entertainment', + status: 'blocked', + }, + { + id: 'youtube-music', + label: 'YouTube Music', + description: 'Music streaming through YouTube Music.', + matchText: ['music.youtube', 'youtube music'], + category: 'music', + status: 'blocked', + }, + { + id: 'spotify-web', + label: 'Spotify Web', + description: 'Spotify in the browser.', + matchText: ['spotify'], + category: 'music', + status: 'blocked', + }, + { + id: 'whatsapp-web', + label: 'WhatsApp Web', + description: 'Messaging through WhatsApp in the browser.', + matchText: ['whatsapp'], + category: 'messaging', + status: 'blocked', + }, + { + id: 'email', + label: 'Email', + description: 'Gmail, Outlook, Yahoo Mail, Proton Mail, and similar inboxes.', + matchText: ['gmail', 'outlook', 'yahoo mail', 'proton mail'], + category: 'communication', + status: 'blocked', + }, + { + id: 'streaming', + label: 'Streaming services', + description: 'Netflix, Disney+, Prime Video, Crunchyroll, Apple TV, etc.', + matchText: ['netflix', 'disney+', 'prime video', 'crunchyroll', 'apple tv'], + category: 'entertainment', + status: 'blocked', + }, + { + id: 'ai-tools', + label: 'AI tools', + description: 'ChatGPT, Claude, Gemini, Perplexity, and similar tools.', + matchText: ['chatgpt', 'claude', 'gemini', 'perplexity'], + category: 'ai', + status: 'allowed', + }, + { + id: 'social-media', + label: 'Social media', + description: 'Instagram, TikTok, Facebook, Reddit, X, and similar sites.', + matchText: ['instagram', 'tiktok', 'facebook', 'reddit', 'x.com'], + category: 'social', + status: 'blocked', + }, + { + id: 'shopping', + label: 'Shopping', + description: 'Amazon, eBay, AliExpress, marketplace browsing, and similar.', + matchText: ['amazon', 'ebay', 'aliexpress', 'marketplace'], + category: 'shopping', + status: 'blocked', + }, +] + +export function getDefaultBrowserActivityRules(): BrowserActivityRule[] { + return COMMON_BROWSER_ACTIVITY_RULES +} \ No newline at end of file diff --git a/electron/src/shared/protocol.ts b/electron/src/shared/protocol.ts deleted file mode 100644 index 9f37611..0000000 --- a/electron/src/shared/protocol.ts +++ /dev/null @@ -1,2 +0,0 @@ -// TypeScript types for IPC messages (renderer ↔ main) and WebSocket events (main ↔ Python). -// Single source of truth so neither side drifts out of sync. \ No newline at end of file diff --git a/python/README.md b/python/README.md new file mode 100644 index 0000000..76cd2f2 --- /dev/null +++ b/python/README.md @@ -0,0 +1,93 @@ +# Taskmaster — Python CV Worker + +The computer-vision backend. It owns the webcam, runs the detectors +(phone now, gaze next), and will stream detection events to the Electron +app over a WebSocket. + +## Requirements + +- **Python 3.11** — MediaPipe has no wheels for 3.13/3.14, so the venv + must be built with `python3.11`. +- Dependencies live in [`requirements.txt`](requirements.txt). + +## Setup + +From the repo root, the easiest path is `./setup.sh`. To do just the +Python side manually: + +```bash +cd python +python3.11 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +``` + +The venv lives at `python/.venv` and is gitignored. + +## Running + +```bash +source .venv/bin/activate +python cv/detection_loop.py # Ctrl+C to stop +``` + +This opens the webcam, samples ~10 frames/sec, runs the phone detector on +each frame, and prints the result. The camera is always released cleanly +on exit. + +## Module layout + +``` +python/ +├── main.py # FastAPI + WebSocket server (not implemented yet) +├── models/ # detection model files (gitignored; fetched by setup.sh) +│ └── yolox_s.onnx # YOLOX-S phone detector (Apache-2.0) +└── cv/ + ├── camera.py # owns the webcam handle: start / read / stop + ├── detection_loop.py # the loop: grab frame -> run detectors -> emit result + ├── phone_detector.py # detect_phone(frame) -> event dict (YOLOX via onnxruntime) + ├── phone_detect_test.py# manual visual test: draws boxes on the webcam feed + └── gaze_detector.py # gaze/face detection (planned) +``` + +### Design: why `camera.py` and `detection_loop.py` are separate + +Each module should have **one reason to change**: + +- `camera.py` is a **resource owner** — it only cares about the webcam + hardware. It changes when capture concerns change. +- `detection_loop.py` is **orchestration/policy** — sampling rate, which + detectors run, what happens to results. It changes when the detection + pipeline changes. + +The dependency arrow points one way: `detection_loop` imports `camera` and +the detectors; `camera` knows nothing about detection. This keeps the +camera reusable (onboarding preview, calibration) and lets each piece be +tested on its own. + +## Detection event shape + +Every detector returns a dict matching the WebSocket protocol in +[`PLAN.md`](../PLAN.md): + +```python +{ "type": "phone", "status": "none" | "detected", + "confidence": float, "timestamp": int } # timestamp = ms since epoch +``` + +### Phone detection + +`phone_detector.detect_phone()` runs **YOLOX-S** (general COCO detector, +Apache-2.0) locally via **onnxruntime** (MIT), and reports the `cell phone` +class. Both are permissively licensed and bundle into a shipped app — no +PyTorch, no AGPL (unlike Ultralytics YOLO). + +- `find_phones(frame)` → list of `(x1, y1, x2, y2, score)` boxes (perception). +- `detect_phone(frame)` → the protocol event above. + +The detector is **perception only** — it answers "is there a phone in this +frame?". Turning that into a *distracted* state (phone visible for N seconds) +is policy that belongs in the loop/state layer, not here. + +The model file (`models/yolox_s.onnx`, ~34 MB) is gitignored and downloaded +by `setup.sh`. diff --git a/python/cv/detection_loop.py b/python/cv/detection_loop.py new file mode 100644 index 0000000..b5a9fb5 --- /dev/null +++ b/python/cv/detection_loop.py @@ -0,0 +1,90 @@ +"""Detection loop — the heartbeat that ties the camera to the detectors. + +This module owns the repeating cycle: + + grab a frame -> run the phone detector on it -> hand off the result + +For now it just prints each result so you can watch the pipeline work. Later +the same loop will push results over the WebSocket to the Electron app instead +of printing them, and it will also call the gaze detector alongside the phone +detector. + +Run it directly to try it out (camera light should turn on): + + cd python + source .venv/bin/activate + python cv/detection_loop.py + +Press Ctrl+C to stop — the camera is always released cleanly on the way out. +""" + +# time — used to pace the loop so we don't pin the CPU at 100%. +import time + +# Sibling modules in this same cv/ folder. When you run this file as a script, +# Python puts this folder on the import path, so these plain imports resolve. +import camera +import phone_detector + + +# How many times per second we sample the webcam. 10 fps is plenty for +# detecting something as slow as "is the user holding a phone", and it keeps +# CPU usage low. Derived sleep below is 1 / this. +FRAMES_PER_SECOND = 10 +_SECONDS_PER_FRAME = 1.0 / FRAMES_PER_SECOND + + +def run_detection_loop(camera_device_index: int = 0) -> None: + """Open the camera and run the detect-on-every-frame loop until stopped. + + camera_device_index = 0 means the default webcam (see camera.py). + The loop runs forever; stop it with Ctrl+C (KeyboardInterrupt). + """ + + # Turn the webcam on. Raises if no camera is available, so if we get past + # this line we know frames should be coming. + camera.start_camera(camera_device_index) + print(f"[detection_loop] camera started — sampling at {FRAMES_PER_SECOND} fps") + print("[detection_loop] press Ctrl+C to stop\n") + + try: + # The main loop. Keep going until the user interrupts us. + while True: + # 1) Grab the most recent frame from the webcam. May be None if a + # single read failed (driver hiccup) — the detector handles that. + current_frame = camera.read_current_frame() + + # 2) Ask the phone detector what it sees in this frame. + phone_result = phone_detector.detect_phone(current_frame) + + # 3) For now, just print it. Later: send over WebSocket instead. + print(_format_result_for_console(phone_result)) + + # 4) Wait a beat so we sample at roughly FRAMES_PER_SECOND, not as + # fast as the CPU can spin. + time.sleep(_SECONDS_PER_FRAME) + + except KeyboardInterrupt: + # Ctrl+C lands here. Not an error — it's the normal way to stop. + print("\n[detection_loop] stop requested") + + finally: + # Whatever happens (normal stop OR a crash), always free the webcam so + # other apps — and the next run — can use it. + camera.stop_camera() + print("[detection_loop] camera released — bye") + + +def _format_result_for_console(result: dict) -> str: + """Turn a detection dict into a short, readable one-line string.""" + return ( + f"phone={result['status']:<8} " + f"confidence={result['confidence']:.2f} " + f"t={result['timestamp']}" + ) + + +# Standard Python entry-point guard: this block only runs when the file is +# executed directly (python cv/detection_loop.py), not when it is imported. +if __name__ == "__main__": + run_detection_loop() diff --git a/python/cv/phone_detect_test.py b/python/cv/phone_detect_test.py new file mode 100644 index 0000000..804c39d --- /dev/null +++ b/python/cv/phone_detect_test.py @@ -0,0 +1,58 @@ +"""Manual visual test for the phone detector. + +Opens the webcam, runs phone_detector.find_phones() on each frame, and draws +a red box + confidence around any phone it sees. Hold your phone up — it +should light up. This is just a visualiser; all the detection logic lives in +phone_detector.py. + +Run it: + cd python + source .venv/bin/activate + python cv/phone_detect_test.py + +Press 'q' (video window focused) or Ctrl+C to quit. +""" + +import cv2 # type: ignore + +import phone_detector + + +def main() -> None: + capture = cv2.VideoCapture(0) + if not capture.isOpened(): + raise RuntimeError("Could not open webcam (index 0).") + + print("Running. Hold a phone up. Press 'q' or Ctrl+C to quit.\n") + + try: + while True: + ok, frame = capture.read() + if not ok: + continue + + phones = phone_detector.find_phones(frame) + + for x1, y1, x2, y2, score in phones: + cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 0, 255), 2) + cv2.putText( + frame, f"PHONE {score:.2f}", (x1, max(y1 - 8, 12)), + cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 2, + ) + + print("PHONE DETECTED" if phones else "...", end="\r", flush=True) + + cv2.imshow("phone detector test (press q to quit)", frame) + if cv2.waitKey(1) & 0xFF == ord("q"): + break + + except KeyboardInterrupt: + pass + finally: + capture.release() + cv2.destroyAllWindows() + print("\nstopped.") + + +if __name__ == "__main__": + main() diff --git a/python/cv/phone_detector.py b/python/cv/phone_detector.py new file mode 100644 index 0000000..7e549aa --- /dev/null +++ b/python/cv/phone_detector.py @@ -0,0 +1,306 @@ +"""Phone-in-frame detection. + +Perception only: given one webcam frame, decide whether a phone is visible. +Whether being on the phone *counts as distracted* (for how long, etc.) is +policy that lives elsewhere — this module just answers "phone in this frame?". + +Backed by YOLOX-S (Apache-2.0) running locally via onnxruntime (MIT). Both +are permissively licensed and bundle cleanly into a shipped app — no PyTorch, +no AGPL. + +Public API: + find_phones(frame) -> list[(x1, y1, x2, y2, score)] # raw boxes + detect_phone(frame) -> dict # protocol event + +The event dict matches the WebSocket protocol in PLAN.md: + { "type": "phone", "status": "none"|"detected", + "confidence": float, "timestamp": int } +""" + +import os +import time + +import cv2 +import numpy as np +import onnxruntime as ort + + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- + +# Model path resolved relative to this file (python/cv/), so it works no +# matter which directory the program is launched from. +_THIS_DIR = os.path.dirname(os.path.abspath(__file__)) +MODEL_PATH = os.path.join(_THIS_DIR, "..", "models", "yolox_s.onnx") + +# YOLOX-S input resolution. +INPUT_SIZE = (640, 640) + +# COCO class index for "cell phone" (class 67 of the model's 80). +CELL_PHONE_CLASS_ID = 67 + +# Minimum combined score (objectness * class prob) to believe a detection. +SCORE_THRESHOLD = 0.30 + +# IoU threshold for non-max suppression (drops duplicate overlapping boxes). +NMS_IOU_THRESHOLD = 0.45 + + +# --------------------------------------------------------------------------- +# Module-level state: the model is loaded once and reused for every frame. +# Loading on every call would be unbearably slow. +# --------------------------------------------------------------------------- + +_session: ort.InferenceSession | None = None + + +def _get_session() -> ort.InferenceSession: + """Lazily create (and cache) the onnxruntime session.""" + global _session + if _session is None: + if not os.path.exists(MODEL_PATH): + raise FileNotFoundError( + f"YOLOX model not found at {MODEL_PATH}. " + "Run ./setup.sh (or download yolox_s.onnx into python/models/)." + ) + _session = ort.InferenceSession( + MODEL_PATH, providers=["CPUExecutionProvider"] + ) + return _session + + +# --------------------------------------------------------------------------- +# Pre/post-processing +# --------------------------------------------------------------------------- + + +def _preprocess(frame_bgr: np.ndarray): + """Resize a webcam frame into the exact input the YOLOX model wants. + + The model only accepts a (1, 3, 640, 640) float32 array. A webcam frame is + a different size and shape, so we convert it here. We shrink the frame to + fit a 640x640 box WITHOUT stretching it, then pad the leftover space with + grey (this is called "letterboxing", like the bars around a widescreen + movie). Stretching would distort the phone and hurt detection. + + Returns: + input_tensor: the (1, 3, 640, 640) float32 array for the model. + scale_ratio: how much we shrank by, so detected boxes can later be + scaled back up onto the original full-size frame. + """ + + # 1. Measure the original webcam frame. A frame's shape is (height, width, + # channels), so shape[0] is height and shape[1] is width. + original_height = frame_bgr.shape[0] + original_width = frame_bgr.shape[1] + + # 2. The size the model wants (640 x 640). + target_height = INPUT_SIZE[0] + target_width = INPUT_SIZE[1] + + # 3. Work out how much to shrink. We need one scale factor that fits BOTH + # sides inside 640. Taking the smaller of the two keeps the aspect ratio + # and guarantees neither side spills past 640. + height_scale = target_height / original_height + width_scale = target_width / original_width + scale_ratio = min(height_scale, width_scale) + + # 4. The frame's new size after shrinking by that factor. + resized_width = int(original_width * scale_ratio) + resized_height = int(original_height * scale_ratio) + + # 5. Actually shrink the frame to that size. + # Note: cv2.resize takes the size as (width, height) — width first. + resized_frame = cv2.resize( + frame_bgr, + (resized_width, resized_height), + interpolation=cv2.INTER_LINEAR, + ) + + # 6. Make a blank 640x640 grey canvas (every pixel = 114, a mid grey), + # then paste the shrunk frame into its top-left corner. The rest stays + # grey — that grey padding is the "letterbox". + grey_value = 114 + letterboxed = np.full( + (target_height, target_width, 3), grey_value, dtype=np.uint8 + ) + letterboxed[:resized_height, :resized_width] = resized_frame + + # 7. Reorder the axes from (height, width, channels) to (channels, height, + # width). OpenCV stores colour last; the model wants colour first. + channels_first = letterboxed.transpose(2, 0, 1) + + # 8. Add a "batch" dimension at the front: (3, 640, 640) -> (1, 3, 640, + # 640). Models process a batch of images at once; our batch is 1 image. + batched = channels_first[np.newaxis, :, :, :] + + # 9. Convert the pixels from whole numbers to decimals (float32), the + # number type the model computes in. We do NOT divide by 255 here — + # YOLOX expects raw 0-255 values, unlike many other models. + input_tensor = batched.astype(np.float32) + + # 10. transpose/newaxis above only relabelled the data without moving it, + # leaving it scattered in memory. onnxruntime needs it laid out in one + # contiguous block, so make a tidy packed copy. + input_tensor = np.ascontiguousarray(input_tensor) + + return input_tensor, scale_ratio + + +def _decode(raw: np.ndarray) -> np.ndarray: + """Turn the model's raw grid output into real pixel coordinates. + + The model does NOT output ready-to-use boxes. It mentally splits the + 640x640 image into grids of cells at three zoom levels (8, 16, and 32 + pixels per cell) and, for each cell, predicts a box as an OFFSET from that + cell's position. To get real pixel coordinates we add each cell's position + back and multiply by its stride (its pixels-per-cell). That is "decoding". + + `raw` has shape (8400, 85): 8400 candidate boxes, each row is + [x_offset, y_offset, width_raw, height_raw, objectness, 80 class scores]. + """ + + # For every one of the 8400 rows we need two matching facts: + # - which grid cell it came from (its column, row position) + # - the stride (pixels-per-cell) of the grid it belongs to + all_cell_positions = [] + all_cell_strides = [] + + # Three grids, fine to coarse. Stride 8 -> 80x80 cells, stride 16 -> 40x40, + # stride 32 -> 20x20. 80*80 + 40*40 + 20*20 = 8400, matching raw's rows. + for stride in (8, 16, 32): + cells_across = INPUT_SIZE[1] // stride # number of columns + cells_down = INPUT_SIZE[0] // stride # number of rows + + # Build the (column, row) index of every cell in this grid. + column_index, row_index = np.meshgrid( + np.arange(cells_across), np.arange(cells_down) + ) + cell_positions = np.stack((column_index, row_index), axis=2).reshape(-1, 2) + all_cell_positions.append(cell_positions) + + # Every cell in this grid shares the same stride. + cell_count = cell_positions.shape[0] + all_cell_strides.append(np.full((cell_count, 1), stride)) + + # Glue the three grids into one long list lined up with raw's 8400 rows. + cell_positions = np.concatenate(all_cell_positions, axis=0) + cell_strides = np.concatenate(all_cell_strides, axis=0) + + # Box center: real_xy = (predicted_offset + cell_position) * stride + raw[:, 0:2] = (raw[:, 0:2] + cell_positions) * cell_strides + + # Box size: real_wh = exp(predicted) * stride + # (exp keeps width/height positive whatever the model outputs.) + raw[:, 2:4] = np.exp(raw[:, 2:4]) * cell_strides + + return raw + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def find_phones(frame) -> list: + """Detect phones in one frame. + + Returns a list of (x1, y1, x2, y2, score) tuples in the frame's pixel + coordinates — empty if no phone is found or the frame is None. + """ + # No frame (camera hiccup) -> nothing to detect. + if frame is None: + return [] + + # 1. Load the model (once) and turn the frame into model input. + session = _get_session() + input_tensor, scale_ratio = _preprocess(frame) + + # 2. Run the model. It returns a list of outputs; we take the first output + # and the first (only) image in the batch -> shape (8400, 85). + # np.asarray pins the type to a normal array — the library's type hint + # says run() *might* return a non-indexable SparseTensor, which YOLOX + # never does — which keeps the type-checker quiet. + model_outputs = session.run(None, {session.get_inputs()[0].name: input_tensor}) + raw_predictions = np.asarray(model_outputs[0])[0] + + # 3. Decode the raw grid output into real pixel boxes (center x/y, w, h). + predictions = _decode(raw_predictions) + + # 4. For each of the 8400 candidates, compute how confident we are it is a + # PHONE: objectness (is anything here at all?) x phone-class probability. + # Column 4 is objectness; column 5 + 67 is the phone class score. + objectness = predictions[:, 4] + phone_class_score = predictions[:, 5 + CELL_PHONE_CLASS_ID] + phone_confidence = objectness * phone_class_score + + # 5. Keep only candidates above our confidence bar. If none survive, there + # is no phone in this frame. + is_confident_phone = phone_confidence > SCORE_THRESHOLD + if not np.any(is_confident_phone): + return [] + + kept_boxes = predictions[is_confident_phone, :4] # each row: cx, cy, w, h + kept_scores = phone_confidence[is_confident_phone] + + # 6. Convert each box from (center_x, center_y, width, height) to corner + # form (x1, y1, x2, y2), and divide by scale_ratio to map it from the + # 640x640 model space back onto the original full-size frame. + center_x = kept_boxes[:, 0] + center_y = kept_boxes[:, 1] + width = kept_boxes[:, 2] + height = kept_boxes[:, 3] + + corner_boxes = np.empty_like(kept_boxes) + corner_boxes[:, 0] = (center_x - width / 2) / scale_ratio # x1 (left) + corner_boxes[:, 1] = (center_y - height / 2) / scale_ratio # y1 (top) + corner_boxes[:, 2] = (center_x + width / 2) / scale_ratio # x2 (right) + corner_boxes[:, 3] = (center_y + height / 2) / scale_ratio # y2 (bottom) + + # 7. The model often fires several overlapping boxes for one phone. + # Non-Max Suppression keeps the strongest box and drops its duplicates. + # cv2.dnn.NMSBoxes wants each box as [x, y, width, height]. + boxes_for_nms = [] + for box in corner_boxes: + x1, y1, x2, y2 = box + boxes_for_nms.append([int(x1), int(y1), int(x2 - x1), int(y2 - y1)]) + + kept_indices = cv2.dnn.NMSBoxes( + boxes_for_nms, kept_scores.tolist(), SCORE_THRESHOLD, NMS_IOU_THRESHOLD + ) + + # 8. Build the final list of surviving phones in original-frame pixels. + phones = [] + for index in np.array(kept_indices).flatten(): + x1, y1, x2, y2 = corner_boxes[index].astype(int) + score = float(kept_scores[index]) + phones.append((int(x1), int(y1), int(x2), int(y2), score)) + return phones + + +def detect_phone(frame) -> dict: + """High-level per-frame phone event, shaped for the WebSocket protocol. + + status is "detected" if any phone is found, else "none". confidence is + the strongest phone score in the frame (0.0 when none). + """ + timestamp_ms = int(time.time() * 1000) + phones = find_phones(frame) + + if not phones: + return _build_result("none", 0.0, timestamp_ms) + + # Each phone tuple is (x1, y1, x2, y2, score); we only want the score (idx 4). + best_score = max(phone[4] for phone in phones) + return _build_result("detected", best_score, timestamp_ms) + + +def _build_result(status: str, confidence: float, timestamp_ms: int) -> dict: + """Assemble the protocol-shaped result dict in one place.""" + return { + "type": "phone", + "status": status, + "confidence": confidence, + "timestamp": timestamp_ms, + } diff --git a/python/docker_README.md b/python/docker_README.md new file mode 100644 index 0000000..c1fcacd --- /dev/null +++ b/python/docker_README.md @@ -0,0 +1,121 @@ +# Taskmaster CV Worker + +Computer vision worker responsible for phone detection and future focus monitoring features. + +## Option 1: Docker (recommended) + +No local Python setup required. + +Build the image: + +```bash +docker build -t taskmaster-cv-worker ./python +``` + +Run image-based detection: + +```bash +docker run --rm taskmaster-cv-worker python cv/phone_image_test.py test_assets/phone_sample.jpg +``` + +Expected output: + +```text +{'type': 'phone', 'status': 'detected', ...} +``` + +This uses a sample image and does not require a webcam. + +--- + +## Option 2: Local development + +Recommended for webcam testing. + +Create a virtual environment: + +```bash +cd python +python3.11 -m venv .venv +``` + +Activate it: + +Linux/macOS: + +```bash +source .venv/bin/activate +``` + +Windows: + +```powershell +.\.venv\Scripts\Activate.ps1 +``` + +Install dependencies: + +```bash +pip install -r requirements.txt +``` + +Download the model: + +```bash +./setup.sh +``` + +Run webcam test: + +```bash +python cv/phone_detect_test.py +``` + +Run detection loop: + +```bash +python cv/detection_loop.py +``` + +--- + +## Install Docker + +### Windows / macOS + +Download Docker Desktop: + +https://www.docker.com/products/docker-desktop/ + +Verify installation: + +```bash +docker --version +docker run hello-world +``` + +### Ubuntu + +```bash +sudo apt update +sudo apt install -y docker.io +sudo systemctl enable docker +sudo systemctl start docker +``` + +Verify installation: + +```bash +docker --version +docker run hello-world +``` + +--- + +## Notes + +- Python 3.11 is required. +- The YOLOX-S model is not committed to Git. +- Docker downloads the model during image build. +- Docker is intended for environment consistency and automated testing. +- Webcam testing is currently easier to perform locally. \ No newline at end of file diff --git a/python/requirements.txt b/python/requirements.txt new file mode 100644 index 0000000..a5293d1 --- /dev/null +++ b/python/requirements.txt @@ -0,0 +1,19 @@ +# Taskmaster — Python CV worker dependencies +# Built/tested on Python 3.11 (MediaPipe has no wheels for 3.13/3.14 yet). +# +# Installed for you by ./setup.sh. To do it manually: +# cd python +# python3.11 -m venv .venv +# source .venv/bin/activate +# pip install -r requirements.txt + +# --- Computer vision (needed now: phone + gaze detection) --- +opencv-python>=4.9 # webcam capture + image ops +mediapipe>=0.10.9 # face mesh for gaze detection (planned) +onnxruntime>=1.17 # runs the YOLOX phone-detection model (local, MIT) +numpy>=1.26,<2.0 # array math; pinned <2 for MediaPipe compatibility + +# --- WebSocket server (needed later: Electron <-> Python bridge) --- +fastapi>=0.110 # app + routing for the detection-event server +uvicorn[standard]>=0.27 # ASGI server that runs FastAPI +websockets>=12.0 # WebSocket transport to the Electron main process diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..9005fe0 --- /dev/null +++ b/setup.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# +# Taskmaster one-shot setup. +# Installs BOTH halves of the app: +# 1. Python CV worker -> venv at python/.venv + pip deps from python/requirements.txt +# 2. Electron app -> npm deps in electron/ +# +# Usage: +# ./setup.sh +# +# Re-runnable: safe to run again; it reuses an existing venv and npm cache. + +# Stop immediately if any command fails, and treat unset vars as errors. +set -euo pipefail + +# Always operate relative to this script's own location, no matter where it +# is called from. +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$PROJECT_ROOT" + +# --------------------------------------------------------------------------- +# 1. Python CV worker +# --------------------------------------------------------------------------- +echo "==> [1/2] Python CV worker" + +# MediaPipe has no wheels for Python 3.13/3.14, so we pin to 3.11 explicitly. +if ! command -v python3.11 >/dev/null 2>&1; then + echo "ERROR: python3.11 not found. Install it (e.g. 'brew install python@3.11') and re-run." >&2 + exit 1 +fi + +# Create the venv only if it does not already exist. +if [ ! -d "python/.venv" ]; then + echo " creating venv at python/.venv (Python 3.11)" + python3.11 -m venv python/.venv +else + echo " reusing existing venv at python/.venv" +fi + +# Install dependencies into the venv using its own pip (no need to 'activate'). +echo " installing Python dependencies" +python/.venv/bin/pip install --upgrade pip +python/.venv/bin/pip install -r python/requirements.txt + +# Download the phone-detection model (YOLOX-S, Apache-2.0). It is gitignored +# (~34 MB), so a fresh clone needs to fetch it once. +PHONE_MODEL="python/models/yolox_s.onnx" +if [ ! -f "$PHONE_MODEL" ]; then + echo " downloading phone-detection model (YOLOX-S)" + mkdir -p python/models + curl -sSL -o "$PHONE_MODEL" \ + "https://github.com/Megvii-BaseDetection/YOLOX/releases/download/0.1.1rc0/yolox_s.onnx" +else + echo " phone-detection model already present" +fi + +# --------------------------------------------------------------------------- +# 2. Electron app +# --------------------------------------------------------------------------- +echo "==> [2/2] Electron app" + +if ! command -v npm >/dev/null 2>&1; then + echo "ERROR: npm not found. Install Node.js >= 18 and re-run." >&2 + exit 1 +fi + +echo " installing npm dependencies in electron/" +( cd electron && npm install ) + +# --------------------------------------------------------------------------- +echo "" +echo "Done. To run the CV worker:" +echo " cd python && source .venv/bin/activate && python cv/detection_loop.py" +echo "To run the Electron app:" +echo " cd electron && npm run dev"