From b8f99a2203263a56dba942b78b134f788268c533 Mon Sep 17 00:00:00 2001 From: Saatvik Sharma <200460255+rxv801@users.noreply.github.com> Date: Sat, 6 Jun 2026 14:21:32 +1000 Subject: [PATCH 01/11] Add claude GitHub actions 1778132378878 (#4) * "Claude PR Assistant workflow" * "Claude Code Review workflow" From 862ec360fe526d2a42fdff4d81608ce23e263d8b Mon Sep 17 00:00:00 2001 From: Saatvik Sharma <200460255+rxv801@users.noreply.github.com> Date: Sat, 6 Jun 2026 14:43:08 +1000 Subject: [PATCH 02/11] feat(cv): phone detection (YOLOX-S via onnxruntime) (#5) * feat(cv): add phone detector placeholder + detection loop - phone_detector.py: detect_phone(frame) placeholder, returns protocol-shaped event ({type,status,confidence,timestamp}); real detector slots in behind it - detection_loop.py: camera -> detect_phone -> print loop at 10fps, clean shutdown - requirements.txt: pin CV/server deps (Python 3.11; MediaPipe lacks 3.13/3.14 wheels) - setup.sh: one-shot install for Python venv + Electron npm - docs: python/README.md (incl. camera vs loop separation rationale), root README setup Co-Authored-By: Claude Opus 4.8 * feat(cv): implement phone detection with YOLOX via onnxruntime Replace the placeholder detect_phone() with a real detector: - find_phones(frame): YOLOX-S inference -> decoded, thresholded, NMS'd boxes - detect_phone(frame): per-frame protocol event (status/confidence/timestamp) - phone_detect_test.py: visual webcam test that reuses find_phones() - lazy-loaded onnxruntime session (load model once, reuse every frame) - setup.sh fetches yolox_s.onnx; onnxruntime added to requirements - gitignore *.task for the upcoming gaze model Detector is perception only ("phone in this frame?"); distracted-state policy (smoothing / N-second timer) stays in the loop layer. --------- Co-authored-by: Claude Opus 4.8 --- .gitignore | 1 + README.md | 46 +++-- python/README.md | 93 ++++++++++ python/cv/detection_loop.py | 90 ++++++++++ python/cv/phone_detect_test.py | 58 +++++++ python/cv/phone_detector.py | 306 +++++++++++++++++++++++++++++++++ python/requirements.txt | 19 ++ setup.sh | 75 ++++++++ 8 files changed, 677 insertions(+), 11 deletions(-) create mode 100644 python/README.md create mode 100644 python/cv/detection_loop.py create mode 100644 python/cv/phone_detect_test.py create mode 100644 python/cv/phone_detector.py create mode 100644 python/requirements.txt create mode 100755 setup.sh diff --git a/.gitignore b/.gitignore index 98343bf..c77af2b 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ build/ *.pt *.pth *.onnx +*.task # Database *.sqlite diff --git a/README.md b/README.md index 58d46f5..90f3bce 100644 --- a/README.md +++ b/README.md @@ -57,11 +57,15 @@ taskmaster/ │ └── preload/ │ └── index.ts ├── python/ +│ ├── README.md # CV worker docs +│ ├── requirements.txt # Python deps (installed by setup.sh) │ ├── main.py # FastAPI + WebSocket server │ └── cv/ -│ ├── camera.py -│ ├── gaze_detector.py -│ └── phone_detector.py +│ ├── camera.py # webcam capture (owns the camera handle) +│ ├── detection_loop.py # camera -> detectors -> events loop +│ ├── phone_detector.py # phone-in-frame detection +│ └── gaze_detector.py # gaze/face detection (planned) +├── setup.sh # one-shot install for Python + Electron ├── PLAN.md └── README.md ``` @@ -69,30 +73,50 @@ taskmaster/ ## Prerequisites - Node.js >= 18 -- Python >= 3.10 +- **Python 3.11** (MediaPipe has no wheels for 3.13/3.14 yet) - A webcam ## Setup -### Python backend +One command installs both the Python CV worker and the Electron app: ```bash -cd python -python -m venv .venv -source .venv/bin/activate # Windows: .venv\Scripts\activate -pip install -r requirements.txt +./setup.sh ``` -### Electron app +It creates the Python venv at `python/.venv` (Python 3.11), installs +`requirements.txt`, and runs `npm install` in `electron/`. + +
+Manual setup (if you prefer) ```bash +# Python CV worker +cd python +python3.11 -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install -r requirements.txt +cd .. + +# Electron app cd electron npm install ``` +
+ ## Development -Start the Python CV server: +Run the CV detection loop directly (current entry point while the +WebSocket server is being built): + +```bash +cd python +source .venv/bin/activate +python cv/detection_loop.py # Ctrl+C to stop +``` + +Later, the FastAPI + WebSocket server will be the entry point instead: ```bash cd python 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..fe2d3c4 --- /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 + +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/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" From e15da537dce02bc5897de1d370383752667e04d9 Mon Sep 17 00:00:00 2001 From: lukitasxue Date: Mon, 8 Jun 2026 14:15:25 +1000 Subject: [PATCH 03/11] whitelist common windows apps detection, and UI, preparing for navigator tabs detection UI and detection logic (possibly) --- .../appDetection/detectCommonWindowsApps.ts | 148 +++++++++++++ electron/src/main/index.ts | 8 +- electron/src/main/ipc-handlers.ts | 17 +- electron/src/preload/index.js | 11 + electron/src/preload/index.ts | 3 - .../onboarding/WhitelistSelectionStep.tsx | 185 ++++++++++++---- .../hooks/useFocusEnvironmentSettings.ts | 207 ++++++++++++++++++ electron/src/renderer/index.css | 153 +++++++++++++ electron/src/renderer/vite-env.d.ts | 19 ++ .../src/shared/appDetection/commonApps.ts | 146 ++++++++++++ 10 files changed, 844 insertions(+), 53 deletions(-) create mode 100644 electron/src/main/appDetection/detectCommonWindowsApps.ts create mode 100644 electron/src/preload/index.js delete mode 100644 electron/src/preload/index.ts create mode 100644 electron/src/renderer/hooks/useFocusEnvironmentSettings.ts create mode 100644 electron/src/renderer/vite-env.d.ts create mode 100644 electron/src/shared/appDetection/commonApps.ts diff --git a/electron/src/main/appDetection/detectCommonWindowsApps.ts b/electron/src/main/appDetection/detectCommonWindowsApps.ts new file mode 100644 index 0000000..a3f8412 --- /dev/null +++ b/electron/src/main/appDetection/detectCommonWindowsApps.ts @@ -0,0 +1,148 @@ +// This file needs to be on main as we are using node APIs to detect if common apps are installed on the user's system. We will likely need to expand this in the future to support more apps and other platforms, but for now we are just focusing on a few common Windows apps. +import fs from 'node:fs' +import path from 'node:path' +import { COMMON_APPS } from "../../shared/appDetection/commonApps.ts" + +export type DetectedWindowsApp = { + id: string + displayName: string + category: 'productivity' | 'distraction' | 'browser' + executablePath: string + defaultStatus: 'allowed' | 'blocked' +} + +function expandWindowsEnvironmentPath(rawPath: string) { + return rawPath.replace(/%([^%]+)%/g, (_, variableName: string) => { + return process.env[variableName] ?? '' + }) +} + +function pathHasWildcard(filePath: string) { + return filePath.includes('*') +} + +function findWildcardPath(filePath: string) { + const normalizedPath = path.normalize(filePath) + const wildcardIndex = normalizedPath.indexOf('*') + + if (wildcardIndex === -1) { + return fs.existsSync(normalizedPath) ? normalizedPath : null + } + + const beforeWildcard = normalizedPath.slice(0, wildcardIndex) + const afterWildcard = normalizedPath.slice(wildcardIndex + 1) + + const baseDirectory = path.dirname(beforeWildcard) + const prefix = path.basename(beforeWildcard) + + try { + if (!fs.existsSync(baseDirectory)) { + return null + } + + const entries = fs.readdirSync(baseDirectory, { + withFileTypes: true, + }) + + const matchedDirectory = entries.find((entry) => { + return entry.isDirectory() && entry.name.startsWith(prefix) + }) + + if (!matchedDirectory) { + return null + } + + const possiblePath = path.join( + baseDirectory, + matchedDirectory.name, + afterWildcard + ) + + return fs.existsSync(possiblePath) ? possiblePath : null + } catch (error) { + console.warn('[Taskmaster] Could not scan wildcard path:', { + filePath, + baseDirectory, + error, + }) + + return null + } +} + +function findExistingAppPath(commonWindowsPaths: string[]) { + for (const rawPath of commonWindowsPaths) { + const expandedPath = expandWindowsEnvironmentPath(rawPath) + + console.log('[Taskmaster] Checking path:', { + rawPath, + expandedPath, + }) + + if (!expandedPath) { + continue + } + + if (pathHasWildcard(expandedPath)) { + const matchedPath = findWildcardPath(expandedPath) + + // --- debug log --- + console.log('[Taskmaster] Wildcard path result:', { + expandedPath, + matchedPath, + }) + // --- remove later --- + + if (matchedPath) { + return matchedPath + } + + continue + } + + const normalizedPath = path.normalize(expandedPath) + try { + const exists = fs.existsSync(normalizedPath) + + console.log('[Taskmaster] Path exists result:', { + normalizedPath, + exists, + }) + + if (exists) { + return normalizedPath + } + } catch (error) { + console.warn('[Taskmaster] Could not check path:', { + normalizedPath, + error, + }) + } + } + + return null +} + +export function detectCommonWindowsApps(): DetectedWindowsApp[] { + if (process.platform !== 'win32') { + return [] + } + + return COMMON_APPS.flatMap((app) => { + const executablePath = findExistingAppPath(app.commonWindowsPaths) + + if (!executablePath) { + return [] + } + + return [ + { + id: app.id, + displayName: app.displayName, + category: app.category, + executablePath, + defaultStatus: app.defaultStatus, + }, + ] + }) +} \ No newline at end of file diff --git a/electron/src/main/index.ts b/electron/src/main/index.ts index ae80b61..4464532 100644 --- a/electron/src/main/index.ts +++ b/electron/src/main/index.ts @@ -5,6 +5,9 @@ import { app, BrowserWindow, Tray, Menu, nativeImage } from 'electron' import path from 'path' import { fileURLToPath } from 'url' +import { registerIpcHandlers } from './ipc-handlers.ts' + + const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) @@ -32,6 +35,7 @@ function createWindow() { minWidth: 1000, minHeight: 700, webPreferences: { + preload: path.join(__dirname, '../preload/index.js'), nodeIntegration: false, contextIsolation: true, }, @@ -40,7 +44,9 @@ function createWindow() { win.loadURL('http://localhost:5173') } + app.whenReady().then(() => { + registerIpcHandlers() createWindow() createTray() -}) +}) \ No newline at end of file diff --git a/electron/src/main/ipc-handlers.ts b/electron/src/main/ipc-handlers.ts index 76789c4..6d4794e 100644 --- a/electron/src/main/ipc-handlers.ts +++ b/electron/src/main/ipc-handlers.ts @@ -1,2 +1,17 @@ // Registers all ipcMain.handle() and ipcMain.on() listeners. -// This is the entry point for every message the renderer sends — start session, save settings, get history, etc. \ No newline at end of file +// This is the entry point for every message the renderer sends — start session, save settings, get history, etc.import { ipcMain } from 'electron' +import { ipcMain } from 'electron' +import { detectCommonWindowsApps } from './appDetection/detectCommonWindowsApps.ts' + +export function registerIpcHandlers() { + ipcMain.removeHandler('taskmaster:detect-common-apps') + + ipcMain.handle('taskmaster:detect-common-apps', () => { + const detectedApps = detectCommonWindowsApps() + + console.log('[Taskmaster] Detected common apps:') + console.log(JSON.stringify(detectedApps, null, 2)) + + return detectedApps + }) +} \ No newline at end of file diff --git a/electron/src/preload/index.js b/electron/src/preload/index.js new file mode 100644 index 0000000..08acabf --- /dev/null +++ b/electron/src/preload/index.js @@ -0,0 +1,11 @@ +// Uses contextBridge.exposeInMainWorld() to give the renderer a safe, limited API. +// Example: window.taskmaster.startSession(). +// The renderer can never call Node directly - everything goes through here. + +const { contextBridge, ipcRenderer } = require('electron') + +console.log('Taskmaster preload loaded') + +contextBridge.exposeInMainWorld('taskmaster', { + detectCommonApps: () => ipcRenderer.invoke('taskmaster:detect-common-apps'), +}) \ No newline at end of file diff --git a/electron/src/preload/index.ts b/electron/src/preload/index.ts deleted file mode 100644 index feaa357..0000000 --- a/electron/src/preload/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Uses contextBridge.exposeInMainWorld() to give the renderer a safe, limited API. -// Example: window.taskmaster.startSession(). -// The renderer can never call Node directly — everything goes through here. \ No newline at end of file diff --git a/electron/src/renderer/components/onboarding/WhitelistSelectionStep.tsx b/electron/src/renderer/components/onboarding/WhitelistSelectionStep.tsx index 1fa4964..a6af86d 100644 --- a/electron/src/renderer/components/onboarding/WhitelistSelectionStep.tsx +++ b/electron/src/renderer/components/onboarding/WhitelistSelectionStep.tsx @@ -1,26 +1,96 @@ +import { useFocusEnvironmentSettings } from '../../hooks/useFocusEnvironmentSettings' +import type { + AppRuleStatus, + FocusApp, +} from '../../hooks/useFocusEnvironmentSettings' + type FocusEnvironmentStepProps = { onBack: () => void onContinue: () => void } -const browserItems = [ - { label: 'Chrome: GitHub', allowed: true }, - { label: 'Chrome: YouTube', allowed: false }, - { label: 'Chrome: OnTrack', allowed: true }, -] +type FocusAppRuleSectionProps = { + title: string + description: string + apps: FocusApp[] + onUpdateAppStatus: (appId: string, status: AppRuleStatus) => void +} -const appItems = [ - { label: 'VS Code', allowed: true }, - { label: 'Discord', allowed: false }, -] +function FocusAppRuleSection({ + title, + description, + apps, + onUpdateAppStatus, +}: FocusAppRuleSectionProps) { + return ( +
+
+

{title}

+

{description}

+
+ +
+ {apps.map((app) => ( +
+ {app.name} + + +
+ ))} +
+
+ ) +} 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,68 +98,87 @@ 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.

- +
-
-
-

Browser tabs

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

Apps

-
- {appItems.map((item) => ( - - ))} -
-
+
+

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

+
+ +
+ + +
- -
) -} +} \ 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..4bddb4d --- /dev/null +++ b/electron/src/renderer/hooks/useFocusEnvironmentSettings.ts @@ -0,0 +1,207 @@ +import { useEffect, useState } from 'react' +import { + getDefaultBrowserOptions, + getDefaultFocusApps, +} from '../../shared/appDetection/commonApps.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 BrowserActivityRuleStatus = 'allowed' | 'blocked' | 'ignored' + +export type BrowserActivityRule = { + id: string + label: string + matchText: string + status: BrowserActivityRuleStatus +} + +export type FocusEnvironmentSettings = { + selectedBrowserId: string + blockSelectedBrowser: boolean + appRules: FocusApp[] + browserActivityRules: BrowserActivityRule[] +} + +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() + +function createDefaultSettings(): FocusEnvironmentSettings { + return { + selectedBrowserId: defaultBrowserOptions[0]?.id ?? '', + blockSelectedBrowser: false, + appRules: defaultFocusApps, + browserActivityRules: [], + } +} + +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 + } +} + +function isDetectedFocusApp( + app: DetectedCommonApp +): app is DetectedCommonApp & { category: AppCategory } { + return app.category === 'productivity' || app.category === 'distraction' +} +// after this filter, category cannot be browser anymore + +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) + + 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]) + + const productivityApps = settings.appRules.filter( + (app) => app.category === 'productivity' + ) + + const distractionApps = settings.appRules.filter( + (app) => app.category === 'distraction' + ) + + const shouldSplitAppRules = settings.appRules.length > 6 + + 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 saveFocusEnvironmentSettings() { + localStorage.setItem( + FOCUS_ENVIRONMENT_SETTINGS_KEY, + JSON.stringify(settings) + ) + } + + return { + settings, + browserOptions, + productivityApps, + distractionApps, + shouldSplitAppRules, + setSelectedBrowserId, + setBlockSelectedBrowser, + updateAppStatus, + saveFocusEnvironmentSettings, + } +} \ No newline at end of file diff --git a/electron/src/renderer/index.css b/electron/src/renderer/index.css index 4000e00..af29971 100644 --- a/electron/src/renderer/index.css +++ b/electron/src/renderer/index.css @@ -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,148 @@ 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.85rem; + 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.38rem 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; } \ No newline at end of file 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..f6a0eaf --- /dev/null +++ b/electron/src/shared/appDetection/commonApps.ts @@ -0,0 +1,146 @@ +// +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', + }, +] + +// Utility functions to get default app lists for onboarding +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, + })) +} + +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 From 59484cfa9f1ac970572dbe0d0d81cc475f668b25 Mon Sep 17 00:00:00 2001 From: lukitasxue Date: Mon, 8 Jun 2026 14:50:51 +1000 Subject: [PATCH 04/11] minor fixes on build, unused import --- .../renderer/components/onboarding/OnboardingCameraSetup.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/electron/src/renderer/components/onboarding/OnboardingCameraSetup.tsx b/electron/src/renderer/components/onboarding/OnboardingCameraSetup.tsx index c0e9ce1..db9b0b9 100644 --- a/electron/src/renderer/components/onboarding/OnboardingCameraSetup.tsx +++ b/electron/src/renderer/components/onboarding/OnboardingCameraSetup.tsx @@ -1,5 +1,5 @@ // === camera setup === -import { useEffect, useRef, useState, } from "react"; +import { useEffect, useRef } from "react"; import { useCameraDevices } from "../../hooks/useCameraDevices"; type CameraSetupStepProps = { From e58f102f7ff05421373dd0098e0b0032f6ce3e44 Mon Sep 17 00:00:00 2001 From: Lucas Hsueh Date: Tue, 9 Jun 2026 11:41:40 +1000 Subject: [PATCH 05/11] ci: add Electron workflow (#7) * ci: add Electron workflow * ci: add Electron and Python workflows --- .github/workflows/electron-ci.yml | 41 +++++++++++++++++++++++ .github/workflows/python-ci.yml | 55 +++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 .github/workflows/electron-ci.yml create mode 100644 .github/workflows/python-ci.yml diff --git a/.github/workflows/electron-ci.yml b/.github/workflows/electron-ci.yml new file mode 100644 index 0000000..c3f8761 --- /dev/null +++ b/.github/workflows/electron-ci.yml @@ -0,0 +1,41 @@ +name: Electron CI + +on: + push: + paths: + - "electron/**" + - ".github/workflows/electron-ci.yml" + + pull_request: + paths: + - "electron/**" + - ".github/workflows/electron-ci.yml" + +jobs: + electron-checks: + name: Install, link, and build Electron app + runs-on: ubuntu-latest + + defaults: + run: + working-directory: electron + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: electron/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Run lint + run: npm run lint + + - name: Build Electron renderer + run: npm run build \ No newline at end of file diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000..49ced8f --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,55 @@ +name: Python CI + +on: + push: + paths: + - "python/**" + - ".github/workflows/python-ci.yml" + + pull_request: + paths: + - "python/**" + - ".github/workflows/python-ci.yml" + + +jobs: + python-checks: + name: Install and import-check Python CV worker + runs-on: ubuntu-latest + + defaults: + run: + working-directory: python + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + cache-dependency-path: python/requirements.txt + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Check Python imports + run: | + python - <<'PY' + import cv2 + import numpy + import onnxruntime + import fastapi + import uvicorn + import websockets + + print("Python CV dependencies imported successfully") + PY + + - name: Check project modules compile + run: | + python -m compileall . \ No newline at end of file From ae417e1a78fe12271a3d75d458c23a6093090bee Mon Sep 17 00:00:00 2001 From: Lucas Hsueh Date: Tue, 9 Jun 2026 15:17:37 +1000 Subject: [PATCH 06/11] Lucas/onboarding (#6) * 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. * changes on onboarding file names and added camera status and camera selection in camera setup 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 - .../OnboardingAdditionalFunctions.tsx | 60 + .../onboarding/OnboardingCameraSetup.tsx | 119 ++ .../onboarding/OnboardingWelcome.tsx | 31 + .../onboarding/WhitelistSelectionStep.tsx | 95 ++ .../src/renderer/hooks/useCameraDevices.ts | 105 ++ electron/src/renderer/index.css | 1044 ++++++++++++++++- electron/src/renderer/pages/MenuPage.tsx | 10 + .../src/renderer/pages/OnboardingPage.tsx | 112 ++ 15 files changed, 1580 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/OnboardingAdditionalFunctions.tsx create mode 100644 electron/src/renderer/components/onboarding/OnboardingCameraSetup.tsx create mode 100644 electron/src/renderer/components/onboarding/OnboardingWelcome.tsx create mode 100644 electron/src/renderer/components/onboarding/WhitelistSelectionStep.tsx create mode 100644 electron/src/renderer/hooks/useCameraDevices.ts 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/OnboardingAdditionalFunctions.tsx b/electron/src/renderer/components/onboarding/OnboardingAdditionalFunctions.tsx new file mode 100644 index 0000000..bebc18a --- /dev/null +++ b/electron/src/renderer/components/onboarding/OnboardingAdditionalFunctions.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/OnboardingCameraSetup.tsx b/electron/src/renderer/components/onboarding/OnboardingCameraSetup.tsx new file mode 100644 index 0000000..c0e9ce1 --- /dev/null +++ b/electron/src/renderer/components/onboarding/OnboardingCameraSetup.tsx @@ -0,0 +1,119 @@ +// === 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

+
+ +
+
+
+
+ ) +} diff --git a/electron/src/renderer/components/onboarding/OnboardingWelcome.tsx b/electron/src/renderer/components/onboarding/OnboardingWelcome.tsx new file mode 100644 index 0000000..56c24b8 --- /dev/null +++ b/electron/src/renderer/components/onboarding/OnboardingWelcome.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/components/onboarding/WhitelistSelectionStep.tsx b/electron/src/renderer/components/onboarding/WhitelistSelectionStep.tsx new file mode 100644 index 0000000..1fa4964 --- /dev/null +++ b/electron/src/renderer/components/onboarding/WhitelistSelectionStep.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/hooks/useCameraDevices.ts b/electron/src/renderer/hooks/useCameraDevices.ts new file mode 100644 index 0000000..35406f6 --- /dev/null +++ b/electron/src/renderer/hooks/useCameraDevices.ts @@ -0,0 +1,105 @@ +// detect camera devices and manage camera stream for onboarding camera setup step +import { useEffect, useState } from "react"; + +type CameraStatus = + | "checking" + | "connected" + | "no-camera" + | "permission-denied" + | "error"; + +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"; + + async function selectCamera(cameraId: string): Promise { + 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, + }); + + permissionStream.getTracks().forEach((track) => track.stop()); + + const devices = await navigator.mediaDevices.enumerateDevices(); + const videoDevices = devices.filter( + (device) => device.kind === "videoinput", + ); + + 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, + ); + + if (savedCameraId && savedCameraStillExists) { + await selectCamera(savedCameraId); + } else { + await selectCamera(videoDevices[0].deviceId); + } + } catch (error) { + console.error("Error accessing cameras:", error); + + if (error instanceof DOMException && error.name === "NotAllowedError") { + setCameraStatus("permission-denied"); + } else { + 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, + }); + + setStream(newStream); + setCameraStatus("connected"); + } catch (error) { + console.error(error); + setCameraStatus("error"); + } + } + + useEffect(() => { + loadCameras(); + }, []); + + useEffect(() => { + if (selectedCameraId) { + startCamera(selectedCameraId); + } + }, [selectedCameraId]); + + return { + cameras, + selectedCameraId, + selectCamera, + stream, + cameraStatus, + }; +} diff --git a/electron/src/renderer/index.css b/electron/src/renderer/index.css index 9aaae3f..4000e00 100644 --- a/electron/src/renderer/index.css +++ b/electron/src/renderer/index.css @@ -1,2 +1,1042 @@ -/*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; + } +} + + + +/* Camera Status Dots */ +.camera-status-dot { + width: 0.6rem; + height: 0.6rem; + border-radius: 999px; + display: inline-block; +} + +.camera-status-dot--connected { + background: var(--color-focused); +} + +.camera-status-dot--error { + background: var(--color-distracted); +} \ No newline at end of file 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..c2843c1 --- /dev/null +++ b/electron/src/renderer/pages/OnboardingPage.tsx @@ -0,0 +1,112 @@ +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' + +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 f11d0714cbb8de575df99cbe69e049f69a58a761 Mon Sep 17 00:00:00 2001 From: lukitasxue Date: Wed, 10 Jun 2026 15:12:16 +1000 Subject: [PATCH 07/11] no changes --- python/cv/phone_detect_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/cv/phone_detect_test.py b/python/cv/phone_detect_test.py index fe2d3c4..804c39d 100644 --- a/python/cv/phone_detect_test.py +++ b/python/cv/phone_detect_test.py @@ -13,7 +13,7 @@ Press 'q' (video window focused) or Ctrl+C to quit. """ -import cv2 +import cv2 # type: ignore import phone_detector From 7c06c7850b48ac6f2ead6e0fc3313797d1bc8b17 Mon Sep 17 00:00:00 2001 From: lukitasxue Date: Wed, 10 Jun 2026 15:30:50 +1000 Subject: [PATCH 08/11] merge conflicts solved --- .../onboarding/OnboardingCameraSetup.tsx | 4 - .../onboarding/WhitelistSelectionStep.tsx | 83 ------------------- electron/src/renderer/index.css | 6 -- 3 files changed, 93 deletions(-) diff --git a/electron/src/renderer/components/onboarding/OnboardingCameraSetup.tsx b/electron/src/renderer/components/onboarding/OnboardingCameraSetup.tsx index 27be6c3..db9b0b9 100644 --- a/electron/src/renderer/components/onboarding/OnboardingCameraSetup.tsx +++ b/electron/src/renderer/components/onboarding/OnboardingCameraSetup.tsx @@ -1,9 +1,5 @@ // === camera setup === -<<<<<<< HEAD import { useEffect, useRef } from "react"; -======= -import { useEffect, useRef, useState, } from "react"; ->>>>>>> ae417e1a78fe12271a3d75d458c23a6093090bee import { useCameraDevices } from "../../hooks/useCameraDevices"; type CameraSetupStepProps = { diff --git a/electron/src/renderer/components/onboarding/WhitelistSelectionStep.tsx b/electron/src/renderer/components/onboarding/WhitelistSelectionStep.tsx index 40175ed..1f89f1b 100644 --- a/electron/src/renderer/components/onboarding/WhitelistSelectionStep.tsx +++ b/electron/src/renderer/components/onboarding/WhitelistSelectionStep.tsx @@ -1,18 +1,14 @@ -<<<<<<< HEAD import { useFocusEnvironmentSettings } from '../../hooks/useFocusEnvironmentSettings' import type { AppRuleStatus, FocusApp, } from '../../hooks/useFocusEnvironmentSettings' -======= ->>>>>>> ae417e1a78fe12271a3d75d458c23a6093090bee type FocusEnvironmentStepProps = { onBack: () => void onContinue: () => void } -<<<<<<< HEAD type FocusAppRuleSectionProps = { title: string description: string @@ -64,24 +60,11 @@ function FocusAppRuleSection({
) } -======= -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 }, -] ->>>>>>> ae417e1a78fe12271a3d75d458c23a6093090bee export default function FocusEnvironmentStep({ onBack, onContinue, }: FocusEnvironmentStepProps) { -<<<<<<< HEAD const { settings, browserOptions, @@ -108,11 +91,6 @@ export default function FocusEnvironmentStep({

Step 3

-======= - return ( -
-

Step 3

->>>>>>> ae417e1a78fe12271a3d75d458c23a6093090bee
@@ -120,7 +98,6 @@ export default function FocusEnvironmentStep({ Focus environment

-<<<<<<< HEAD Choose which apps are allowed during your deep work sessions.

@@ -129,22 +106,12 @@ export default function FocusEnvironmentStep({ Taskmaster checks the active app during focus sessions. Apps not recognised yet will be marked as unknown and can be reviewed after the session. -======= - 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. ->>>>>>> ae417e1a78fe12271a3d75d458c23a6093090bee

- - -
- -
-
-

Browser tabs

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

Apps

-
- {appItems.map((item) => ( - - ))} -
-
->>>>>>> ae417e1a78fe12271a3d75d458c23a6093090bee
-<<<<<<< HEAD @@ -248,20 +175,10 @@ export default function FocusEnvironmentStep({ type="button" onClick={handleContinue} > -======= - -
) -<<<<<<< HEAD -} -======= } ->>>>>>> ae417e1a78fe12271a3d75d458c23a6093090bee diff --git a/electron/src/renderer/index.css b/electron/src/renderer/index.css index 824f05e..af29971 100644 --- a/electron/src/renderer/index.css +++ b/electron/src/renderer/index.css @@ -1021,7 +1021,6 @@ a { .onboarding-fixed-actions .secondary-button { flex: 1 1 0; } -<<<<<<< HEAD .browser-block-toggle { max-width: none; @@ -1031,8 +1030,6 @@ a { grid-template-columns: minmax(0, 1fr); } -======= ->>>>>>> ae417e1a78fe12271a3d75d458c23a6093090bee } @@ -1051,7 +1048,6 @@ a { .camera-status-dot--error { background: var(--color-distracted); -<<<<<<< HEAD } @@ -1196,6 +1192,4 @@ a { .focus-app-toggle input:focus-visible + span { outline: 2px solid var(--color-accent-bright); outline-offset: 3px; -======= ->>>>>>> ae417e1a78fe12271a3d75d458c23a6093090bee } \ No newline at end of file From 251210cfac53d235b68da5e0fec0a23f12e38865 Mon Sep 17 00:00:00 2001 From: lukitasxue Date: Wed, 10 Jun 2026 17:39:30 +1000 Subject: [PATCH 09/11] Add browser activity onboarding rules UI and logic --- .../BrowserActivitySelectionStep.tsx | 184 ++++++++++++++++++ .../OnboardingAdditionalFunctions.tsx | 6 + .../onboarding/WhitelistSelectionStep.tsx | 33 ++++ .../hooks/useFocusEnvironmentSettings.ts | 111 +++++++++-- electron/src/renderer/index.css | 83 +++++++- .../src/renderer/pages/OnboardingPage.tsx | 23 ++- .../src/shared/appDetection/commonApps.ts | 22 ++- .../commonBrowserActivityRules.ts | 112 +++++++++++ electron/src/shared/protocol.ts | 2 - 9 files changed, 552 insertions(+), 24 deletions(-) create mode 100644 electron/src/renderer/components/onboarding/BrowserActivitySelectionStep.tsx create mode 100644 electron/src/shared/browserActivity/commonBrowserActivityRules.ts delete mode 100644 electron/src/shared/protocol.ts diff --git a/electron/src/renderer/components/onboarding/BrowserActivitySelectionStep.tsx b/electron/src/renderer/components/onboarding/BrowserActivitySelectionStep.tsx new file mode 100644 index 0000000..5741011 --- /dev/null +++ b/electron/src/renderer/components/onboarding/BrowserActivitySelectionStep.tsx @@ -0,0 +1,184 @@ +/** + * Browser activity onboarding step. + * + * This screen lets the user decide which common websites should be treated as + * allowed or blocked during focus sessions. + * + * It does not detect websites yet. Later, Taskmaster can compare the active + * browser window title against the matchText values stored in the settings. + */ + +import { useFocusEnvironmentSettings } from '../../hooks/useFocusEnvironmentSettings' +import type { + BrowserActivityRule, + BrowserActivityRuleStatus, +} from '../../hooks/useFocusEnvironmentSettings' + + +type BrowserActivitySelectionStepProps = { + onBack: () => void + onContinue: () => void +} + +type BrowserActivityRuleSectionProps = { + title: string + description: string + rules: BrowserActivityRule[] + onUpdateRuleStatus: ( + ruleId: string, + status: BrowserActivityRuleStatus + ) => void +} + + + + + +/** + * Renders browser activity rules from useFocusEnvironmentSettings. + * + * The settings hook owns the data and update logic. This component only renders + * the onboarding UI and forwards toggle changes back to the hook. + */ + +function BrowserActivityRuleSection({ + title, + description, + rules, + onUpdateRuleStatus, +}: BrowserActivityRuleSectionProps) { + + return ( +
+
+

{title}

+

{description}

+
+ +
+ {rules.map((rule) => { + const isBlocked = rule.status === 'blocked' + + return ( +
+
+ {rule.label} + + {rule.description} + +
+ + +
+ ) + })} +
+
+ ) +} + +/** + * Onboarding step for configuring common browser page rules. + * + * For now, this is only static UI state. In the next step, these rules should + * be moved into useFocusEnvironmentSettings so Back/Continue can save them to + * localStorage together with the desktop app rules. + */ +export default function BrowserActivitySelectionStep({ + onBack, + onContinue, +}: BrowserActivitySelectionStepProps) { + const { + blockedBrowserActivityRules, + flexibleBrowserActivityRules, + updateBrowserActivityRuleStatus, + saveFocusEnvironmentSettings, + } = useFocusEnvironmentSettings() + + function handleBack() { + saveFocusEnvironmentSettings() + onBack() + } + + function handleContinue() { + saveFocusEnvironmentSettings() + onContinue() + } + return ( +
+

Step 4

+ +
+
+
+

+ Browser activity +

+

+ Choose which websites should count as distractions during focus + sessions. +

+
+ +

+ For the MVP, Taskmaster will estimate browser activity from the + active window title, such as “YouTube - Google Chrome”. +

+
+ +
+
+

+ These websites do not need to be installed. They are common page + patterns that Taskmaster can later match while your browser is + open. +

+
+ +
+ + + +
+
+ +
+ + + +
+
+
+ ) +} \ No newline at end of file diff --git a/electron/src/renderer/components/onboarding/OnboardingAdditionalFunctions.tsx b/electron/src/renderer/components/onboarding/OnboardingAdditionalFunctions.tsx index bebc18a..1a9595b 100644 --- a/electron/src/renderer/components/onboarding/OnboardingAdditionalFunctions.tsx +++ b/electron/src/renderer/components/onboarding/OnboardingAdditionalFunctions.tsx @@ -1,3 +1,9 @@ +/** + * Final onboarding options screen. + * + * This screen is currently a placeholder for future focus-session guardrails. + * The options shown here are not fully wired into session behavior yet. + */ type DistractionOptionsStepProps = { onBack: () => void onFinish: () => void diff --git a/electron/src/renderer/components/onboarding/WhitelistSelectionStep.tsx b/electron/src/renderer/components/onboarding/WhitelistSelectionStep.tsx index 1f89f1b..85ae15f 100644 --- a/electron/src/renderer/components/onboarding/WhitelistSelectionStep.tsx +++ b/electron/src/renderer/components/onboarding/WhitelistSelectionStep.tsx @@ -1,3 +1,16 @@ +/** + * Desktop app whitelist step for onboarding. + * + * This screen lets the user choose which detected desktop apps should be + * treated as allowed or blocked during focus sessions. + * + * Important: + * - This file is only for installed desktop apps. + * - Browser tabs/websites such as YouTube, Gmail, Netflix, or ChatGPT + * should be handled in a separate browser activity step later. + * - The detection and localStorage logic lives in useFocusEnvironmentSettings. + */ + import { useFocusEnvironmentSettings } from '../../hooks/useFocusEnvironmentSettings' import type { AppRuleStatus, @@ -16,6 +29,16 @@ type FocusAppRuleSectionProps = { onUpdateAppStatus: (appId: string, status: AppRuleStatus) => void } +/** + * Renders one group of desktop app rules. + * + * Example groups: + * - Productivity apps + * - Potential distractions + * + * Each app stays in its original group, but the user can toggle whether it is + * currently allowed or blocked during focus sessions. + */ function FocusAppRuleSection({ title, description, @@ -61,6 +84,16 @@ function FocusAppRuleSection({ ) } +/** + * 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, diff --git a/electron/src/renderer/hooks/useFocusEnvironmentSettings.ts b/electron/src/renderer/hooks/useFocusEnvironmentSettings.ts index 4bddb4d..c702798 100644 --- a/electron/src/renderer/hooks/useFocusEnvironmentSettings.ts +++ b/electron/src/renderer/hooks/useFocusEnvironmentSettings.ts @@ -1,8 +1,24 @@ +/** + * 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 { - getDefaultBrowserOptions, - getDefaultFocusApps, -} from '../../shared/appDetection/commonApps.ts' + getDefaultBrowserActivityRules, + type BrowserActivityRule, + type BrowserActivityRuleStatus, +} from '../../shared/commonBrowserActivityRules.ts' export type AppCategory = 'productivity' | 'distraction' export type AppRuleStatus = 'allowed' | 'blocked' @@ -19,15 +35,6 @@ export type BrowserOption = { name: string } -export type BrowserActivityRuleStatus = 'allowed' | 'blocked' | 'ignored' - -export type BrowserActivityRule = { - id: string - label: string - matchText: string - status: BrowserActivityRuleStatus -} - export type FocusEnvironmentSettings = { selectedBrowserId: string blockSelectedBrowser: boolean @@ -35,6 +42,11 @@ export type FocusEnvironmentSettings = { browserActivityRules: BrowserActivityRule[] } +export type { + BrowserActivityRule, + BrowserActivityRuleStatus, +} from '../../shared/commonBrowserActivityRules.ts' + type DetectedCommonApp = { id: string displayName: string @@ -47,16 +59,25 @@ 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: [], + browserActivityRules: defaultBrowserActivityRules, } } + function loadFocusEnvironmentSettings(): FocusEnvironmentSettings | null { const savedSettings = localStorage.getItem(FOCUS_ENVIRONMENT_SETTINGS_KEY) @@ -72,12 +93,20 @@ function loadFocusEnvironmentSettings(): FocusEnvironmentSettings | 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' } -// after this filter, category cannot be browser anymore +// ====== \\ + function convertDetectedAppsToFocusApps( detectedApps: DetectedCommonApp[] @@ -113,6 +142,14 @@ export function useFocusEnvironmentSettings() { 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) { @@ -147,7 +184,11 @@ export function useFocusEnvironmentSettings() { loadDetectedApps() }, [hasSavedSettings]) + // ===== \\ + /** + * Derived desktop app groups for the desktop app whitelist UI. + */ const productivityApps = settings.appRules.filter( (app) => app.category === 'productivity' ) @@ -155,9 +196,29 @@ export function useFocusEnvironmentSettings() { 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, @@ -186,6 +247,23 @@ export function useFocusEnvironmentSettings() { })) } + 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, @@ -203,5 +281,8 @@ export function useFocusEnvironmentSettings() { 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 af29971..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 { @@ -1069,7 +1069,7 @@ a { display: grid; align-content: start; gap: var(--space-sm); - padding: 0.85rem; + 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); @@ -1105,7 +1105,7 @@ a { grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: var(--space-sm); - padding: 0.38rem 0.48rem; + 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); @@ -1192,4 +1192,81 @@ a { .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/shared/appDetection/commonApps.ts b/electron/src/shared/appDetection/commonApps.ts index f6a0eaf..5459bfa 100644 --- a/electron/src/shared/appDetection/commonApps.ts +++ b/electron/src/shared/appDetection/commonApps.ts @@ -1,4 +1,13 @@ -// +/** + * 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 = { @@ -112,7 +121,11 @@ export const COMMON_APPS: CommonAppDefinition[] = [ }, ] -// Utility functions to get default app lists for onboarding + +/** + * 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 @@ -136,6 +149,11 @@ export function getDefaultFocusApps(): DefaultFocusApp[] { })) } + +/** + * 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') 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 From d6153aa1fcbf36a019affcca341c94ab5974487b Mon Sep 17 00:00:00 2001 From: lukitasxue Date: Wed, 10 Jun 2026 17:40:27 +1000 Subject: [PATCH 10/11] Clean up camera setup lifecycle and comments --- .../onboarding/OnboardingCameraSetup.tsx | 21 +- .../onboarding/OnboardingWelcome.tsx | 6 + .../src/renderer/hooks/useCameraDevices.ts | 238 +++++++++++++----- 3 files changed, 199 insertions(+), 66 deletions(-) diff --git a/electron/src/renderer/components/onboarding/OnboardingCameraSetup.tsx b/electron/src/renderer/components/onboarding/OnboardingCameraSetup.tsx index db9b0b9..d24000a 100644 --- a/electron/src/renderer/components/onboarding/OnboardingCameraSetup.tsx +++ b/electron/src/renderer/components/onboarding/OnboardingCameraSetup.tsx @@ -1,3 +1,12 @@ +/** + * Camera setup onboarding screen. + * + * This screen lets the user preview and select the camera Taskmaster will use + * during focus sessions. + * + * The camera stream is owned by useCameraDevices. When this screen unmounts, + * the hook stops the stream so the camera turns off. + */ // === camera setup === import { useEffect, useRef } from "react"; import { useCameraDevices } from "../../hooks/useCameraDevices"; @@ -33,6 +42,12 @@ export default function CameraSetupStep({ const isCameraConnected = cameraStatus === "connected"; + /** + * Attach the MediaStream from the hook to the video element. + * + * React cannot set srcObject directly through JSX, so this needs to be done + * imperatively through a ref. + */ useEffect(() => { if (videoRef.current && stream) { videoRef.current.srcObject = stream; @@ -63,8 +78,10 @@ const isCameraConnected = cameraStatus === "connected"; Camera