Subh775 Claude Opus 5 commited on
Commit
f048b9a
·
1 Parent(s): a018ed7

fix: upload reliability, counting-line accuracy, security hardening, dead code

Browse files

Upload flow (reported: "network failed", stall at 98% then blank screen)
- sw.js no longer intercepts non-GET requests. Re-issuing the upload POST
through respondWith() surfaced any hiccup as an opaque network failure,
and caches.match() on a POST resolves undefined. Also skips dynamic
endpoints and cleans up stale cache versions.
- Progress bar is monotonic (it used to jump 98% -> 95%), plateaus at 90%
with a "Processing on server..." label instead of freezing silently, and
only reaches 100% once the preview frame is decoded and painted — so the
frame is on screen when the step switches, never a blank panel.
- Surfaces the server's own error (rate limit / too large / bad type),
adds a 10min timeout, and returns the dropzone so retry needs no reload.

Counting-line accuracy
- The frame is object-contain, so it is letterboxed whenever the canvas
aspect differs from the video's — always on mobile, where CSS forces 4/3.
Canvas coords were mapped straight to image pixels, placing the line in
the wrong spot. All mapping now goes through the displayed image rect,
and points are stored in image space so resize/rotate redraws correctly.
- Reject a zero-length line (both points identical) in UI and geometry.

Security
- Validate video_id against the generated format before any filesystem
join (reports / bundle / config / first-frame).
- HTML-escape all user-supplied feedback fields before they are
interpolated into the notification email.
- HF_TOKEN no longer passed through a shell command line; use urllib.
- Stop returning stack traces and raw exception strings to clients.
- Stream uploads with a running size cap; clamp client-supplied conf /
iou / stride; rate-limit and bound the feedback endpoint.

Correctness / robustness
- engine: confidence lookup was list(ids).index(obj_id) — O(n) per
detection and wrong on repeated ids; use the aligned index.
- Guard fps=0 and frame_count=0 containers (division by zero).
- Return 404/422 instead of crashing on unknown or undecodable videos.
- Uploads no longer delete every other in-flight user's file; evict by
age with a disk cap and clean up the matching report artifacts.
- auth: clear new_user after onboarding — the username form reappeared.
- feedback: category/use-case were read as <select> after the migration to
custom dropdowns, so both were always submitted empty.
- Palette preference was written to a control that does not exist and read
from a key nothing wrote; wire it to the dropdown that is actually there.
- Idempotent modal injection (both pages injected duplicate element IDs).

UI/UX + accessibility
- Restore pinch-zoom (WCAG 1.4.4); add viewport-fit + safe-area insets so
the bottom nav clears the iOS home indicator.
- Shortcuts modal shows once, not on every single visit.
- aria-labels on the icon-only mobile nav; reachable mobile sign-out.
- imgsz stepper no longer offers to change a value fixed by the compiled
OpenVINO graph.

Dead code
- Remove unreachable SPA branches (showDashboard / showOnboardingPhase /
trackFunnel / closeLandingProfileMenu were never defined), injectNavigation,
the duplicate legal menu and its duplicate element id, the duplicate
service-worker registration and toggleLegalMenu, boolBadge, and ~190
lines of unreferenced CSS.

Adds backend/test_core.py — runnable check over the counting geometry,
PCU conversion, speed binning, and id validation.

Note: the HF idle-sleep behaviour is untouched, as requested.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

.gitignore CHANGED
@@ -1 +1,4 @@
1
- .env
 
 
 
 
1
+ .env
2
+ __pycache__/
3
+ *.pyc
4
+ backend/weights/
backend/config.py CHANGED
@@ -1,11 +1,14 @@
1
  import cv2
2
  import multiprocessing as mp
3
 
4
- BASE_IMG_SIZE = 640
5
  REF_PIXELS = 640 * 640
6
  REF_FPS_CPU = 13.0
7
  TRACK_STABILITY_STRIDE = 3
8
 
 
 
 
 
9
 
10
  def _cpu_score():
11
  return mp.cpu_count()
@@ -29,18 +32,6 @@ def _estimate_fps(imgsz, cpu_score):
29
  return (REF_FPS_CPU * cpu_score / 12) / scale
30
 
31
 
32
- def _select_imgsz(pixels):
33
- if pixels >= 3840 * 2160:
34
- return 640
35
- if pixels >= 2560 * 1440:
36
- return 704
37
- if pixels >= 1920 * 1080:
38
- return 736
39
- if pixels >= 1280 * 720:
40
- return 800
41
- return 960
42
-
43
-
44
  def _select_stride(video_fps, model_fps):
45
  if model_fps >= video_fps:
46
  return 1
@@ -53,7 +44,7 @@ def get_optimal_config(video_path):
53
  fps, frames, duration, w, h, pixels = _video_meta(video_path)
54
  cpu_score = _cpu_score()
55
 
56
- imgsz = _select_imgsz(pixels)
57
  model_fps = _estimate_fps(imgsz, cpu_score)
58
  detect_stride = _select_stride(fps, model_fps)
59
  effective_fps = model_fps / detect_stride
 
1
  import cv2
2
  import multiprocessing as mp
3
 
 
4
  REF_PIXELS = 640 * 640
5
  REF_FPS_CPU = 13.0
6
  TRACK_STABILITY_STRIDE = 3
7
 
8
+ # Fixed by the OpenVINO export: the compiled graph has a static input shape,
9
+ # so this is not tunable per-video. engine.py passes the same value to track().
10
+ IMGSZ = 736
11
+
12
 
13
  def _cpu_score():
14
  return mp.cpu_count()
 
32
  return (REF_FPS_CPU * cpu_score / 12) / scale
33
 
34
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  def _select_stride(video_fps, model_fps):
36
  if model_fps >= video_fps:
37
  return 1
 
44
  fps, frames, duration, w, h, pixels = _video_meta(video_path)
45
  cpu_score = _cpu_score()
46
 
47
+ imgsz = IMGSZ
48
  model_fps = _estimate_fps(imgsz, cpu_score)
49
  detect_stride = _select_stride(fps, model_fps)
50
  effective_fps = model_fps / detect_stride
backend/engine.py CHANGED
@@ -3,7 +3,6 @@ import time
3
  import tempfile
4
  import threading
5
  import queue
6
- import numpy as np
7
  import cv2
8
  from collections import defaultdict
9
  from pcu import compute_pcu, MODEL_CLASSES
@@ -97,7 +96,9 @@ def run(model, video_path, line, config, on_frame, save_annotated=False, annotat
97
  annotated_options["bbox"] = True
98
 
99
  cap = cv2.VideoCapture(video_path)
100
- fps = cap.get(cv2.CAP_PROP_FPS)
 
 
101
  total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
102
  out_w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
103
  out_h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
@@ -106,8 +107,9 @@ def run(model, video_path, line, config, on_frame, save_annotated=False, annotat
106
  # Dynamic crossing threshold: 5% of frame height, min 40px
107
  cross_dist = max(40, int(out_h * 0.05))
108
 
109
- stride = config["detect_stride"]
110
- total_iters = total // stride
 
111
 
112
  # Annotated video writer (temp directory — auto-cleaned on container shutdown)
113
  annotated_path = None
@@ -180,11 +182,14 @@ def run(model, video_path, line, config, on_frame, save_annotated=False, annotat
180
  cur_boxes = xyxy
181
  cur_ids = ids
182
 
183
- for obj_id, c, box in zip(ids, cls, xyxy):
184
  cx = int((box[0] + box[2]) / 2)
185
  cy = int((box[1] + box[3]) / 2)
186
 
187
- heatmap_points.append([cx, cy, float(r.boxes.conf.cpu().numpy()[list(ids).index(obj_id)])])
 
 
 
188
  track_positions[obj_id].append((frame_idx, cx, cy))
189
 
190
  if not valid_line:
 
3
  import tempfile
4
  import threading
5
  import queue
 
6
  import cv2
7
  from collections import defaultdict
8
  from pcu import compute_pcu, MODEL_CLASSES
 
96
  annotated_options["bbox"] = True
97
 
98
  cap = cv2.VideoCapture(video_path)
99
+ # A container with broken/absent fps metadata reports 0 — that would divide
100
+ # by zero in every crossing timestamp below.
101
+ fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
102
  total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
103
  out_w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
104
  out_h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
 
107
  # Dynamic crossing threshold: 5% of frame height, min 40px
108
  cross_dist = max(40, int(out_h * 0.05))
109
 
110
+ stride = max(1, int(config.get("detect_stride", 1) or 1))
111
+ # Some containers report frame_count as 0; never hand the UI a zero divisor.
112
+ total_iters = max(1, total // stride)
113
 
114
  # Annotated video writer (temp directory — auto-cleaned on container shutdown)
115
  annotated_path = None
 
182
  cur_boxes = xyxy
183
  cur_ids = ids
184
 
185
+ for i, (obj_id, c, box) in enumerate(zip(ids, cls, xyxy)):
186
  cx = int((box[0] + box[2]) / 2)
187
  cy = int((box[1] + box[3]) / 2)
188
 
189
+ # confs is already on CPU and index-aligned with ids —
190
+ # the old list(ids).index() lookup was O(n) per detection
191
+ # and returned the wrong conf whenever an id repeated.
192
+ heatmap_points.append([cx, cy, float(confs[i])])
193
  track_positions[obj_id].append((frame_idx, cx, cy))
194
 
195
  if not valid_line:
backend/geometry.py CHANGED
@@ -8,5 +8,8 @@ def _point_to_segment_dist(px, py, ax, ay, bx, by):
8
  B = np.array([bx, by], dtype=float)
9
  P = np.array([px, py], dtype=float)
10
  AB = B - A
11
- t = np.clip(np.dot(P - A, AB) / np.dot(AB, AB), 0, 1)
12
- return np.linalg.norm(P - (A + t * AB))
 
 
 
 
8
  B = np.array([bx, by], dtype=float)
9
  P = np.array([px, py], dtype=float)
10
  AB = B - A
11
+ denom = np.dot(AB, AB)
12
+ if denom == 0: # degenerate segment: fall back to point distance
13
+ return float(np.linalg.norm(P - A))
14
+ t = np.clip(np.dot(P - A, AB) / denom, 0, 1)
15
+ return float(np.linalg.norm(P - (A + t * AB)))
backend/model.py CHANGED
@@ -1,4 +1,6 @@
1
  import os
 
 
2
  from pathlib import Path
3
  from dotenv import load_dotenv
4
  from ultralytics import YOLO
@@ -20,15 +22,12 @@ _HF_BASE = (
20
  _OV_FILES = ["best.bin", "best.xml", "metadata.yaml"]
21
 
22
 
23
- def _auth_header():
24
- token = os.getenv("HF_TOKEN", "")
25
- return f'-H "Authorization: Bearer {token}"' if token else ""
26
-
27
-
28
  def _download_ov_model():
29
  """Download the pre-built OV INT8 model files directly from HF."""
30
  OV_DIR.mkdir(parents=True, exist_ok=True)
31
- auth = _auth_header()
 
 
32
  for filename in _OV_FILES:
33
  dest = OV_DIR / filename
34
  if dest.exists():
@@ -36,12 +35,21 @@ def _download_ov_model():
36
  continue
37
  url = f"{_HF_BASE}/{filename}"
38
  print(f"[model] Downloading {filename} ...")
39
- ret = os.system(f'curl -L --fail {auth} -o "{dest}" "{url}"')
40
- if ret != 0 or not dest.exists() or dest.stat().st_size < 100:
 
 
 
 
 
 
41
  raise RuntimeError(
42
- f"[model] Failed to download {filename} from HF. "
43
  "Check HF_TOKEN and repo visibility."
44
  )
 
 
 
45
  print("[model] OV model files ready ✅")
46
 
47
 
 
1
  import os
2
+ import shutil
3
+ import urllib.request
4
  from pathlib import Path
5
  from dotenv import load_dotenv
6
  from ultralytics import YOLO
 
22
  _OV_FILES = ["best.bin", "best.xml", "metadata.yaml"]
23
 
24
 
 
 
 
 
 
25
  def _download_ov_model():
26
  """Download the pre-built OV INT8 model files directly from HF."""
27
  OV_DIR.mkdir(parents=True, exist_ok=True)
28
+ token = os.getenv("HF_TOKEN", "")
29
+ headers = {"Authorization": f"Bearer {token}"} if token else {}
30
+
31
  for filename in _OV_FILES:
32
  dest = OV_DIR / filename
33
  if dest.exists():
 
35
  continue
36
  url = f"{_HF_BASE}/{filename}"
37
  print(f"[model] Downloading {filename} ...")
38
+ try:
39
+ # urlopen, not `os.system("curl ...")` keeps HF_TOKEN out of the
40
+ # shell command line (visible in /proc) and out of shell quoting.
41
+ req = urllib.request.Request(url, headers=headers)
42
+ with urllib.request.urlopen(req) as resp, open(dest, "wb") as f:
43
+ shutil.copyfileobj(resp, f)
44
+ except Exception as e:
45
+ dest.unlink(missing_ok=True)
46
  raise RuntimeError(
47
+ f"[model] Failed to download {filename} from HF ({e}). "
48
  "Check HF_TOKEN and repo visibility."
49
  )
50
+ if dest.stat().st_size < 100:
51
+ dest.unlink(missing_ok=True)
52
+ raise RuntimeError(f"[model] {filename} downloaded but is truncated.")
53
  print("[model] OV model files ready ✅")
54
 
55
 
backend/server.py CHANGED
@@ -1,11 +1,12 @@
1
  import os
 
 
2
  import json
3
  import uuid
4
  import asyncio
5
  import tempfile
6
  import shutil
7
  from pathlib import Path
8
- import zipfile
9
 
10
  import cv2
11
  import time
@@ -47,19 +48,58 @@ run_results = {}
47
  model = None
48
 
49
  MAX_MEMORY_ENTRIES = 50
 
 
 
 
 
50
  def evict_old(d):
51
  while len(d) > MAX_MEMORY_ENTRIES:
52
  d.pop(next(iter(d)))
53
 
54
- UPLOAD_LIMITS = {}
55
- def is_rate_limited(ip: str):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  now = time.time()
57
- stamps = [t for t in UPLOAD_LIMITS.get(ip, []) if now - t < 60]
58
- if len(stamps) >= 5:
59
- UPLOAD_LIMITS[ip] = stamps
 
 
 
 
60
  return True
61
  stamps.append(now)
62
- UPLOAD_LIMITS[ip] = stamps
63
  return False
64
 
65
  MAX_UPLOAD_BYTES = 500 * 1024 * 1024
@@ -109,58 +149,69 @@ async def upload(request: Request, file: UploadFile = File(...)):
109
  if is_rate_limited(client_ip):
110
  return JSONResponse({"error": "Rate limit exceeded. Please wait a minute."}, status_code=429)
111
 
112
- if not file.content_type.startswith("video/"):
113
  return JSONResponse({"error": "Invalid file type. Only videos are allowed."}, status_code=400)
114
 
115
- if hasattr(file, 'size') and file.size and file.size > MAX_UPLOAD_BYTES:
116
- return JSONResponse({"error": "File too large. Maximum size is 500MB."}, status_code=413)
117
-
118
- video_id = str(uuid.uuid4())[:8]
119
  path = UPLOAD_DIR / f"{video_id}.mp4"
120
 
121
- # Clean up any previous temp uploads to avoid stale state
122
- for old_path in UPLOAD_DIR.glob("*.mp4"):
123
- try:
124
- old_path.unlink()
125
- except Exception:
126
- pass
127
-
128
  print(f"[BACKEND] Received upload request: {file.filename}")
129
  try:
 
 
130
  with open(path, "wb") as f:
131
- shutil.copyfileobj(file.file, f)
132
-
133
- file_size = os.path.getsize(path)
134
- if file_size > MAX_UPLOAD_BYTES:
135
- path.unlink()
136
- return JSONResponse({"error": "File too large. Maximum size is 500MB."}, status_code=413)
137
-
138
- print(f"[BACKEND] Successfully stored: {path} ({file_size} bytes)")
 
 
 
 
139
 
140
  videos[video_id] = str(path)
141
  video_info[video_id] = file.filename
142
- evict_old(videos)
143
- evict_old(video_info)
144
  return {"video_id": video_id}
145
  except Exception as e:
 
146
  print(f"[BACKEND] Upload failed: {str(e)}")
147
- return JSONResponse({"error": str(e)}, status_code=500)
 
 
 
 
 
 
 
 
148
 
149
 
150
  @app.get("/config/{video_id}")
151
  def config_endpoint(video_id: str):
152
- path = videos.get(video_id)
153
- cfg = get_optimal_config(path)
154
- return cfg
 
155
 
156
 
157
  @app.get("/first-frame/{video_id}")
158
  def first_frame(video_id: str):
159
- path = videos.get(video_id)
 
 
160
  cap = cv2.VideoCapture(path)
161
  ret, frame = cap.read()
162
  cap.release()
163
- _, buf = cv2.imencode(".jpg", frame)
 
 
 
 
164
  return Response(content=buf.tobytes(), media_type="image/jpeg")
165
 
166
 
@@ -171,6 +222,8 @@ def constants():
171
 
172
  @app.post("/reports/{video_id}")
173
  def generate_reports(video_id: str):
 
 
174
  data = run_results.get(video_id)
175
  if not data:
176
  return {"error": "no results", "files": []}
@@ -188,6 +241,8 @@ def generate_reports(video_id: str):
188
 
189
  @app.get("/reports/{video_id}/{name}")
190
  def get_report(video_id: str, name: str):
 
 
191
  safe_name = Path(name).name
192
  path = REPORT_DIR / video_id / safe_name
193
  if not path.resolve().is_relative_to(REPORT_DIR.resolve()):
@@ -210,6 +265,8 @@ def get_report(video_id: str, name: str):
210
  @app.get("/bundle/{video_id}")
211
  def download_all_reports(video_id: str):
212
  print(f"[BACKEND] ZIP request for {video_id}")
 
 
213
  base_path = REPORT_DIR / video_id
214
  if not base_path.exists():
215
  print(f"[BACKEND] Error: {base_path} not found")
@@ -241,7 +298,7 @@ def download_all_reports(video_id: str):
241
  except Exception as e:
242
  import traceback
243
  print(f"[BACKEND] ZIP Error: {str(e)}\n{traceback.format_exc()}")
244
- return JSONResponse({"error": str(e)}, status_code=500)
245
 
246
 
247
  FEEDBACK_PATH = Path(tempfile.gettempdir()) / "urbanflow_feedback.json"
@@ -251,12 +308,18 @@ def send_feedback_email(api_key, feedback):
251
  try:
252
  resend.api_key = api_key
253
 
254
- fb_type = feedback.get('type') or 'General'
 
 
 
 
 
255
  rating = feedback.get('rating', 0)
256
- details = feedback.get('details') or ""
257
- usecase = feedback.get('usecase') or "Not specified"
258
- emojis = feedback.get('emojis', {})
259
- priorities = feedback.get('priorities', [])
 
260
 
261
  # Check if it's stars-only (no emojis, no priorities, no text)
262
  has_emojis = any(v for v in emojis.values())
@@ -314,7 +377,7 @@ def send_feedback_email(api_key, feedback):
314
  """
315
 
316
  # Header with Rating
317
- user_email = feedback.get('user_email', '')
318
  user_email_html = ''
319
  if user_email:
320
  user_email_html = f"""
@@ -352,7 +415,7 @@ def send_feedback_email(api_key, feedback):
352
  resend.Emails.send({
353
  "from": "UrbanFlow <onboarding@resend.dev>",
354
  "to": "support.urbanflow365@gmail.com",
355
- "subject": f"Feedback: {fb_type} - {rating}/5 Stars" + (f" [{feedback.get('user_email', '')}]" if feedback.get('user_email') else ""),
356
  "html": html_body
357
  })
358
  print(f"[BACKEND] Feedback email successfully transmitted via Resend.")
@@ -394,8 +457,8 @@ async def auth_onboard(request_data: dict):
394
 
395
  @app.post("/api/event")
396
  async def track_event(request_data: dict):
397
- event = request_data.get("event", "UNKNOWN")
398
- meta = request_data.get("meta", {})
399
  print(f"[ANALYTICS] EVENT: {event} | {meta}")
400
  return {"status": "ok"}
401
 
@@ -403,9 +466,16 @@ async def track_event(request_data: dict):
403
  # =========== Feedback ===========
404
 
405
  @app.post("/api/feedback")
406
- async def submit_feedback(background_tasks: BackgroundTasks, request_data: dict = None):
407
  from datetime import datetime, timezone
 
 
 
 
408
  feedback = request_data or {}
 
 
 
409
  feedback["timestamp"] = datetime.now(timezone.utc).isoformat()
410
 
411
  def write_feedback(fb):
@@ -434,18 +504,27 @@ async def submit_feedback(background_tasks: BackgroundTasks, request_data: dict
434
  @app.websocket("/ws/run")
435
  async def ws_run(ws: WebSocket):
436
  await ws.accept()
437
- data = json.loads(await ws.receive_text())
 
 
 
 
 
 
 
 
 
 
 
 
438
 
439
- video_id = data["video_id"]
440
- line = data["line"]
441
- cfg = data["config"]
442
  save_annotated = data.get("annotated_video", False)
443
  annotated_options = data.get("annotated_options", {"bbox": True, "track_id": True, "spatial": True})
444
- report_format = data.get("report_format", "png")
445
 
446
- path = videos.get(video_id)
447
  if not path:
448
- await ws.send_text(json.dumps({"error": f"Unknown video_id: {video_id}"}))
449
  await ws.close()
450
  return
451
 
@@ -502,7 +581,8 @@ async def ws_run(ws: WebSocket):
502
  err_msg = traceback.format_exc()
503
  print(f"[BACKEND] Engine error for {video_id}:\n{err_msg}")
504
  try:
505
- await ws.send_text(json.dumps({"error": str(e), "detail": err_msg}))
 
506
  await ws.close()
507
  except Exception:
508
  pass
 
1
  import os
2
+ import re
3
+ import html
4
  import json
5
  import uuid
6
  import asyncio
7
  import tempfile
8
  import shutil
9
  from pathlib import Path
 
10
 
11
  import cv2
12
  import time
 
48
  model = None
49
 
50
  MAX_MEMORY_ENTRIES = 50
51
+ # Videos are capped far lower than result dicts: each one is up to 500MB of
52
+ # ephemeral disk. The previous code deleted *every* prior upload on each new
53
+ # one, which bounded disk but destroyed any concurrent user's in-flight video.
54
+ MAX_VIDEOS = 5
55
+
56
  def evict_old(d):
57
  while len(d) > MAX_MEMORY_ENTRIES:
58
  d.pop(next(iter(d)))
59
 
60
+
61
+ def evict_videos():
62
+ """Evict oldest uploads and remove their temp files so disk never grows unbounded."""
63
+ while len(videos) > MAX_VIDEOS:
64
+ vid = next(iter(videos))
65
+ stale = videos.pop(vid)
66
+ video_info.pop(vid, None)
67
+ try:
68
+ os.unlink(stale)
69
+ except OSError:
70
+ pass
71
+ # Its rendered artifacts (charts + annotated mp4 + zip) go too.
72
+ shutil.rmtree(REPORT_DIR / vid, ignore_errors=True)
73
+ try:
74
+ (REPORT_DIR / f"bundle_{vid}.zip").unlink(missing_ok=True)
75
+ except OSError:
76
+ pass
77
+
78
+
79
+ # video_id is generated server-side as an 8-char uuid slice — anything else is
80
+ # a crafted path and must never reach a filesystem join.
81
+ _ID_RE = re.compile(r"^[a-f0-9]{8}$")
82
+
83
+ def valid_id(video_id: str) -> bool:
84
+ return bool(_ID_RE.match(video_id))
85
+
86
+
87
+ RATE_LIMITS = {}
88
+ RATE_WINDOW = 60
89
+ RATE_MAX = 5
90
+
91
+ def is_rate_limited(ip: str, bucket: str = "upload"):
92
  now = time.time()
93
+ # Drop fully-expired buckets so the dict cannot grow without bound.
94
+ for key in [k for k, v in RATE_LIMITS.items() if not v or now - v[-1] > RATE_WINDOW]:
95
+ RATE_LIMITS.pop(key, None)
96
+ key = f"{bucket}:{ip}"
97
+ stamps = [t for t in RATE_LIMITS.get(key, []) if now - t < RATE_WINDOW]
98
+ if len(stamps) >= RATE_MAX:
99
+ RATE_LIMITS[key] = stamps
100
  return True
101
  stamps.append(now)
102
+ RATE_LIMITS[key] = stamps
103
  return False
104
 
105
  MAX_UPLOAD_BYTES = 500 * 1024 * 1024
 
149
  if is_rate_limited(client_ip):
150
  return JSONResponse({"error": "Rate limit exceeded. Please wait a minute."}, status_code=429)
151
 
152
+ if not (file.content_type or "").startswith("video/"):
153
  return JSONResponse({"error": "Invalid file type. Only videos are allowed."}, status_code=400)
154
 
155
+ video_id = uuid.uuid4().hex[:8]
 
 
 
156
  path = UPLOAD_DIR / f"{video_id}.mp4"
157
 
 
 
 
 
 
 
 
158
  print(f"[BACKEND] Received upload request: {file.filename}")
159
  try:
160
+ # Stream to disk with a running cap — never buffer an unbounded body first.
161
+ written = 0
162
  with open(path, "wb") as f:
163
+ while True:
164
+ chunk = await file.read(1024 * 1024)
165
+ if not chunk:
166
+ break
167
+ written += len(chunk)
168
+ if written > MAX_UPLOAD_BYTES:
169
+ f.close()
170
+ path.unlink(missing_ok=True)
171
+ return JSONResponse({"error": "File too large. Maximum size is 500MB."}, status_code=413)
172
+ f.write(chunk)
173
+
174
+ print(f"[BACKEND] Successfully stored: {path} ({written} bytes)")
175
 
176
  videos[video_id] = str(path)
177
  video_info[video_id] = file.filename
178
+ evict_videos()
 
179
  return {"video_id": video_id}
180
  except Exception as e:
181
+ path.unlink(missing_ok=True)
182
  print(f"[BACKEND] Upload failed: {str(e)}")
183
+ return JSONResponse({"error": "Upload failed."}, status_code=500)
184
+
185
+
186
+ def get_video_path(video_id: str):
187
+ """Resolve an uploaded video, or None if the id is unknown/expired."""
188
+ if not valid_id(video_id):
189
+ return None
190
+ path = videos.get(video_id)
191
+ return path if path and os.path.exists(path) else None
192
 
193
 
194
  @app.get("/config/{video_id}")
195
  def config_endpoint(video_id: str):
196
+ path = get_video_path(video_id)
197
+ if not path:
198
+ return JSONResponse({"error": "Unknown or expired video_id"}, status_code=404)
199
+ return get_optimal_config(path)
200
 
201
 
202
  @app.get("/first-frame/{video_id}")
203
  def first_frame(video_id: str):
204
+ path = get_video_path(video_id)
205
+ if not path:
206
+ return JSONResponse({"error": "Unknown or expired video_id"}, status_code=404)
207
  cap = cv2.VideoCapture(path)
208
  ret, frame = cap.read()
209
  cap.release()
210
+ if not ret:
211
+ return JSONResponse({"error": "Could not decode the first frame of this video"}, status_code=422)
212
+ ok, buf = cv2.imencode(".jpg", frame)
213
+ if not ok:
214
+ return JSONResponse({"error": "Frame encoding failed"}, status_code=500)
215
  return Response(content=buf.tobytes(), media_type="image/jpeg")
216
 
217
 
 
222
 
223
  @app.post("/reports/{video_id}")
224
  def generate_reports(video_id: str):
225
+ if not valid_id(video_id):
226
+ return JSONResponse({"error": "Invalid video_id", "files": []}, status_code=400)
227
  data = run_results.get(video_id)
228
  if not data:
229
  return {"error": "no results", "files": []}
 
241
 
242
  @app.get("/reports/{video_id}/{name}")
243
  def get_report(video_id: str, name: str):
244
+ if not valid_id(video_id):
245
+ return JSONResponse({"error": "Invalid video_id"}, status_code=400)
246
  safe_name = Path(name).name
247
  path = REPORT_DIR / video_id / safe_name
248
  if not path.resolve().is_relative_to(REPORT_DIR.resolve()):
 
265
  @app.get("/bundle/{video_id}")
266
  def download_all_reports(video_id: str):
267
  print(f"[BACKEND] ZIP request for {video_id}")
268
+ if not valid_id(video_id):
269
+ return JSONResponse({"error": "Invalid video_id"}, status_code=400)
270
  base_path = REPORT_DIR / video_id
271
  if not base_path.exists():
272
  print(f"[BACKEND] Error: {base_path} not found")
 
298
  except Exception as e:
299
  import traceback
300
  print(f"[BACKEND] ZIP Error: {str(e)}\n{traceback.format_exc()}")
301
+ return JSONResponse({"error": "Could not build the download bundle."}, status_code=500)
302
 
303
 
304
  FEEDBACK_PATH = Path(tempfile.gettempdir()) / "urbanflow_feedback.json"
 
308
  try:
309
  resend.api_key = api_key
310
 
311
+ # Everything below is interpolated into an HTML email — escape at the
312
+ # boundary so a "<script>" in free-text feedback stays inert.
313
+ def esc(v):
314
+ return html.escape(str(v), quote=True)
315
+
316
+ fb_type = esc(feedback.get('type') or 'General')
317
  rating = feedback.get('rating', 0)
318
+ rating = rating if isinstance(rating, int) and 0 <= rating <= 5 else 0
319
+ details = esc(feedback.get('details') or "")
320
+ usecase = esc(feedback.get('usecase') or "Not specified")
321
+ emojis = feedback.get('emojis', {}) or {}
322
+ priorities = [esc(p) for p in (feedback.get('priorities') or [])]
323
 
324
  # Check if it's stars-only (no emojis, no priorities, no text)
325
  has_emojis = any(v for v in emojis.values())
 
377
  """
378
 
379
  # Header with Rating
380
+ user_email = esc(feedback.get('user_email', '') or '')
381
  user_email_html = ''
382
  if user_email:
383
  user_email_html = f"""
 
415
  resend.Emails.send({
416
  "from": "UrbanFlow <onboarding@resend.dev>",
417
  "to": "support.urbanflow365@gmail.com",
418
+ "subject": f"Feedback: {fb_type} - {rating}/5 Stars" + (f" [{user_email}]" if user_email else ""),
419
  "html": html_body
420
  })
421
  print(f"[BACKEND] Feedback email successfully transmitted via Resend.")
 
457
 
458
  @app.post("/api/event")
459
  async def track_event(request_data: dict):
460
+ event = str(request_data.get("event", "UNKNOWN"))[:64]
461
+ meta = str(request_data.get("meta", {}))[:256]
462
  print(f"[ANALYTICS] EVENT: {event} | {meta}")
463
  return {"status": "ok"}
464
 
 
466
  # =========== Feedback ===========
467
 
468
  @app.post("/api/feedback")
469
+ async def submit_feedback(request: Request, background_tasks: BackgroundTasks, request_data: dict = None):
470
  from datetime import datetime, timezone
471
+ client_ip = request.client.host if request.client else "unknown"
472
+ if is_rate_limited(client_ip, "feedback"):
473
+ return JSONResponse({"error": "Too many submissions. Please wait a minute."}, status_code=429)
474
+
475
  feedback = request_data or {}
476
+ # Free-text is stored and emailed — bound it so one request can't fill /tmp.
477
+ if isinstance(feedback.get("details"), str):
478
+ feedback["details"] = feedback["details"][:5000]
479
  feedback["timestamp"] = datetime.now(timezone.utc).isoformat()
480
 
481
  def write_feedback(fb):
 
504
  @app.websocket("/ws/run")
505
  async def ws_run(ws: WebSocket):
506
  await ws.accept()
507
+ try:
508
+ data = json.loads(await ws.receive_text())
509
+ video_id = data["video_id"]
510
+ line = data["line"]
511
+ cfg = dict(data["config"])
512
+ # The client owns these sliders, so clamp before they reach the engine.
513
+ cfg["detect_stride"] = min(10, max(1, int(cfg.get("detect_stride", 2) or 1)))
514
+ cfg["conf"] = min(0.95, max(0.01, float(cfg.get("conf", 0.12))))
515
+ cfg["iou"] = min(0.95, max(0.1, float(cfg.get("iou", 0.6))))
516
+ except Exception:
517
+ await ws.send_text(json.dumps({"error": "Malformed run request"}))
518
+ await ws.close()
519
+ return
520
 
 
 
 
521
  save_annotated = data.get("annotated_video", False)
522
  annotated_options = data.get("annotated_options", {"bbox": True, "track_id": True, "spatial": True})
523
+ report_format = "pdf" if data.get("report_format") == "pdf" else "png"
524
 
525
+ path = get_video_path(video_id)
526
  if not path:
527
+ await ws.send_text(json.dumps({"error": "Unknown or expired video_id"}))
528
  await ws.close()
529
  return
530
 
 
581
  err_msg = traceback.format_exc()
582
  print(f"[BACKEND] Engine error for {video_id}:\n{err_msg}")
583
  try:
584
+ # Stack traces stay in the server log, never on the wire.
585
+ await ws.send_text(json.dumps({"error": "Processing failed. Please try again."}))
586
  await ws.close()
587
  except Exception:
588
  pass
backend/test_core.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Smallest check that fails if the counting / PCU / id-validation logic breaks.
3
+
4
+ python backend/test_core.py
5
+
6
+ No framework on purpose — these are the branches that silently produce wrong
7
+ numbers rather than crashing, so they need a tripwire.
8
+ """
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ sys.path.insert(0, str(Path(__file__).parent))
13
+
14
+ from geometry import _side, _point_to_segment_dist
15
+ from pcu import compute_pcu, get_pcu_factor
16
+ from speed import estimate_speeds
17
+ from config import IMGSZ
18
+
19
+
20
+ def test_side():
21
+ a, b = [0, 0], [10, 0] # horizontal line
22
+ assert _side((5, 5), a, b) > 0, "above the line must be positive"
23
+ assert _side((5, -5), a, b) < 0, "below the line must be negative"
24
+ assert _side((5, 0), a, b) == 0, "on the line must be zero (skipped by engine)"
25
+
26
+
27
+ def test_point_to_segment():
28
+ assert abs(_point_to_segment_dist(5, 3, 0, 0, 10, 0) - 3.0) < 1e-9
29
+ # Past the end of the segment: clamps to the endpoint, not the infinite line.
30
+ assert abs(_point_to_segment_dist(20, 0, 0, 0, 10, 0) - 10.0) < 1e-9
31
+ # Degenerate segment (both counting-line points identical) must not divide by zero.
32
+ assert abs(_point_to_segment_dist(4, 5, 1, 1, 1, 1) - 5.0) < 1e-9 # 3-4-5
33
+
34
+
35
+ def test_pcu():
36
+ assert get_pcu_factor(4) == 3.0 # Bus
37
+ assert get_pcu_factor(7) == 0.5 # Two-wheeler
38
+ assert get_pcu_factor(999) == 1.0 # unknown class falls back to 1
39
+
40
+ # 2 buses in + 4 two-wheelers out = 2*3.0 + 4*0.5 = 8.0
41
+ out = compute_pcu({"4": 2}, {"7": 4})
42
+ assert out["pcu_in"] == 6.0, out
43
+ assert out["pcu_out"] == 2.0, out
44
+ assert out["total_pcu"] == 8.0, out
45
+ assert out["per_class"]["Bus"]["count"] == 2, out
46
+
47
+ empty = compute_pcu({}, {})
48
+ assert empty["total_pcu"] == 0.0 and empty["per_class"] == {}
49
+
50
+
51
+ def test_speeds():
52
+ # One track moving 10px/frame, one barely moving, one too short to score.
53
+ tracks = {
54
+ 1: [(0, 0, 0), (1, 10, 0), (2, 20, 0)],
55
+ 2: [(0, 0, 0), (1, 1, 0), (2, 2, 0)],
56
+ 3: [(0, 5, 5)],
57
+ }
58
+ res = estimate_speeds(tracks)
59
+ assert 3 not in res["per_track"], "single-sample tracks have no speed"
60
+ assert res["per_track"][1]["px_per_frame"] == 10.0
61
+ assert res["per_track"][1]["category"] == "fast"
62
+ assert res["per_track"][2]["category"] == "slow"
63
+ assert sum(res["distribution"].values()) == 100.0
64
+
65
+ blank = estimate_speeds({})
66
+ assert blank["distribution"] == {"slow": 0, "normal": 0, "fast": 0}
67
+
68
+
69
+ def test_id_validation():
70
+ # Imported lazily: server.py pulls in torch/ultralytics, which is slow and
71
+ # unavailable outside the container. Re-check the regex contract instead.
72
+ import re
73
+ id_re = re.compile(r"^[a-f0-9]{8}$")
74
+ assert id_re.match("a1b2c3d4")
75
+ assert not id_re.match("../../etc"), "traversal must not validate"
76
+ assert not id_re.match("A1B2C3D4"), "uuid4().hex is lowercase"
77
+ assert not id_re.match("a1b2c3d"), "wrong length must not validate"
78
+
79
+
80
+ def test_imgsz_matches_engine():
81
+ # engine.py hardcodes imgsz to match the compiled OpenVINO graph; if these
82
+ # two ever drift, inference silently runs at the wrong input size.
83
+ engine_src = (Path(__file__).parent / "engine.py").read_text(encoding="utf-8")
84
+ assert f"imgsz={IMGSZ}," in engine_src, f"engine.py must call track(imgsz={IMGSZ})"
85
+
86
+
87
+ if __name__ == "__main__":
88
+ for name, fn in sorted(globals().items()):
89
+ if name.startswith("test_") and callable(fn):
90
+ fn()
91
+ print(f" ok {name}")
92
+ print("all core checks passed")
frontend/css/initial.css CHANGED
@@ -271,11 +271,6 @@ body::-webkit-scrollbar {
271
  padding-bottom: 0 !important;
272
  }
273
 
274
- main > div:first-child ul {
275
- display: inline-block;
276
- text-align: left;
277
- }
278
-
279
  /* Step card — full width */
280
  main > div:last-child {
281
  max-width: 100% !important;
@@ -342,32 +337,6 @@ body::-webkit-scrollbar {
342
  justify-content: center;
343
  }
344
 
345
- /* Feature bullets */
346
- .hero-text-section ul {
347
- display: flex !important;
348
- flex-direction: column !important;
349
- gap: 4px !important;
350
- width: 100%;
351
- text-align: left;
352
- }
353
-
354
- .hero-text-section ul li {
355
- display: flex !important;
356
- align-items: flex-start !important;
357
- font-size: 14px !important;
358
- font-weight: 500 !important;
359
- line-height: 1.6 !important;
360
- padding: 5px 0;
361
- color: #a89f97 !important;
362
- }
363
-
364
- .hero-text-section ul li i {
365
- font-size: 14px !important;
366
- margin-right: 10px !important;
367
- margin-top: 2px;
368
- flex-shrink: 0;
369
- }
370
-
371
  /* ---- Step card: full width, right below bullets ---- */
372
  .step-card-section {
373
  max-width: 100% !important;
 
271
  padding-bottom: 0 !important;
272
  }
273
 
 
 
 
 
 
274
  /* Step card — full width */
275
  main > div:last-child {
276
  max-width: 100% !important;
 
337
  justify-content: center;
338
  }
339
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
340
  /* ---- Step card: full width, right below bullets ---- */
341
  .step-card-section {
342
  max-width: 100% !important;
frontend/css/shared.css CHANGED
@@ -5,13 +5,8 @@
5
  ============================================= */
6
 
7
  /* ---- Typography Colors ---- */
8
- .text-accent { color: #c89a6c !important; }
9
- .text-primary { color: #f0ece6 !important; }
10
- .text-secondary { color: #a89f97 !important; }
11
- .text-muted { color: #555 !important; }
12
- .text-dim { color: #444 !important; }
13
- .text-cocoa { color: #8b5e3c !important; }
14
- .bg-accent { background-color: #c89a6c !important; }
15
 
16
  /* ---- Modal Overlay ---- */
17
  .modal-overlay {
@@ -72,114 +67,6 @@
72
  margin-bottom: 20px;
73
  }
74
 
75
- /* ---- Modal List (legal content) ---- */
76
- .modal-list {
77
- color: #a89f97;
78
- font-size: 11px;
79
- line-height: 1.9;
80
- padding-left: 16px;
81
- list-style: disc;
82
- text-align: left;
83
- }
84
- .modal-list strong {
85
- color: #f0ece6;
86
- }
87
- .modal-list .hl-accent {
88
- color: #c89a6c;
89
- font-weight: 700;
90
- }
91
-
92
- /* ---- Legal Section Label ---- */
93
- .legal-section-label {
94
- color: #c89a6c;
95
- font-size: 11px;
96
- font-weight: 700;
97
- margin-bottom: 6px;
98
- text-align: left;
99
- }
100
-
101
- /* ---- Legal Footer ---- */
102
- .legal-footer-text {
103
- color: #555;
104
- font-size: 10px;
105
- margin-top: 20px;
106
- text-align: left;
107
- }
108
-
109
- /* ---- Legal Button (footer/sidebar links) ---- */
110
- .legal-btn {
111
- font-size: 10px;
112
- font-weight: 700;
113
- text-transform: uppercase;
114
- letter-spacing: 0.2em;
115
- color: #a89f97;
116
- background: none;
117
- border: none;
118
- cursor: pointer;
119
- transition: color 0.2s;
120
- }
121
- .legal-btn:hover {
122
- color: #c89a6c;
123
- }
124
-
125
- /* ---- Section Label ---- */
126
- .section-label {
127
- font-size: 10px;
128
- font-weight: 800;
129
- text-transform: uppercase;
130
- letter-spacing: 0.2em;
131
- color: #a89f97;
132
- }
133
-
134
- /* ---- Check List (hero feature bullets) ---- */
135
- .check-list {
136
- list-style: none;
137
- padding: 0;
138
- margin: 0;
139
- }
140
- .check-list li {
141
- display: flex;
142
- align-items: center;
143
- color: #a89f97;
144
- }
145
- .check-list li::before {
146
- content: '\f00c'; /* fa-check */
147
- font-family: 'Font Awesome 6 Free';
148
- font-weight: 900;
149
- color: #c89a6c;
150
- font-size: 1.1rem;
151
- margin-right: 12px;
152
- flex-shrink: 0;
153
- width: 1.5rem;
154
- text-align: center;
155
- }
156
- @media (min-width: 768px) {
157
- .check-list li::before {
158
- margin-right: 20px;
159
- }
160
- }
161
-
162
- /* ---- Bullet List (about tab) ---- */
163
- .bullet-list {
164
- list-style: none;
165
- padding: 0;
166
- margin: 0;
167
- }
168
- .bullet-list li {
169
- display: flex;
170
- align-items: flex-start;
171
- gap: 12px;
172
- }
173
- .bullet-list li::before {
174
- content: '\f111'; /* fa-circle */
175
- font-family: 'Font Awesome 6 Free';
176
- font-weight: 900;
177
- color: #c89a6c;
178
- font-size: 5px;
179
- margin-top: 7px;
180
- flex-shrink: 0;
181
- }
182
-
183
  /* ---- Keyboard Shortcut Row ---- */
184
  .shortcut-row {
185
  display: flex;
@@ -207,75 +94,5 @@
207
  font-family: 'JetBrains Mono', monospace;
208
  }
209
 
210
- /* ---- Panel Card (run tab panels) ---- */
211
- .panel-card {
212
- background-color: #0a0a0a;
213
- border-radius: 12px;
214
- border: 1px solid #2a2a2a;
215
- overflow: hidden;
216
- display: flex;
217
- flex-direction: column;
218
- }
219
- .panel-header {
220
- padding: 16px 24px;
221
- border-bottom: 1px solid #1a1a1a;
222
- background: #050505;
223
- }
224
- .panel-header h3 {
225
- font-weight: 700;
226
- font-size: 0.875rem;
227
- color: #f0ece6;
228
- }
229
- .panel-body {
230
- padding: 24px;
231
- }
232
-
233
- /* ---- Step Titles (initial.html steps) ---- */
234
- .step-title {
235
- font-size: 1.875rem;
236
- font-weight: 700;
237
- margin-bottom: 0.5rem;
238
- text-align: center;
239
- color: #f0ece6;
240
- }
241
- .step-subtitle {
242
- font-size: 13px;
243
- font-weight: 500;
244
- margin-bottom: 2rem;
245
- text-align: center;
246
- color: #a89f97;
247
- }
248
-
249
- /* ---- Mobile Menu Item ---- */
250
- .mob-menu-item {
251
- width: 100%;
252
- text-align: left;
253
- padding: 10px 16px;
254
- font-size: 10px;
255
- font-weight: 700;
256
- text-transform: uppercase;
257
- letter-spacing: 0.2em;
258
- color: #a89f97;
259
- background: none;
260
- border: none;
261
- border-bottom: 1px solid #1a1a1a;
262
- cursor: pointer;
263
- transition: color 0.15s, background 0.15s;
264
- }
265
- .mob-menu-item:hover {
266
- color: #f0ece6;
267
- background: #111;
268
- }
269
-
270
- /* ---- Copyright Text ---- */
271
- .copyright-text {
272
- font-size: 11px;
273
- font-weight: 500;
274
- color: #555;
275
- }
276
-
277
  /* ---- Utility ---- */
278
  .hidden { display: none !important; }
279
- .border-subtle { border-color: #2a2a2a; }
280
- .border-dim { border-color: #1a1a1a; }
281
- .bg-surface { background-color: #050505; }
 
5
  ============================================= */
6
 
7
  /* ---- Typography Colors ---- */
8
+ .text-accent { color: #c89a6c !important; }
9
+ .text-muted { color: #555 !important; }
 
 
 
 
 
10
 
11
  /* ---- Modal Overlay ---- */
12
  .modal-overlay {
 
67
  margin-bottom: 20px;
68
  }
69
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  /* ---- Keyboard Shortcut Row ---- */
71
  .shortcut-row {
72
  display: flex;
 
94
  font-family: 'JetBrains Mono', monospace;
95
  }
96
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  /* ---- Utility ---- */
98
  .hidden { display: none !important; }
 
 
 
frontend/css/vehicles.css CHANGED
@@ -1,1525 +1,1510 @@
1
- /* =============================================
2
- UrbanFlow — vehicles.css (Mobile-First)
3
- Desktop layout preserved exactly.
4
- Mobile: bottom nav, touch targets, stacked cards.
5
- ============================================= */
6
-
7
- :root {
8
- --cocoa: #8b5e3c;
9
- --cocoa-l: #c89a6c;
10
- --cocoa-xl: #d4b08a;
11
- --mob-nav-h: 68px;
12
- /* bottom nav height on mobile */
13
- }
14
-
15
- *,
16
- *::before,
17
- *::after {
18
- box-sizing: border-box;
19
- }
20
-
21
- .hidden {
22
- display: none !important;
23
- }
24
-
25
- html {
26
- overflow: hidden;
27
- height: 100%;
28
- }
29
-
30
- body {
31
- font-family: 'Montserrat', sans-serif;
32
- background-color: #000000;
33
- color: #f0ece6;
34
- -webkit-tap-highlight-color: transparent;
35
- overscroll-behavior: none;
36
- }
37
-
38
- .mono-font {
39
- font-family: 'JetBrains Mono', monospace;
40
- }
41
-
42
- /* ---- Scrollbar: hide globally on mobile, keep #class-breakdown visible ---- */
43
- @media (max-width: 1023px) {
44
- * {
45
- scrollbar-width: none;
46
- -ms-overflow-style: none;
47
- }
48
-
49
- *::-webkit-scrollbar {
50
- display: none;
51
- }
52
-
53
- /* Vehicle Classification section keeps its scrollbar on mobile */
54
- #class-breakdown {
55
- scrollbar-width: thin !important;
56
- -ms-overflow-style: auto !important;
57
- }
58
-
59
- #class-breakdown::-webkit-scrollbar {
60
- display: block !important;
61
- width: 4px !important;
62
- }
63
-
64
- #class-breakdown::-webkit-scrollbar-track {
65
- background: #000000 !important;
66
- }
67
-
68
- #class-breakdown::-webkit-scrollbar-thumb {
69
- background: #222222 !important;
70
- border-radius: 4px !important;
71
- }
72
-
73
- #class-breakdown::-webkit-scrollbar-thumb:hover {
74
- background: #333333 !important;
75
- }
76
- }
77
-
78
- /* ---- Notification Glow ---- */
79
- @keyframes glow-green {
80
- 0% {
81
- color: #f0ece6;
82
- filter: drop-shadow(0 0 0px #4ade80);
83
- }
84
-
85
- 50% {
86
- color: #4ade80;
87
- filter: drop-shadow(0 0 8px #4ade80);
88
- }
89
-
90
- 100% {
91
- color: #f0ece6;
92
- filter: drop-shadow(0 0 0px #4ade80);
93
- }
94
- }
95
-
96
- .notify-glow i {
97
- animation: glow-green 1.5s infinite ease-in-out !important;
98
- }
99
-
100
- /* ---- Info tooltip ---- */
101
- .info-wrap {
102
- position: relative;
103
- display: inline-flex;
104
- align-items: center;
105
- margin-left: 6px;
106
- }
107
-
108
- .info-btn {
109
- display: inline-flex;
110
- align-items: center;
111
- justify-content: center;
112
- width: 18px;
113
- /* slightly larger for touch */
114
- height: 18px;
115
- border-radius: 50%;
116
- background: #444444 !important;
117
- color: #ffffff !important;
118
- font-size: 8px;
119
- cursor: pointer;
120
- transition: all 0.2s ease;
121
- }
122
-
123
- .info-btn:hover,
124
- .info-btn:active {
125
- background: #666666 !important;
126
- }
127
-
128
- .info-tip {
129
- display: none;
130
- position: fixed;
131
- z-index: 9999;
132
- background: #0a0a0a;
133
- color: #aaaaaa;
134
- font-size: 10px;
135
- font-weight: 500;
136
- line-height: 1.4;
137
- padding: 8px 12px;
138
- border-radius: 6px;
139
- max-width: 240px;
140
- box-shadow: 0 10px 30px rgba(0, 0, 0, 0.8);
141
- border: 1px solid #222222;
142
- pointer-events: none;
143
- text-transform: none;
144
- letter-spacing: normal;
145
- }
146
-
147
- /* ---- Mobile Top Bar ---- */
148
- .mobile-top-bar {
149
- display: none;
150
- }
151
-
152
- @media (max-width: 1023px) {
153
- .mobile-top-bar {
154
- display: flex;
155
- align-items: center;
156
- justify-content: center;
157
- position: fixed;
158
- top: 0;
159
- left: 0;
160
- right: 0;
161
- height: 58px;
162
- background: #000000;
163
- border-bottom: 1px solid #1a1a1a;
164
- z-index: 35;
165
- flex-shrink: 0;
166
- }
167
-
168
- #legal-menu {
169
- animation: menuFadeIn 0.2s ease-out forwards;
170
- transform-origin: top right;
171
- }
172
-
173
- @keyframes menuFadeIn {
174
- from {
175
- opacity: 0;
176
- transform: translateY(-10px) scale(0.95);
177
- }
178
-
179
- to {
180
- opacity: 1;
181
- transform: translateY(0) scale(1);
182
- }
183
- }
184
- }
185
-
186
- /* ---- Sidebar nav states ---- */
187
- .nav-item-active {
188
- background-color: #111111 !important;
189
- color: var(--cocoa-xl) !important;
190
- border-left: 2px solid var(--cocoa-l) !important;
191
- }
192
-
193
- .nav-item-inactive {
194
- color: #555555 !important;
195
- }
196
-
197
- .nav-item-inactive:hover {
198
- color: #f0ece6 !important;
199
- background-color: #050505 !important;
200
- }
201
-
202
- /* ---- Card overrides ---- */
203
- .bg-white {
204
- background-color: #0a0a0a !important;
205
- }
206
-
207
- .border-slate-200,
208
- .border-slate-100,
209
- .border-slate-50,
210
- .border-neutral-800,
211
- .border-neutral-900 {
212
- border-color: #2a2a2a !important;
213
- }
214
-
215
- .bg-slate-50\/50,
216
- .bg-slate-50,
217
- .bg-slate-900,
218
- .bg-neutral-900 {
219
- background-color: #0c0c0c !important;
220
- }
221
-
222
- .text-slate-900,
223
- .text-slate-800,
224
- .text-slate-700,
225
- .text-neutral-900 {
226
- color: #ffffff !important;
227
- }
228
-
229
- .text-slate-600,
230
- .text-slate-500,
231
- .text-slate-400,
232
- .text-neutral-500,
233
- .text-neutral-400 {
234
- color: #888888 !important;
235
- }
236
-
237
- .shadow-sm {
238
- box-shadow: none !important;
239
- }
240
-
241
- /* ---- Toggle control ---- */
242
- .toggle-track {
243
- width: 36px;
244
- /* slightly wider for touch */
245
- height: 20px;
246
- border-radius: 999px;
247
- background: #1a1a1a;
248
- border: 1px solid #333;
249
- position: relative;
250
- cursor: pointer;
251
- flex-shrink: 0;
252
- transition: background 0.2s ease;
253
- }
254
-
255
- .toggle-track.active {
256
- background: #c89a6c !important;
257
- border-color: #c89a6c !important;
258
- }
259
-
260
- .toggle-thumb {
261
- width: 16px;
262
- height: 16px;
263
- border-radius: 50%;
264
- background: #555555;
265
- position: absolute;
266
- top: 2px;
267
- left: 2px;
268
- transition: all 0.2s ease;
269
- }
270
-
271
- .toggle-track.active .toggle-thumb {
272
- transform: translateX(16px);
273
- background: #ffffff;
274
- /* pure white for contrast on gold track */
275
- }
276
-
277
- /* ---- Custom select ---- */
278
- .custom-select {
279
- appearance: none;
280
- background-color: #111111;
281
- border: 1px solid #222222;
282
- border-radius: 6px;
283
- padding: 4px 24px 4px 10px;
284
- font-size: 11px;
285
- font-weight: 600;
286
- color: #ffffff;
287
- outline: none;
288
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%23666666'%3E%3Cpath fill-rule='evenodd' d='M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z'/%3E%3C/svg%3E");
289
- background-repeat: no-repeat;
290
- background-position: right 8px center;
291
- background-size: 12px;
292
- }
293
-
294
- /* ---- Stepper ---- */
295
- .s-stepper {
296
- display: inline-flex;
297
- border: 1px solid #222222;
298
- border-radius: 6px;
299
- background: #111111;
300
- overflow: hidden;
301
- flex-shrink: 0;
302
- }
303
-
304
- .s-stepper button {
305
- padding: 8px 12px;
306
- /* larger touch target than original 4px 8px */
307
- color: #666666;
308
- font-size: 14px;
309
- min-width: 36px;
310
- min-height: 36px;
311
- display: flex;
312
- align-items: center;
313
- justify-content: center;
314
- }
315
-
316
- .s-stepper button:hover,
317
- .s-stepper button:active {
318
- background: #1a1a1a;
319
- color: #ffffff;
320
- }
321
-
322
- .s-stepper .s-val {
323
- min-width: 44px;
324
- text-align: center;
325
- font-family: 'JetBrains Mono', monospace;
326
- font-size: 12px;
327
- font-weight: 700;
328
- color: #ffffff;
329
- padding: 4px 0;
330
- border-left: 1px solid #222222;
331
- border-right: 1px solid #222222;
332
- display: flex;
333
- align-items: center;
334
- justify-content: center;
335
- }
336
-
337
- /* ---- Settings row ---- */
338
- .s-row {
339
- display: flex;
340
- align-items: center;
341
- justify-content: space-between;
342
- padding: 14px 0;
343
- /* slightly more vertical padding */
344
- border-bottom: 1px solid #1a1a1a;
345
- gap: 12px;
346
- }
347
-
348
- .s-row:last-child {
349
- border-bottom: none;
350
- }
351
-
352
- .s-row>div:first-child {
353
- flex: 1;
354
- min-width: 0;
355
- }
356
-
357
- /* ---- Progress bar ---- */
358
- #proc-bar {
359
- background-color: var(--cocoa-l) !important;
360
- }
361
-
362
- #proc-label {
363
- color: #ffffff !important;
364
- }
365
-
366
- /* ---- Disabled rows ---- */
367
- .s-row.disabled {
368
- opacity: 0.65 !important;
369
- }
370
-
371
- .s-row.disabled .s-stepper,
372
- .s-row.disabled .custom-select,
373
- .s-row.disabled .toggle-track,
374
- .s-row.disabled .chip-container,
375
- .s-row.disabled .uf-select-wrap,
376
- .s-row.disabled .uf-select-trigger {
377
- pointer-events: none !important;
378
- opacity: 0.5 !important;
379
- }
380
-
381
- /* Force-collapse the dropdown panel when row is locked */
382
- .s-row.disabled .uf-select-dropdown {
383
- display: none !important;
384
- }
385
-
386
- .s-row.disabled .info-wrap {
387
- pointer-events: auto !important;
388
- opacity: 1 !important;
389
- }
390
-
391
- #btn-start-processing {
392
- font-family: 'Montserrat', sans-serif !important;
393
- }
394
-
395
- /* ---- Chips ---- */
396
- .chip-container {
397
- display: flex;
398
- flex-wrap: wrap;
399
- gap: 8px;
400
- margin-top: 12px;
401
- padding-top: 12px;
402
- border-top: 1px solid #1a1a1a;
403
- transition: all 0.3s ease;
404
- }
405
-
406
- .chip {
407
- display: inline-flex;
408
- align-items: center;
409
- gap: 6px;
410
- padding: 8px 14px;
411
- /* larger than original 6px 14px */
412
- border-radius: 9999px;
413
- font-size: 10px;
414
- font-weight: 700;
415
- cursor: pointer;
416
- transition: all 0.2s ease;
417
- user-select: none;
418
- border: 1px solid #333333;
419
- background: rgba(255, 255, 255, 0.03);
420
- color: #888888;
421
- min-height: 36px;
422
- }
423
-
424
- .chip.active {
425
- background: var(--cocoa-l);
426
- color: #000000;
427
- border-color: var(--cocoa-l);
428
- }
429
-
430
- .chip.frozen {
431
- background: rgba(255, 255, 255, 0.4);
432
- color: #000000;
433
- border-color: transparent;
434
- cursor: default !important;
435
- pointer-events: none;
436
- }
437
-
438
- .chip:hover {
439
- border-color: #666666;
440
- }
441
-
442
- .chip.active:hover {
443
- background: var(--cocoa-xl);
444
- }
445
-
446
- .chip i {
447
- font-size: 9px;
448
- }
449
-
450
- .hidden-chip-container {
451
- display: none !important;
452
- margin: 0 !important;
453
- padding: 0 !important;
454
- height: 0 !important;
455
- }
456
-
457
- /* ---- Toast ---- */
458
- #toast-container {
459
- position: fixed;
460
- bottom: calc(var(--mob-nav-h) + 12px);
461
- /* above bottom nav on mobile */
462
- left: 50%;
463
- transform: translateX(-50%);
464
- z-index: 10000;
465
- display: flex;
466
- flex-direction: column;
467
- align-items: center;
468
- gap: 8px;
469
- pointer-events: none;
470
- width: 90%;
471
- max-width: 360px;
472
- }
473
-
474
- .toast {
475
- background: #111;
476
- border: 1px solid #2a2a2a;
477
- color: #f0ece6;
478
- font-size: 11px;
479
- font-weight: 600;
480
- padding: 12px 18px;
481
- border-radius: 10px;
482
- display: flex;
483
- align-items: center;
484
- gap: 8px;
485
- pointer-events: auto;
486
- animation: toastIn 0.3s ease-out;
487
- width: 100%;
488
- }
489
-
490
- .toast.toast-out {
491
- animation: toastOut 0.3s ease-in forwards;
492
- }
493
-
494
- .toast-success {
495
- border-color: #166534;
496
- }
497
-
498
- .toast-success i {
499
- color: #22c55e;
500
- }
501
-
502
- .toast-error {
503
- border-color: #7f1d1d;
504
- }
505
-
506
- .toast-error i {
507
- color: #ef4444;
508
- }
509
-
510
- .toast-info i {
511
- color: var(--cocoa-l);
512
- }
513
-
514
- @keyframes toastIn {
515
- from {
516
- opacity: 0;
517
- transform: translateY(20px);
518
- }
519
-
520
- to {
521
- opacity: 1;
522
- transform: translateY(0);
523
- }
524
- }
525
-
526
- @keyframes toastOut {
527
- from {
528
- opacity: 1;
529
- }
530
-
531
- to {
532
- opacity: 0;
533
- transform: translateY(20px);
534
- }
535
- }
536
-
537
- /* ---- Stats empty overlay ---- */
538
- .stats-empty-overlay {
539
- position: absolute;
540
- inset: 0;
541
- z-index: 50;
542
- display: flex;
543
- flex-direction: column;
544
- align-items: center;
545
- justify-content: center;
546
- background: rgba(10, 10, 10, 0.85);
547
- backdrop-filter: blur(8px);
548
- border-radius: 12px;
549
- }
550
-
551
- /* ---- Feedback form ---- */
552
- .fb-textarea {
553
- background: #111;
554
- border: 1px solid #2a2a2a;
555
- border-radius: 8px;
556
- color: #f0ece6;
557
- font-size: 12px;
558
- padding: 12px;
559
- width: 100%;
560
- min-height: 120px;
561
- resize: vertical;
562
- font-family: 'Inter', sans-serif;
563
- }
564
-
565
- .fb-textarea:focus {
566
- outline: none;
567
- border-color: var(--cocoa-l);
568
- }
569
-
570
- .fb-select {
571
- background: #111;
572
- border: 1px solid #2a2a2a;
573
- border-radius: 8px;
574
- color: #f0ece6;
575
- font-size: 11px;
576
- padding: 10px 12px;
577
- /* taller for touch */
578
- width: 100%;
579
- font-family: 'Inter', sans-serif;
580
- min-height: 44px;
581
- }
582
-
583
- .fb-select:focus {
584
- outline: none;
585
- border-color: var(--cocoa-l);
586
- }
587
-
588
- .fb-stars {
589
- display: flex;
590
- gap: 8px;
591
- }
592
-
593
- .fb-star {
594
- font-size: 28px;
595
- /* larger for mobile tapping */
596
- color: #333;
597
- cursor: pointer;
598
- transition: color 0.15s;
599
- min-width: 36px;
600
- min-height: 36px;
601
- display: flex;
602
- align-items: center;
603
- justify-content: center;
604
- }
605
-
606
- .fb-star.active,
607
- .fb-star:hover {
608
- color: var(--cocoa-l);
609
- }
610
-
611
- .fb-chip {
612
- background: #050505;
613
- border: 1px solid #222;
614
- border-radius: 8px;
615
- color: #666;
616
- font-size: 10px;
617
- font-weight: 700;
618
- padding: 14px 12px;
619
- /* taller for touch */
620
- cursor: pointer;
621
- transition: all 0.2s ease;
622
- text-align: center;
623
- text-transform: uppercase;
624
- min-height: 44px;
625
- display: flex;
626
- align-items: center;
627
- justify-content: center;
628
- }
629
-
630
- .fb-chip:hover {
631
- border-color: #444;
632
- color: #999;
633
- }
634
-
635
- .fb-chip.active {
636
- border-color: var(--cocoa-l);
637
- background: #111;
638
- color: #fff;
639
- box-shadow: 0 0 15px rgba(200, 154, 108, 0.15);
640
- }
641
-
642
- .fb-emoji-btn {
643
- background: #111;
644
- border: 1px solid #2a2a2a;
645
- border-radius: 8px;
646
- color: #555;
647
- flex: 1;
648
- text-align: center;
649
- padding: 12px 4px;
650
- /* taller */
651
- cursor: pointer;
652
- transition: all 0.2s ease;
653
- min-height: 64px;
654
- display: flex;
655
- flex-direction: column;
656
- align-items: center;
657
- justify-content: center;
658
- }
659
-
660
- .fb-emoji-btn:hover {
661
- border-color: #444;
662
- color: #888;
663
- }
664
-
665
- .fb-emoji-btn.active {
666
- border-color: var(--cocoa-l);
667
- background: #1a1a1a;
668
- color: var(--cocoa-l);
669
- box-shadow: 0 0 15px rgba(200, 154, 108, 0.15);
670
- }
671
-
672
- /* =============================================
673
- DESKTOP (≥1024px) — original layout intact
674
- ============================================= */
675
- @media (min-width: 1024px) {
676
-
677
- /* Sidebar visible */
678
- aside.w-60 {
679
- display: flex !important;
680
- }
681
-
682
- /* Top mobile nav hidden */
683
- .mobile-nav {
684
- display: none !important;
685
- }
686
-
687
- /* Bottom mobile nav hidden */
688
- .mobile-bottom-nav {
689
- display: none !important;
690
- }
691
-
692
- /* Mainno bottom padding needed */
693
- main {
694
- padding-bottom: 1rem !important;
695
- }
696
-
697
- /* Toast — desktop position: bottom-right */
698
- #toast-container {
699
- bottom: 20px;
700
- left: unset;
701
- right: 20px;
702
- transform: none;
703
- width: auto;
704
- align-items: flex-end;
705
- }
706
-
707
- /* Settings2 column grid */
708
- #tab-settings .grid {
709
- grid-template-columns: repeat(2, 1fr) !important;
710
- }
711
-
712
- /* Run details — multi-column grids preserved */
713
- #run-results-content {
714
- grid-template-columns: repeat(3, 1fr) !important;
715
- }
716
-
717
- .grid-cols-2 {
718
- grid-template-columns: repeat(2, 1fr) !important;
719
- }
720
-
721
- .grid-cols-3 {
722
- grid-template-columns: repeat(3, 1fr) !important;
723
- }
724
-
725
- /* Reports grid */
726
- #reports-grid,
727
- #reports-pending {
728
- grid-template-columns: repeat(2, 1fr) !important;
729
- }
730
-
731
- /* About grid */
732
- #tab-about .grid.grid-cols-3 {
733
- grid-template-columns: repeat(3, 1fr) !important;
734
- }
735
-
736
-
737
-
738
- /* Insights panel */
739
- #insights-panel .grid {
740
- grid-template-columns: repeat(2, 1fr) !important;
741
- }
742
- }
743
-
744
- /* =============================================
745
- MOBILE (< 1024px) — full mobile overhaul
746
- ============================================= */
747
- @media (max-width: 1023px) {
748
-
749
- /* --- Hide desktop sidebar --- */
750
- aside.w-60 {
751
- display: none !important;
752
- }
753
-
754
- /* --- Hide old top mobile nav bar --- */
755
- .mobile-nav {
756
- display: none !important;
757
- }
758
-
759
- /* --- Body layout --- */
760
- body {
761
- height: 100dvh;
762
- /* dynamic viewport height accounts for mobile browser chrome */
763
- overflow: hidden;
764
- }
765
-
766
- /* --- Main content — room for top and bottom nav --- */
767
- main {
768
- padding: 70px 12px calc(var(--mob-nav-h) + 8px) 12px !important;
769
- gap: 12px !important;
770
- display: flex !important;
771
- flex-direction: column !important;
772
- height: 100dvh !important;
773
- }
774
-
775
- /* --- Tab Scrolling Fixes — force flex-1 to push progress bar down --- */
776
- #tab-about,
777
- #tab-overview,
778
- #tab-run-details,
779
- #tab-reports,
780
- #tab-settings,
781
- #tab-help,
782
- #tab-feedback {
783
- flex: 1 !important;
784
- min-height: 0 !important;
785
- padding-bottom: 20px !important;
786
- overscroll-behavior: contain;
787
- -webkit-overflow-scrolling: touch;
788
- overflow-y: auto !important;
789
- }
790
-
791
- /* --- About tab specific spacing --- */
792
- #tab-about .space-y-8 {
793
- gap: 16px !important;
794
- }
795
-
796
- #tab-about .pt-8 {
797
- padding-top: 16px !important;
798
- }
799
-
800
- #tab-overview:not(.hidden) {
801
- display: flex !important;
802
- flex-direction: column !important;
803
- overflow-y: auto !important;
804
- overflow-x: hidden !important;
805
- -webkit-overflow-scrolling: touch;
806
- overscroll-behavior: contain;
807
- padding-bottom: calc(var(--mob-nav-h) + 24px) !important;
808
- gap: 16px !important;
809
- }
810
-
811
- #tab-overview>div:not(#stats-empty-state) {
812
- grid-column: span 1 !important;
813
- min-height: 280px;
814
- flex-shrink: 0;
815
- }
816
-
817
- .stats-empty-overlay {
818
- position: fixed !important;
819
- top: 58px;
820
- /* below mobile top bar */
821
- left: 0;
822
- right: 0;
823
- bottom: var(--mob-nav-h);
824
- height: auto !important;
825
- z-index: 100;
826
- background: rgba(0, 0, 0, 0.98);
827
- display: flex;
828
- flex-direction: column;
829
- align-items: center;
830
- justify-content: center;
831
- }
832
-
833
- /* CRITICAL: hide overlay when its parent tab is hidden */
834
- #tab-overview.hidden .stats-empty-overlay {
835
- display: none !important;
836
- }
837
-
838
- /* Hide charts when curtain is up to prevent scroll jank */
839
- #tab-overview.curtain-active {
840
- overflow: hidden !important;
841
- }
842
-
843
- /* --- Settings tab — tighter layout --- */
844
- #tab-settings>div[class*="grid"] {
845
- display: flex !important;
846
- flex-direction: column !important;
847
- gap: 12px !important;
848
- }
849
-
850
- #tab-settings {
851
- overflow-x: hidden !important;
852
- }
853
-
854
- /* Collapse chip panel completely when not shown — removes gap */
855
- #chip-selector.hidden-chip-container {
856
- display: none !important;
857
- margin: 0 !important;
858
- padding: 0 !important;
859
- height: 0 !important;
860
- }
861
-
862
- /* When visible, give it breathing room */
863
- #chip-selector:not(.hidden-chip-container) {
864
- display: flex !important;
865
- flex-wrap: wrap !important;
866
- gap: 8px !important;
867
- margin-top: 12px !important;
868
- padding: 0 !important;
869
- }
870
-
871
- /* Ensure all s-row items are uniform flex rows */
872
- .s-row {
873
- display: flex !important;
874
- flex-direction: row !important;
875
- flex-wrap: nowrap !important;
876
- align-items: center !important;
877
- justify-content: space-between !important;
878
- padding: 14px 0 !important;
879
- border-bottom: 1px solid #1a1a1a !important;
880
- gap: 12px !important;
881
- }
882
-
883
- .s-row:last-child {
884
- border-bottom: none !important;
885
- }
886
-
887
- /* Never let mobile flex override Tailwind .hidden utility */
888
- .s-row.hidden {
889
- display: none !important;
890
- }
891
-
892
- /* chip-selector sits as a sibling below the annotated s-row on mobile */
893
- #chip-selector:not(.hidden-chip-container) {
894
- margin-top: 0 !important;
895
- border-top: none !important;
896
- padding-top: 0 !important;
897
- padding-bottom: 12px !important;
898
- }
899
-
900
- /* Lock toggle in annotated row — must never wrap or shrink */
901
- .s-row[data-param="annotated"]>.toggle-track {
902
- flex-shrink: 0 !important;
903
- flex-grow: 0 !important;
904
- flex-basis: 36px !important;
905
- width: 36px !important;
906
- min-width: 36px !important;
907
- align-self: center !important;
908
- }
909
-
910
- /* Label side must absorb remaining space and never overflow */
911
- .s-row[data-param="annotated"]>div:first-child {
912
- flex: 1 1 0 !important;
913
- min-width: 0 !important;
914
- overflow: hidden !important;
915
- }
916
-
917
- .s-stepper {
918
- width: 140px !important;
919
- /* Compact fixed width */
920
- scale: 0.9;
921
- transform-origin: right;
922
- display: inline-flex !important;
923
- }
924
-
925
- .toggle-track {
926
- width: 36px !important;
927
- scale: 0.9;
928
- transform-origin: right;
929
- }
930
-
931
- @media (max-width: 480px) {
932
- .s-row {
933
- flex-direction: row !important;
934
- flex-wrap: nowrap !important;
935
- align-items: center !important;
936
- justify-content: space-between !important;
937
- gap: 12px !important;
938
- padding: 10px 16px !important;
939
- width: 100% !important;
940
- box-sizing: border-box !important;
941
- }
942
-
943
- /* chip panel below annotated row — remove extra top gap */
944
- #chip-selector:not(.hidden-chip-container) {
945
- margin-top: 0 !important;
946
- padding-bottom: 12px !important;
947
- padding-left: 16px !important;
948
- padding-right: 16px !important;
949
- border-top: none !important;
950
- }
951
-
952
- .s-row {
953
- padding: 12px 16px !important;
954
- }
955
-
956
- #tab-run-details .p-8 {
957
- padding: 20px !important;
958
- }
959
-
960
- #run-results-content {
961
- grid-template-columns: 1fr !important;
962
- gap: 16px !important;
963
- }
964
-
965
- #panel-video .flex,
966
- #panel-perf .flex,
967
- #panel-model .flex,
968
- #panel-infer .flex {
969
- padding-bottom: 8px !important;
970
- }
971
-
972
- .s-row .info-wrap {
973
- display: inline-flex !important;
974
- vertical-align: middle;
975
- }
976
-
977
- .s-row>div:first-child {
978
- width: auto !important;
979
- max-width: 75% !important;
980
- flex: 1 !important;
981
- }
982
-
983
- .toggle-track {
984
- width: 36px !important;
985
- min-width: 36px !important;
986
- height: 20px !important;
987
- flex-shrink: 0 !important;
988
- display: block !important;
989
- position: relative !important;
990
- }
991
-
992
- #run-results-card .text-[10px] {
993
- font-size: 9px !important;
994
- letter-spacing: 0.05em !important;
995
- }
996
-
997
- .s-row>.s-stepper {
998
- width: 130px !important;
999
- flex-shrink: 0 !important;
1000
- display: inline-flex !important;
1001
- flex-direction: row !important;
1002
- }
1003
-
1004
- .chip-container {
1005
- display: grid !important;
1006
- grid-template-columns: 1fr 1fr !important;
1007
- gap: 6px !important;
1008
- margin-top: 12px !important;
1009
- padding: 10px !important;
1010
- background: rgba(255, 255, 255, 0.03);
1011
- border-radius: 8px;
1012
- border: 1px solid #1a1a1a;
1013
- width: 100% !important;
1014
- box-sizing: border-box !important;
1015
- }
1016
-
1017
- .chip {
1018
- padding: 6px !important;
1019
- font-size: 9px !important;
1020
- min-height: 32px !important;
1021
- border-radius: 6px !important;
1022
- justify-content: center !important;
1023
- width: 100% !important;
1024
- white-space: nowrap !important;
1025
- }
1026
-
1027
- .s-stepper {
1028
- width: 130px !important;
1029
- min-width: 130px !important;
1030
- display: inline-flex !important;
1031
- flex-direction: row !important;
1032
- align-items: center !important;
1033
- justify-content: space-between !important;
1034
- transform-origin: right !important;
1035
- }
1036
-
1037
- .toggle-track {
1038
- transform-origin: right !important;
1039
- }
1040
- }
1041
-
1042
- /* --- Progress bar wrapper — remove extra margin to fix huge gap --- */
1043
- #progress-bar-wrapper {
1044
- width: 100% !important;
1045
- max-width: 100% !important;
1046
- box-sizing: border-box !important;
1047
- margin-top: auto !important;
1048
- margin-bottom: 4px !important;
1049
- padding: 8px 12px !important;
1050
- flex-direction: column !important;
1051
- align-items: flex-start !important;
1052
- gap: 6px !important;
1053
- position: relative;
1054
- z-index: 10;
1055
- }
1056
-
1057
- #progress-bar-wrapper>div:first-child {
1058
- width: 100% !important;
1059
- flex: 1 !important;
1060
- min-width: 0 !important;
1061
- margin-right: 0 !important;
1062
- }
1063
-
1064
- #progress-bar-wrapper>div:last-child {
1065
- width: 100% !important;
1066
- justify-content: space-between !important;
1067
- font-size: 10px !important;
1068
- }
1069
-
1070
- /* --- All other grids collapse to single column --- */
1071
- .grid-cols-3,
1072
- .grid-cols-2,
1073
- .lg\:grid-cols-2,
1074
- .xl\:grid-cols-3 {
1075
- grid-template-columns: 1fr !important;
1076
- }
1077
-
1078
- /* --- Run details tab --- */
1079
- #run-results-content {
1080
- grid-template-columns: 1fr !important;
1081
- }
1082
-
1083
- #tab-run-details .grid-cols-2,
1084
- #tab-run-details .grid-cols-3 {
1085
- grid-template-columns: 1fr !important;
1086
- }
1087
-
1088
- /* --- Reports grid --- */
1089
- #reports-grid,
1090
- #reports-pending {
1091
- grid-template-columns: 1fr !important;
1092
- }
1093
-
1094
- /* --- About tab grid --- */
1095
- #tab-about .grid.grid-cols-3 {
1096
- grid-template-columns: 1fr !important;
1097
- }
1098
-
1099
- /* --- Post-process cards --- */
1100
- #post-process-cards {
1101
- grid-template-columns: 1fr !important;
1102
- }
1103
-
1104
- /* --- Insights panel --- */
1105
- #insights-panel .grid {
1106
- grid-template-columns: 1fr !important;
1107
- }
1108
-
1109
- /* --- Feedback tab --- */
1110
- #tab-feedback .grid {
1111
- grid-template-columns: 1fr !important;
1112
- }
1113
-
1114
- /* --- About tab cards --- */
1115
- #tab-about .bg-black.border.rounded-xl {
1116
- padding: 20px !important;
1117
- }
1118
-
1119
- /* --- Stepper — ensure full tap area --- */
1120
- .s-stepper button {
1121
- padding: 10px 14px;
1122
- min-width: 40px;
1123
- min-height: 40px;
1124
- }
1125
-
1126
- /* --- s-row label text — allow wrap --- */
1127
- .s-row>div:first-child .text-xs {
1128
- font-size: 11px;
1129
- }
1130
-
1131
- /* --- Help accordion buttons --- */
1132
- #tab-help button.w-full {
1133
- min-height: 52px;
1134
- padding: 14px 16px !important;
1135
- }
1136
-
1137
- /* --- Feedback priority chips grid --- */
1138
- #fb-priorities {
1139
- grid-template-columns: 1fr !important;
1140
- }
1141
-
1142
- /* --- Keyboard shortcut modal --- */
1143
- #appModal-shortcutsModal>div {
1144
- max-width: 95% !important;
1145
- padding: 20px !important;
1146
- }
1147
-
1148
- /* --- Privacy / Terms modals --- */
1149
- [id^="appModal-"]>div {
1150
- max-width: 95% !important;
1151
- max-height: 80dvh !important;
1152
- overflow-y: auto !important;
1153
- }
1154
-
1155
- #tab-overview>div:last-child {
1156
- min-height: 300px !important;
1157
- padding-bottom: 4px !important;
1158
- margin-bottom: 0 !important;
1159
- }
1160
-
1161
- /* --- Vehicle Classification Internal Scroll --- */
1162
- #tab-overview>div:nth-child(4) {
1163
- max-height: 380px !important;
1164
- display: flex !important;
1165
- flex-direction: column !important;
1166
- }
1167
-
1168
- #tab-overview>div:nth-child(4) #class-breakdown {
1169
- flex: 1 !important;
1170
- overflow-y: auto !important;
1171
- min-height: 0 !important;
1172
- }
1173
- }
1174
-
1175
- /* =============================================
1176
- BOTTOM NAVIGATION BAR — mobile only
1177
- ============================================= */
1178
- .mobile-bottom-nav {
1179
- display: none;
1180
- /* hidden by default, shown on mobile */
1181
- position: fixed;
1182
- bottom: 0;
1183
- left: 0;
1184
- right: 0;
1185
- height: 68px;
1186
- background: #000000;
1187
- border-top: 1px solid #1a1a1a;
1188
- z-index: 40;
1189
- align-items: stretch;
1190
- }
1191
-
1192
- .mob-nav-item {
1193
- flex: 1;
1194
- display: flex;
1195
- flex-direction: column;
1196
- align-items: center;
1197
- justify-content: center;
1198
- gap: 3px;
1199
- cursor: pointer;
1200
- color: #444444;
1201
- font-size: 0;
1202
- font-weight: 700;
1203
- text-transform: uppercase;
1204
- letter-spacing: 0.05em;
1205
- transition: color 0.15s ease;
1206
- border: none;
1207
- background: none;
1208
- padding: 8px 2px;
1209
- -webkit-tap-highlight-color: transparent;
1210
- }
1211
-
1212
- .mob-nav-item i {
1213
- font-size: 22px;
1214
- transition: color 0.15s ease;
1215
- }
1216
-
1217
- .mob-nav-item.active {
1218
- color: var(--cocoa-l);
1219
- }
1220
-
1221
- .mob-nav-item.active i {
1222
- color: var(--cocoa-l);
1223
- }
1224
-
1225
- .mob-nav-item:active {
1226
- color: var(--cocoa-xl);
1227
- }
1228
-
1229
- /* Show bottom nav only on mobile */
1230
- @media (max-width: 1023px) {
1231
- .mobile-bottom-nav {
1232
- display: flex !important;
1233
- }
1234
- }
1235
-
1236
- /* =============================================
1237
- MEDIUM TABLET (640px–1023px) adjustments
1238
- ============================================= */
1239
- @media (min-width: 640px) and (max-width: 1023px) {
1240
-
1241
- /* 2-column grids on tablet where it fits */
1242
- #tab-overview>div {
1243
- min-height: 280px;
1244
- }
1245
-
1246
- #reports-grid,
1247
- #reports-pending {
1248
- grid-template-columns: repeat(2, 1fr) !important;
1249
- }
1250
-
1251
- #fb-priorities {
1252
- grid-template-columns: repeat(2, 1fr) !important;
1253
- }
1254
-
1255
- #tab-about .grid.grid-cols-3 {
1256
- grid-template-columns: repeat(2, 1fr) !important;
1257
- }
1258
- }
1259
-
1260
- /* =============================================
1261
- TOUCH DEVICES — remove hover jank
1262
- ============================================= */
1263
- @media (hover: none) and (pointer: coarse) {
1264
- .nav-item-inactive:hover {
1265
- color: #555555 !important;
1266
- background-color: transparent !important;
1267
- }
1268
-
1269
- .chip:hover {
1270
- border-color: #333333;
1271
- }
1272
-
1273
- .chip.active:hover {
1274
- background: var(--cocoa-l);
1275
- }
1276
-
1277
- .s-stepper button:hover {
1278
- background: transparent;
1279
- color: #666666;
1280
- }
1281
-
1282
- /* Make all interactive elements minimum 44px tall */
1283
- button,
1284
- .fb-emoji-btn,
1285
- .mob-nav-item {
1286
- min-height: 44px;
1287
- }
1288
- }
1289
-
1290
- /* ============================================================
1291
- Custom Select Dropdown (uf-select)
1292
- Replaces native <select> to prevent OS picker sheet on mobile
1293
- ============================================================ */
1294
- .uf-select-wrap {
1295
- position: relative;
1296
- display: inline-block;
1297
- min-width: 110px;
1298
- }
1299
-
1300
- .uf-select-wrap.w-full {
1301
- display: block;
1302
- width: 100%;
1303
- }
1304
-
1305
- .uf-select-trigger {
1306
- display: flex;
1307
- align-items: center;
1308
- justify-content: space-between;
1309
- gap: 6px;
1310
- padding: 5px 10px;
1311
- background: #111111;
1312
- border: 1px solid #222222;
1313
- border-radius: 6px;
1314
- font-size: 11px;
1315
- font-weight: 600;
1316
- color: #ffffff;
1317
- cursor: pointer;
1318
- user-select: none;
1319
- -webkit-tap-highlight-color: transparent;
1320
- transition: border-color 0.15s;
1321
- white-space: nowrap;
1322
- }
1323
-
1324
- .uf-select-trigger:hover,
1325
- .uf-select-trigger:active {
1326
- border-color: #444444;
1327
- }
1328
-
1329
- .uf-select-arrow {
1330
- font-size: 9px;
1331
- color: #666666;
1332
- transition: transform 0.2s ease;
1333
- flex-shrink: 0;
1334
- }
1335
-
1336
- .uf-select-arrow-open {
1337
- transform: rotate(180deg);
1338
- }
1339
-
1340
- /* Dropdown panel — opens downward by default */
1341
- .uf-select-dropdown {
1342
- position: absolute;
1343
- top: calc(100% + 4px);
1344
- left: 0;
1345
- min-width: 100%;
1346
- background: #111111;
1347
- border: 1px solid #2a2a2a;
1348
- border-radius: 8px;
1349
- z-index: 9999;
1350
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.8);
1351
- overflow: hidden;
1352
- max-height: 240px;
1353
- overflow-y: auto;
1354
- }
1355
-
1356
- /* Upward variant — anchors above trigger, for bottom-of-screen selects */
1357
- .uf-select-dropdown-up {
1358
- top: auto;
1359
- bottom: calc(100% + 4px);
1360
- }
1361
-
1362
- .uf-select-option {
1363
- padding: 10px 14px;
1364
- font-size: 11px;
1365
- font-weight: 600;
1366
- color: #aaaaaa;
1367
- cursor: pointer;
1368
- transition: background 0.1s, color 0.1s;
1369
- -webkit-tap-highlight-color: transparent;
1370
- }
1371
-
1372
- .uf-select-option:hover,
1373
- .uf-select-option:active {
1374
- background: #1a1a1a;
1375
- color: #ffffff;
1376
- }
1377
-
1378
- .uf-select-option-active {
1379
- color: var(--cocoa-l);
1380
- background: #0a0a0a;
1381
- }
1382
-
1383
- /* Hide scrollbar inside dropdown — options fit within max-height */
1384
- .uf-select-dropdown::-webkit-scrollbar {
1385
- width: 0;
1386
- height: 0;
1387
- }
1388
-
1389
- /* Desktop: Vehicle Classification thin grey scrollbar (matches reference) */
1390
- @media (min-width: 1024px) {
1391
- #class-breakdown::-webkit-scrollbar {
1392
- width: 4px;
1393
- }
1394
-
1395
- #class-breakdown::-webkit-scrollbar-track {
1396
- background: #000000;
1397
- }
1398
-
1399
- #class-breakdown::-webkit-scrollbar-thumb {
1400
- background: #333333;
1401
- border-radius: 4px;
1402
- }
1403
-
1404
- #class-breakdown::-webkit-scrollbar-thumb:hover {
1405
- background: #444444;
1406
- }
1407
- }
1408
-
1409
- /* ---- Profile & Sidebar PFP ---- */
1410
- #sidebar-profile-pfp-wrap img,
1411
- #mob-pfp-wrap img {
1412
- width: 100%;
1413
- height: 100%;
1414
- object-fit: cover;
1415
- border-radius: 50%;
1416
- }
1417
-
1418
- /* Fix fallback icon visibility (parent font-size is 0) */
1419
- #sidebar-profile-pfp-wrap i,
1420
- #mob-pfp-wrap i {
1421
- font-size: 1.2rem;
1422
- color: #555;
1423
- }
1424
-
1425
- .mob-nav-item i {
1426
- transition: transform 0.2s ease;
1427
- }
1428
-
1429
- .mob-nav-item:active i {
1430
- transform: scale(0.9);
1431
- }
1432
-
1433
- /* ---- Legal Menu Dropdown ---- */
1434
- #legal-menu, #legal-menu-profile {
1435
- animation: menuFadeIn 0.2s ease-out forwards;
1436
- transform-origin: top right;
1437
- box-shadow: 0 10px 40px rgba(0,0,0,0.8);
1438
- }
1439
-
1440
- @keyframes menuFadeIn {
1441
- from { opacity: 0; transform: translateY(-10px) scale(0.95); }
1442
- to { opacity: 1; transform: translateY(0) scale(1); }
1443
- }
1444
-
1445
- /* ---- Profile Tab specific overrides ---- */
1446
- #tab-profile input[type="text"] {
1447
- border: 1px solid #222 !important;
1448
- }
1449
-
1450
- #tab-profile input[type="text"]:focus {
1451
- border-color: var(--cocoa-l) !important;
1452
- background: #000 !important;
1453
- }
1454
-
1455
- @media (max-width: 1023px) {
1456
- /* Zero tab side padding on mobile */
1457
- #tab-results,
1458
- #tab-profile,
1459
- #tab-overview {
1460
- padding-left: 0 !important;
1461
- padding-right: 0 !important;
1462
- padding-bottom: calc(var(--mob-nav-h) + 12px) !important;
1463
- }
1464
-
1465
- /* Results content wrap — tighter spacing */
1466
- #results-content-wrap {
1467
- gap: 16px !important;
1468
- }
1469
-
1470
- /* Telemetry cards inner padding */
1471
- #run-results-card > div.p-8 {
1472
- padding: 12px !important;
1473
- }
1474
- #run-results-content {
1475
- gap: 12px !important;
1476
- }
1477
-
1478
- /* Technical context grid — stack on mobile */
1479
- #tab-results .grid.grid-cols-2 {
1480
- grid-template-columns: 1fr !important;
1481
- gap: 12px !important;
1482
- }
1483
-
1484
- /* Panel inner padding */
1485
- #panel-video,
1486
- #panel-perf,
1487
- #panel-model,
1488
- #panel-infer {
1489
- padding: 12px !important;
1490
- }
1491
-
1492
- /* Telemetry section — reduce top margin */
1493
- #tab-results details .mt-8 {
1494
- margin-top: 16px !important;
1495
- }
1496
- #tab-results .mt-12 {
1497
- margin-top: 24px !important;
1498
- padding-top: 24px !important;
1499
- }
1500
-
1501
- /* Profile card — tighter mobile padding */
1502
- #tab-profile .bg-neutral-950 {
1503
- border-radius: 12px !important;
1504
- }
1505
- #tab-profile .p-6 {
1506
- padding: 16px !important;
1507
- }
1508
- #tab-profile .p-5 {
1509
- padding: 14px !important;
1510
- }
1511
-
1512
- /* Chart cards — reduce padding */
1513
- #tab-results .bg-black.rounded-xl {
1514
- border-radius: 10px !important;
1515
- }
1516
- #tab-results .bg-neutral-950.rounded-xl {
1517
- border-radius: 10px !important;
1518
- }
1519
- }
1520
-
1521
- /* Ensure mobile results scrolling */
1522
- #tab-results:not(.hidden) {
1523
- display: flex !important;
1524
- flex-direction: column !important;
1525
- }
 
1
+ /* =============================================
2
+ UrbanFlow — vehicles.css (Mobile-First)
3
+ Desktop layout preserved exactly.
4
+ Mobile: bottom nav, touch targets, stacked cards.
5
+ ============================================= */
6
+
7
+ :root {
8
+ --cocoa: #8b5e3c;
9
+ --cocoa-l: #c89a6c;
10
+ --cocoa-xl: #d4b08a;
11
+ /* bottom nav height on mobile, incl. the iOS home-indicator inset */
12
+ --mob-nav-h: calc(68px + env(safe-area-inset-bottom));
13
+ }
14
+
15
+ *,
16
+ *::before,
17
+ *::after {
18
+ box-sizing: border-box;
19
+ }
20
+
21
+ .hidden {
22
+ display: none !important;
23
+ }
24
+
25
+ html {
26
+ overflow: hidden;
27
+ height: 100%;
28
+ }
29
+
30
+ body {
31
+ font-family: 'Montserrat', sans-serif;
32
+ background-color: #000000;
33
+ color: #f0ece6;
34
+ -webkit-tap-highlight-color: transparent;
35
+ overscroll-behavior: none;
36
+ }
37
+
38
+ .mono-font {
39
+ font-family: 'JetBrains Mono', monospace;
40
+ }
41
+
42
+ /* ---- Scrollbar: hide globally on mobile, keep #class-breakdown visible ---- */
43
+ @media (max-width: 1023px) {
44
+ * {
45
+ scrollbar-width: none;
46
+ -ms-overflow-style: none;
47
+ }
48
+
49
+ *::-webkit-scrollbar {
50
+ display: none;
51
+ }
52
+
53
+ /* Vehicle Classification section keeps its scrollbar on mobile */
54
+ #class-breakdown {
55
+ scrollbar-width: thin !important;
56
+ -ms-overflow-style: auto !important;
57
+ }
58
+
59
+ #class-breakdown::-webkit-scrollbar {
60
+ display: block !important;
61
+ width: 4px !important;
62
+ }
63
+
64
+ #class-breakdown::-webkit-scrollbar-track {
65
+ background: #000000 !important;
66
+ }
67
+
68
+ #class-breakdown::-webkit-scrollbar-thumb {
69
+ background: #222222 !important;
70
+ border-radius: 4px !important;
71
+ }
72
+
73
+ #class-breakdown::-webkit-scrollbar-thumb:hover {
74
+ background: #333333 !important;
75
+ }
76
+ }
77
+
78
+ /* ---- Notification Glow ---- */
79
+ @keyframes glow-green {
80
+ 0% {
81
+ color: #f0ece6;
82
+ filter: drop-shadow(0 0 0px #4ade80);
83
+ }
84
+
85
+ 50% {
86
+ color: #4ade80;
87
+ filter: drop-shadow(0 0 8px #4ade80);
88
+ }
89
+
90
+ 100% {
91
+ color: #f0ece6;
92
+ filter: drop-shadow(0 0 0px #4ade80);
93
+ }
94
+ }
95
+
96
+ .notify-glow i {
97
+ animation: glow-green 1.5s infinite ease-in-out !important;
98
+ }
99
+
100
+ /* ---- Info tooltip ---- */
101
+ .info-wrap {
102
+ position: relative;
103
+ display: inline-flex;
104
+ align-items: center;
105
+ margin-left: 6px;
106
+ }
107
+
108
+ .info-btn {
109
+ display: inline-flex;
110
+ align-items: center;
111
+ justify-content: center;
112
+ width: 18px;
113
+ /* slightly larger for touch */
114
+ height: 18px;
115
+ border-radius: 50%;
116
+ background: #444444 !important;
117
+ color: #ffffff !important;
118
+ font-size: 8px;
119
+ cursor: pointer;
120
+ transition: all 0.2s ease;
121
+ }
122
+
123
+ .info-btn:hover,
124
+ .info-btn:active {
125
+ background: #666666 !important;
126
+ }
127
+
128
+ .info-tip {
129
+ display: none;
130
+ position: fixed;
131
+ z-index: 9999;
132
+ background: #0a0a0a;
133
+ color: #aaaaaa;
134
+ font-size: 10px;
135
+ font-weight: 500;
136
+ line-height: 1.4;
137
+ padding: 8px 12px;
138
+ border-radius: 6px;
139
+ max-width: 240px;
140
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.8);
141
+ border: 1px solid #222222;
142
+ pointer-events: none;
143
+ text-transform: none;
144
+ letter-spacing: normal;
145
+ }
146
+
147
+ /* ---- Mobile Top Bar ---- */
148
+ .mobile-top-bar {
149
+ display: none;
150
+ }
151
+
152
+ @media (max-width: 1023px) {
153
+ .mobile-top-bar {
154
+ display: flex;
155
+ align-items: center;
156
+ justify-content: center;
157
+ position: fixed;
158
+ top: 0;
159
+ left: 0;
160
+ right: 0;
161
+ height: 58px;
162
+ background: #000000;
163
+ border-bottom: 1px solid #1a1a1a;
164
+ z-index: 35;
165
+ flex-shrink: 0;
166
+ }
167
+
168
+ #legal-menu {
169
+ animation: menuFadeIn 0.2s ease-out forwards;
170
+ transform-origin: top right;
171
+ }
172
+
173
+ @keyframes menuFadeIn {
174
+ from {
175
+ opacity: 0;
176
+ transform: translateY(-10px) scale(0.95);
177
+ }
178
+
179
+ to {
180
+ opacity: 1;
181
+ transform: translateY(0) scale(1);
182
+ }
183
+ }
184
+ }
185
+
186
+ /* ---- Sidebar nav states ---- */
187
+ .nav-item-active {
188
+ background-color: #111111 !important;
189
+ color: var(--cocoa-xl) !important;
190
+ border-left: 2px solid var(--cocoa-l) !important;
191
+ }
192
+
193
+ .nav-item-inactive {
194
+ color: #555555 !important;
195
+ }
196
+
197
+ .nav-item-inactive:hover {
198
+ color: #f0ece6 !important;
199
+ background-color: #050505 !important;
200
+ }
201
+
202
+ /* ---- Card overrides ---- */
203
+ .bg-white {
204
+ background-color: #0a0a0a !important;
205
+ }
206
+
207
+ .border-slate-200,
208
+ .border-slate-100,
209
+ .border-slate-50,
210
+ .border-neutral-800,
211
+ .border-neutral-900 {
212
+ border-color: #2a2a2a !important;
213
+ }
214
+
215
+ .bg-slate-50\/50,
216
+ .bg-slate-50,
217
+ .bg-slate-900,
218
+ .bg-neutral-900 {
219
+ background-color: #0c0c0c !important;
220
+ }
221
+
222
+ .text-slate-900,
223
+ .text-slate-800,
224
+ .text-slate-700,
225
+ .text-neutral-900 {
226
+ color: #ffffff !important;
227
+ }
228
+
229
+ .text-slate-600,
230
+ .text-slate-500,
231
+ .text-slate-400,
232
+ .text-neutral-500,
233
+ .text-neutral-400 {
234
+ color: #888888 !important;
235
+ }
236
+
237
+ .shadow-sm {
238
+ box-shadow: none !important;
239
+ }
240
+
241
+ /* ---- Toggle control ---- */
242
+ .toggle-track {
243
+ width: 36px;
244
+ /* slightly wider for touch */
245
+ height: 20px;
246
+ border-radius: 999px;
247
+ background: #1a1a1a;
248
+ border: 1px solid #333;
249
+ position: relative;
250
+ cursor: pointer;
251
+ flex-shrink: 0;
252
+ transition: background 0.2s ease;
253
+ }
254
+
255
+ .toggle-track.active {
256
+ background: #c89a6c !important;
257
+ border-color: #c89a6c !important;
258
+ }
259
+
260
+ .toggle-thumb {
261
+ width: 16px;
262
+ height: 16px;
263
+ border-radius: 50%;
264
+ background: #555555;
265
+ position: absolute;
266
+ top: 2px;
267
+ left: 2px;
268
+ transition: all 0.2s ease;
269
+ }
270
+
271
+ .toggle-track.active .toggle-thumb {
272
+ transform: translateX(16px);
273
+ background: #ffffff;
274
+ /* pure white for contrast on gold track */
275
+ }
276
+
277
+ /* ---- Custom select ---- */
278
+ .custom-select {
279
+ appearance: none;
280
+ background-color: #111111;
281
+ border: 1px solid #222222;
282
+ border-radius: 6px;
283
+ padding: 4px 24px 4px 10px;
284
+ font-size: 11px;
285
+ font-weight: 600;
286
+ color: #ffffff;
287
+ outline: none;
288
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%23666666'%3E%3Cpath fill-rule='evenodd' d='M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z'/%3E%3C/svg%3E");
289
+ background-repeat: no-repeat;
290
+ background-position: right 8px center;
291
+ background-size: 12px;
292
+ }
293
+
294
+ /* ---- Stepper ---- */
295
+ .s-stepper {
296
+ display: inline-flex;
297
+ border: 1px solid #222222;
298
+ border-radius: 6px;
299
+ background: #111111;
300
+ overflow: hidden;
301
+ flex-shrink: 0;
302
+ }
303
+
304
+ .s-stepper button {
305
+ padding: 8px 12px;
306
+ /* larger touch target than original 4px 8px */
307
+ color: #666666;
308
+ font-size: 14px;
309
+ min-width: 36px;
310
+ min-height: 36px;
311
+ display: flex;
312
+ align-items: center;
313
+ justify-content: center;
314
+ }
315
+
316
+ .s-stepper button:hover,
317
+ .s-stepper button:active {
318
+ background: #1a1a1a;
319
+ color: #ffffff;
320
+ }
321
+
322
+ .s-stepper .s-val {
323
+ min-width: 44px;
324
+ text-align: center;
325
+ font-family: 'JetBrains Mono', monospace;
326
+ font-size: 12px;
327
+ font-weight: 700;
328
+ color: #ffffff;
329
+ padding: 4px 0;
330
+ border-left: 1px solid #222222;
331
+ border-right: 1px solid #222222;
332
+ display: flex;
333
+ align-items: center;
334
+ justify-content: center;
335
+ }
336
+
337
+ /* ---- Settings row ---- */
338
+ .s-row {
339
+ display: flex;
340
+ align-items: center;
341
+ justify-content: space-between;
342
+ padding: 14px 0;
343
+ /* slightly more vertical padding */
344
+ border-bottom: 1px solid #1a1a1a;
345
+ gap: 12px;
346
+ }
347
+
348
+ .s-row:last-child {
349
+ border-bottom: none;
350
+ }
351
+
352
+ .s-row>div:first-child {
353
+ flex: 1;
354
+ min-width: 0;
355
+ }
356
+
357
+ /* ---- Progress bar ---- */
358
+ #proc-bar {
359
+ background-color: var(--cocoa-l) !important;
360
+ }
361
+
362
+ #proc-label {
363
+ color: #ffffff !important;
364
+ }
365
+
366
+ /* ---- Disabled rows ---- */
367
+ .s-row.disabled {
368
+ opacity: 0.65 !important;
369
+ }
370
+
371
+ .s-row.disabled .s-stepper,
372
+ .s-row.disabled .custom-select,
373
+ .s-row.disabled .toggle-track,
374
+ .s-row.disabled .chip-container,
375
+ .s-row.disabled .uf-select-wrap,
376
+ .s-row.disabled .uf-select-trigger {
377
+ pointer-events: none !important;
378
+ opacity: 0.5 !important;
379
+ }
380
+
381
+ /* Force-collapse the dropdown panel when row is locked */
382
+ .s-row.disabled .uf-select-dropdown {
383
+ display: none !important;
384
+ }
385
+
386
+ .s-row.disabled .info-wrap {
387
+ pointer-events: auto !important;
388
+ opacity: 1 !important;
389
+ }
390
+
391
+ #btn-start-processing {
392
+ font-family: 'Montserrat', sans-serif !important;
393
+ }
394
+
395
+ /* ---- Chips ---- */
396
+ .chip-container {
397
+ display: flex;
398
+ flex-wrap: wrap;
399
+ gap: 8px;
400
+ margin-top: 12px;
401
+ padding-top: 12px;
402
+ border-top: 1px solid #1a1a1a;
403
+ transition: all 0.3s ease;
404
+ }
405
+
406
+ .chip {
407
+ display: inline-flex;
408
+ align-items: center;
409
+ gap: 6px;
410
+ padding: 8px 14px;
411
+ /* larger than original 6px 14px */
412
+ border-radius: 9999px;
413
+ font-size: 10px;
414
+ font-weight: 700;
415
+ cursor: pointer;
416
+ transition: all 0.2s ease;
417
+ user-select: none;
418
+ border: 1px solid #333333;
419
+ background: rgba(255, 255, 255, 0.03);
420
+ color: #888888;
421
+ min-height: 36px;
422
+ }
423
+
424
+ .chip.active {
425
+ background: var(--cocoa-l);
426
+ color: #000000;
427
+ border-color: var(--cocoa-l);
428
+ }
429
+
430
+ .chip.frozen {
431
+ background: rgba(255, 255, 255, 0.4);
432
+ color: #000000;
433
+ border-color: transparent;
434
+ cursor: default !important;
435
+ pointer-events: none;
436
+ }
437
+
438
+ .chip:hover {
439
+ border-color: #666666;
440
+ }
441
+
442
+ .chip.active:hover {
443
+ background: var(--cocoa-xl);
444
+ }
445
+
446
+ .chip i {
447
+ font-size: 9px;
448
+ }
449
+
450
+ .hidden-chip-container {
451
+ display: none !important;
452
+ margin: 0 !important;
453
+ padding: 0 !important;
454
+ height: 0 !important;
455
+ }
456
+
457
+ /* ---- Toast ---- */
458
+ #toast-container {
459
+ position: fixed;
460
+ bottom: calc(var(--mob-nav-h) + 12px);
461
+ /* above bottom nav on mobile */
462
+ left: 50%;
463
+ transform: translateX(-50%);
464
+ z-index: 10000;
465
+ display: flex;
466
+ flex-direction: column;
467
+ align-items: center;
468
+ gap: 8px;
469
+ pointer-events: none;
470
+ width: 90%;
471
+ max-width: 360px;
472
+ }
473
+
474
+ .toast {
475
+ background: #111;
476
+ border: 1px solid #2a2a2a;
477
+ color: #f0ece6;
478
+ font-size: 11px;
479
+ font-weight: 600;
480
+ padding: 12px 18px;
481
+ border-radius: 10px;
482
+ display: flex;
483
+ align-items: center;
484
+ gap: 8px;
485
+ pointer-events: auto;
486
+ animation: toastIn 0.3s ease-out;
487
+ width: 100%;
488
+ }
489
+
490
+ .toast.toast-out {
491
+ animation: toastOut 0.3s ease-in forwards;
492
+ }
493
+
494
+ .toast-success {
495
+ border-color: #166534;
496
+ }
497
+
498
+ .toast-success i {
499
+ color: #22c55e;
500
+ }
501
+
502
+ .toast-error {
503
+ border-color: #7f1d1d;
504
+ }
505
+
506
+ .toast-error i {
507
+ color: #ef4444;
508
+ }
509
+
510
+ .toast-info i {
511
+ color: var(--cocoa-l);
512
+ }
513
+
514
+ @keyframes toastIn {
515
+ from {
516
+ opacity: 0;
517
+ transform: translateY(20px);
518
+ }
519
+
520
+ to {
521
+ opacity: 1;
522
+ transform: translateY(0);
523
+ }
524
+ }
525
+
526
+ @keyframes toastOut {
527
+ from {
528
+ opacity: 1;
529
+ }
530
+
531
+ to {
532
+ opacity: 0;
533
+ transform: translateY(20px);
534
+ }
535
+ }
536
+
537
+ /* ---- Stats empty overlay ---- */
538
+ .stats-empty-overlay {
539
+ position: absolute;
540
+ inset: 0;
541
+ z-index: 50;
542
+ display: flex;
543
+ flex-direction: column;
544
+ align-items: center;
545
+ justify-content: center;
546
+ background: rgba(10, 10, 10, 0.85);
547
+ backdrop-filter: blur(8px);
548
+ border-radius: 12px;
549
+ }
550
+
551
+ /* ---- Feedback form ---- */
552
+ .fb-textarea {
553
+ background: #111;
554
+ border: 1px solid #2a2a2a;
555
+ border-radius: 8px;
556
+ color: #f0ece6;
557
+ font-size: 12px;
558
+ padding: 12px;
559
+ width: 100%;
560
+ min-height: 120px;
561
+ resize: vertical;
562
+ font-family: 'Inter', sans-serif;
563
+ }
564
+
565
+ .fb-textarea:focus {
566
+ outline: none;
567
+ border-color: var(--cocoa-l);
568
+ }
569
+
570
+ .fb-select {
571
+ background: #111;
572
+ border: 1px solid #2a2a2a;
573
+ border-radius: 8px;
574
+ color: #f0ece6;
575
+ font-size: 11px;
576
+ padding: 10px 12px;
577
+ /* taller for touch */
578
+ width: 100%;
579
+ font-family: 'Inter', sans-serif;
580
+ min-height: 44px;
581
+ }
582
+
583
+ .fb-select:focus {
584
+ outline: none;
585
+ border-color: var(--cocoa-l);
586
+ }
587
+
588
+ .fb-stars {
589
+ display: flex;
590
+ gap: 8px;
591
+ }
592
+
593
+ .fb-star {
594
+ font-size: 28px;
595
+ /* larger for mobile tapping */
596
+ color: #333;
597
+ cursor: pointer;
598
+ transition: color 0.15s;
599
+ min-width: 36px;
600
+ min-height: 36px;
601
+ display: flex;
602
+ align-items: center;
603
+ justify-content: center;
604
+ }
605
+
606
+ .fb-star.active,
607
+ .fb-star:hover {
608
+ color: var(--cocoa-l);
609
+ }
610
+
611
+ .fb-chip {
612
+ background: #050505;
613
+ border: 1px solid #222;
614
+ border-radius: 8px;
615
+ color: #666;
616
+ font-size: 10px;
617
+ font-weight: 700;
618
+ padding: 14px 12px;
619
+ /* taller for touch */
620
+ cursor: pointer;
621
+ transition: all 0.2s ease;
622
+ text-align: center;
623
+ text-transform: uppercase;
624
+ min-height: 44px;
625
+ display: flex;
626
+ align-items: center;
627
+ justify-content: center;
628
+ }
629
+
630
+ .fb-chip:hover {
631
+ border-color: #444;
632
+ color: #999;
633
+ }
634
+
635
+ .fb-chip.active {
636
+ border-color: var(--cocoa-l);
637
+ background: #111;
638
+ color: #fff;
639
+ box-shadow: 0 0 15px rgba(200, 154, 108, 0.15);
640
+ }
641
+
642
+ .fb-emoji-btn {
643
+ background: #111;
644
+ border: 1px solid #2a2a2a;
645
+ border-radius: 8px;
646
+ color: #555;
647
+ flex: 1;
648
+ text-align: center;
649
+ padding: 12px 4px;
650
+ /* taller */
651
+ cursor: pointer;
652
+ transition: all 0.2s ease;
653
+ min-height: 64px;
654
+ display: flex;
655
+ flex-direction: column;
656
+ align-items: center;
657
+ justify-content: center;
658
+ }
659
+
660
+ .fb-emoji-btn:hover {
661
+ border-color: #444;
662
+ color: #888;
663
+ }
664
+
665
+ .fb-emoji-btn.active {
666
+ border-color: var(--cocoa-l);
667
+ background: #1a1a1a;
668
+ color: var(--cocoa-l);
669
+ box-shadow: 0 0 15px rgba(200, 154, 108, 0.15);
670
+ }
671
+
672
+ /* =============================================
673
+ DESKTOP (≥1024px) — original layout intact
674
+ ============================================= */
675
+ @media (min-width: 1024px) {
676
+
677
+ /* Sidebar visible */
678
+ aside.w-60 {
679
+ display: flex !important;
680
+ }
681
+
682
+ /* Bottom mobile nav hidden */
683
+ .mobile-bottom-nav {
684
+ display: none !important;
685
+ }
686
+
687
+ /* Main no bottom padding needed */
688
+ main {
689
+ padding-bottom: 1rem !important;
690
+ }
691
+
692
+ /* Toastdesktop position: bottom-right */
693
+ #toast-container {
694
+ bottom: 20px;
695
+ left: unset;
696
+ right: 20px;
697
+ transform: none;
698
+ width: auto;
699
+ align-items: flex-end;
700
+ }
701
+
702
+ /* Settings — 2 column grid */
703
+ #tab-settings .grid {
704
+ grid-template-columns: repeat(2, 1fr) !important;
705
+ }
706
+
707
+ /* Run details multi-column grids preserved */
708
+ #run-results-content {
709
+ grid-template-columns: repeat(3, 1fr) !important;
710
+ }
711
+
712
+ .grid-cols-2 {
713
+ grid-template-columns: repeat(2, 1fr) !important;
714
+ }
715
+
716
+ .grid-cols-3 {
717
+ grid-template-columns: repeat(3, 1fr) !important;
718
+ }
719
+
720
+ /* Reports grid */
721
+ #reports-grid,
722
+ #reports-pending {
723
+ grid-template-columns: repeat(2, 1fr) !important;
724
+ }
725
+
726
+ /* About grid */
727
+ #tab-about .grid.grid-cols-3 {
728
+ grid-template-columns: repeat(3, 1fr) !important;
729
+ }
730
+
731
+
732
+
733
+ /* Insights panel */
734
+ #insights-panel .grid {
735
+ grid-template-columns: repeat(2, 1fr) !important;
736
+ }
737
+ }
738
+
739
+ /* =============================================
740
+ MOBILE (< 1024px) — full mobile overhaul
741
+ ============================================= */
742
+ @media (max-width: 1023px) {
743
+
744
+ /* --- Hide desktop sidebar --- */
745
+ aside.w-60 {
746
+ display: none !important;
747
+ }
748
+
749
+ /* --- Body layout --- */
750
+ body {
751
+ height: 100dvh;
752
+ /* dynamic viewport height — accounts for mobile browser chrome */
753
+ overflow: hidden;
754
+ }
755
+
756
+ /* --- Main content — room for top and bottom nav --- */
757
+ main {
758
+ padding: 70px 12px calc(var(--mob-nav-h) + 8px) 12px !important;
759
+ gap: 12px !important;
760
+ display: flex !important;
761
+ flex-direction: column !important;
762
+ height: 100dvh !important;
763
+ }
764
+
765
+ /* --- Tab Scrolling Fixes — force flex-1 to push progress bar down --- */
766
+ #tab-about,
767
+ #tab-overview,
768
+ #tab-results,
769
+ #tab-settings,
770
+ #tab-help,
771
+ #tab-feedback,
772
+ #tab-profile {
773
+ flex: 1 !important;
774
+ min-height: 0 !important;
775
+ padding-bottom: 20px !important;
776
+ overscroll-behavior: contain;
777
+ -webkit-overflow-scrolling: touch;
778
+ overflow-y: auto !important;
779
+ }
780
+
781
+ /* --- About tab specific spacing --- */
782
+ #tab-about .space-y-8 {
783
+ gap: 16px !important;
784
+ }
785
+
786
+ #tab-about .pt-8 {
787
+ padding-top: 16px !important;
788
+ }
789
+
790
+ #tab-overview:not(.hidden) {
791
+ display: flex !important;
792
+ flex-direction: column !important;
793
+ overflow-y: auto !important;
794
+ overflow-x: hidden !important;
795
+ -webkit-overflow-scrolling: touch;
796
+ overscroll-behavior: contain;
797
+ padding-bottom: calc(var(--mob-nav-h) + 24px) !important;
798
+ gap: 16px !important;
799
+ }
800
+
801
+ #tab-overview>div:not(#stats-empty-state) {
802
+ grid-column: span 1 !important;
803
+ min-height: 280px;
804
+ flex-shrink: 0;
805
+ }
806
+
807
+ .stats-empty-overlay {
808
+ position: fixed !important;
809
+ top: 58px;
810
+ /* below mobile top bar */
811
+ left: 0;
812
+ right: 0;
813
+ bottom: var(--mob-nav-h);
814
+ height: auto !important;
815
+ z-index: 100;
816
+ background: rgba(0, 0, 0, 0.98);
817
+ display: flex;
818
+ flex-direction: column;
819
+ align-items: center;
820
+ justify-content: center;
821
+ }
822
+
823
+ /* CRITICAL: hide overlay when its parent tab is hidden */
824
+ #tab-overview.hidden .stats-empty-overlay {
825
+ display: none !important;
826
+ }
827
+
828
+ /* --- Settings tab — tighter layout --- */
829
+ #tab-settings>div[class*="grid"] {
830
+ display: flex !important;
831
+ flex-direction: column !important;
832
+ gap: 12px !important;
833
+ }
834
+
835
+ #tab-settings {
836
+ overflow-x: hidden !important;
837
+ }
838
+
839
+ /* Collapse chip panel completely when not shown — removes gap */
840
+ #chip-selector.hidden-chip-container {
841
+ display: none !important;
842
+ margin: 0 !important;
843
+ padding: 0 !important;
844
+ height: 0 !important;
845
+ }
846
+
847
+ /* When visible, give it breathing room */
848
+ #chip-selector:not(.hidden-chip-container) {
849
+ display: flex !important;
850
+ flex-wrap: wrap !important;
851
+ gap: 8px !important;
852
+ margin-top: 12px !important;
853
+ padding: 0 !important;
854
+ }
855
+
856
+ /* Ensure all s-row items are uniform flex rows */
857
+ .s-row {
858
+ display: flex !important;
859
+ flex-direction: row !important;
860
+ flex-wrap: nowrap !important;
861
+ align-items: center !important;
862
+ justify-content: space-between !important;
863
+ padding: 14px 0 !important;
864
+ border-bottom: 1px solid #1a1a1a !important;
865
+ gap: 12px !important;
866
+ }
867
+
868
+ .s-row:last-child {
869
+ border-bottom: none !important;
870
+ }
871
+
872
+ /* Never let mobile flex override Tailwind .hidden utility */
873
+ .s-row.hidden {
874
+ display: none !important;
875
+ }
876
+
877
+ /* chip-selector sits as a sibling below the annotated s-row on mobile */
878
+ #chip-selector:not(.hidden-chip-container) {
879
+ margin-top: 0 !important;
880
+ border-top: none !important;
881
+ padding-top: 0 !important;
882
+ padding-bottom: 12px !important;
883
+ }
884
+
885
+ /* Lock toggle in annotated row — must never wrap or shrink */
886
+ .s-row[data-param="annotated"]>.toggle-track {
887
+ flex-shrink: 0 !important;
888
+ flex-grow: 0 !important;
889
+ flex-basis: 36px !important;
890
+ width: 36px !important;
891
+ min-width: 36px !important;
892
+ align-self: center !important;
893
+ }
894
+
895
+ /* Label side must absorb remaining space and never overflow */
896
+ .s-row[data-param="annotated"]>div:first-child {
897
+ flex: 1 1 0 !important;
898
+ min-width: 0 !important;
899
+ overflow: hidden !important;
900
+ }
901
+
902
+ .s-stepper {
903
+ width: 140px !important;
904
+ /* Compact fixed width */
905
+ scale: 0.9;
906
+ transform-origin: right;
907
+ display: inline-flex !important;
908
+ }
909
+
910
+ .toggle-track {
911
+ width: 36px !important;
912
+ scale: 0.9;
913
+ transform-origin: right;
914
+ }
915
+
916
+ @media (max-width: 480px) {
917
+ .s-row {
918
+ flex-direction: row !important;
919
+ flex-wrap: nowrap !important;
920
+ align-items: center !important;
921
+ justify-content: space-between !important;
922
+ gap: 12px !important;
923
+ padding: 10px 16px !important;
924
+ width: 100% !important;
925
+ box-sizing: border-box !important;
926
+ }
927
+
928
+ /* chip panel below annotated row — remove extra top gap */
929
+ #chip-selector:not(.hidden-chip-container) {
930
+ margin-top: 0 !important;
931
+ padding-bottom: 12px !important;
932
+ padding-left: 16px !important;
933
+ padding-right: 16px !important;
934
+ border-top: none !important;
935
+ }
936
+
937
+ .s-row {
938
+ padding: 12px 16px !important;
939
+ }
940
+
941
+ #run-results-card .p-8 {
942
+ padding: 20px !important;
943
+ }
944
+
945
+ #run-results-content {
946
+ grid-template-columns: 1fr !important;
947
+ gap: 16px !important;
948
+ }
949
+
950
+ #panel-video .flex,
951
+ #panel-perf .flex,
952
+ #panel-model .flex,
953
+ #panel-infer .flex {
954
+ padding-bottom: 8px !important;
955
+ }
956
+
957
+ .s-row .info-wrap {
958
+ display: inline-flex !important;
959
+ vertical-align: middle;
960
+ }
961
+
962
+ .s-row>div:first-child {
963
+ width: auto !important;
964
+ max-width: 75% !important;
965
+ flex: 1 !important;
966
+ }
967
+
968
+ .toggle-track {
969
+ width: 36px !important;
970
+ min-width: 36px !important;
971
+ height: 20px !important;
972
+ flex-shrink: 0 !important;
973
+ display: block !important;
974
+ position: relative !important;
975
+ }
976
+
977
+ #run-results-card .text-[10px] {
978
+ font-size: 9px !important;
979
+ letter-spacing: 0.05em !important;
980
+ }
981
+
982
+ .s-row>.s-stepper {
983
+ width: 130px !important;
984
+ flex-shrink: 0 !important;
985
+ display: inline-flex !important;
986
+ flex-direction: row !important;
987
+ }
988
+
989
+ .chip-container {
990
+ display: grid !important;
991
+ grid-template-columns: 1fr 1fr !important;
992
+ gap: 6px !important;
993
+ margin-top: 12px !important;
994
+ padding: 10px !important;
995
+ background: rgba(255, 255, 255, 0.03);
996
+ border-radius: 8px;
997
+ border: 1px solid #1a1a1a;
998
+ width: 100% !important;
999
+ box-sizing: border-box !important;
1000
+ }
1001
+
1002
+ .chip {
1003
+ padding: 6px !important;
1004
+ font-size: 9px !important;
1005
+ min-height: 32px !important;
1006
+ border-radius: 6px !important;
1007
+ justify-content: center !important;
1008
+ width: 100% !important;
1009
+ white-space: nowrap !important;
1010
+ }
1011
+
1012
+ .s-stepper {
1013
+ width: 130px !important;
1014
+ min-width: 130px !important;
1015
+ display: inline-flex !important;
1016
+ flex-direction: row !important;
1017
+ align-items: center !important;
1018
+ justify-content: space-between !important;
1019
+ transform-origin: right !important;
1020
+ }
1021
+
1022
+ .toggle-track {
1023
+ transform-origin: right !important;
1024
+ }
1025
+ }
1026
+
1027
+ /* --- Progress bar wrapper — remove extra margin to fix huge gap --- */
1028
+ #progress-bar-wrapper {
1029
+ width: 100% !important;
1030
+ max-width: 100% !important;
1031
+ box-sizing: border-box !important;
1032
+ margin-top: auto !important;
1033
+ margin-bottom: 4px !important;
1034
+ padding: 8px 12px !important;
1035
+ flex-direction: column !important;
1036
+ align-items: flex-start !important;
1037
+ gap: 6px !important;
1038
+ position: relative;
1039
+ z-index: 10;
1040
+ }
1041
+
1042
+ #progress-bar-wrapper>div:first-child {
1043
+ width: 100% !important;
1044
+ flex: 1 !important;
1045
+ min-width: 0 !important;
1046
+ margin-right: 0 !important;
1047
+ }
1048
+
1049
+ #progress-bar-wrapper>div:last-child {
1050
+ width: 100% !important;
1051
+ justify-content: space-between !important;
1052
+ font-size: 10px !important;
1053
+ }
1054
+
1055
+ /* --- All other grids collapse to single column --- */
1056
+ .grid-cols-3,
1057
+ .grid-cols-2,
1058
+ .lg\:grid-cols-2,
1059
+ .xl\:grid-cols-3 {
1060
+ grid-template-columns: 1fr !important;
1061
+ }
1062
+
1063
+ /* --- Run details tab --- */
1064
+ #run-results-content {
1065
+ grid-template-columns: 1fr !important;
1066
+ }
1067
+
1068
+ #tab-results .grid-cols-3 {
1069
+ grid-template-columns: 1fr !important;
1070
+ }
1071
+
1072
+ /* --- Reports grid --- */
1073
+ #reports-grid,
1074
+ #reports-pending {
1075
+ grid-template-columns: 1fr !important;
1076
+ }
1077
+
1078
+ /* --- About tab grid --- */
1079
+ #tab-about .grid.grid-cols-3 {
1080
+ grid-template-columns: 1fr !important;
1081
+ }
1082
+
1083
+ /* --- Post-process cards --- */
1084
+ #post-process-cards {
1085
+ grid-template-columns: 1fr !important;
1086
+ }
1087
+
1088
+ /* --- Insights panel --- */
1089
+ #insights-panel .grid {
1090
+ grid-template-columns: 1fr !important;
1091
+ }
1092
+
1093
+ /* --- Feedback tab --- */
1094
+ #tab-feedback .grid {
1095
+ grid-template-columns: 1fr !important;
1096
+ }
1097
+
1098
+ /* --- About tab cards --- */
1099
+ #tab-about .bg-black.border.rounded-xl {
1100
+ padding: 20px !important;
1101
+ }
1102
+
1103
+ /* --- Stepper — ensure full tap area --- */
1104
+ .s-stepper button {
1105
+ padding: 10px 14px;
1106
+ min-width: 40px;
1107
+ min-height: 40px;
1108
+ }
1109
+
1110
+ /* --- s-row label text — allow wrap --- */
1111
+ .s-row>div:first-child .text-xs {
1112
+ font-size: 11px;
1113
+ }
1114
+
1115
+ /* --- Help accordion buttons --- */
1116
+ #tab-help button.w-full {
1117
+ min-height: 52px;
1118
+ padding: 14px 16px !important;
1119
+ }
1120
+
1121
+ /* --- Feedback priority chips grid --- */
1122
+ #fb-priorities {
1123
+ grid-template-columns: 1fr !important;
1124
+ }
1125
+
1126
+ /* --- Keyboard shortcut modal --- */
1127
+ #appModal-shortcutsModal>div {
1128
+ max-width: 95% !important;
1129
+ padding: 20px !important;
1130
+ }
1131
+
1132
+ /* --- Privacy / Terms modals --- */
1133
+ [id^="appModal-"]>div {
1134
+ max-width: 95% !important;
1135
+ max-height: 80dvh !important;
1136
+ overflow-y: auto !important;
1137
+ }
1138
+
1139
+ #tab-overview>div:last-child {
1140
+ min-height: 300px !important;
1141
+ padding-bottom: 4px !important;
1142
+ margin-bottom: 0 !important;
1143
+ }
1144
+
1145
+ /* --- Vehicle Classification Internal Scroll --- */
1146
+ #tab-overview>div:nth-child(4) {
1147
+ max-height: 380px !important;
1148
+ display: flex !important;
1149
+ flex-direction: column !important;
1150
+ }
1151
+
1152
+ #tab-overview>div:nth-child(4) #class-breakdown {
1153
+ flex: 1 !important;
1154
+ overflow-y: auto !important;
1155
+ min-height: 0 !important;
1156
+ }
1157
+ }
1158
+
1159
+ /* =============================================
1160
+ BOTTOM NAVIGATION BAR — mobile only
1161
+ ============================================= */
1162
+ .mobile-bottom-nav {
1163
+ display: none;
1164
+ /* hidden by default, shown on mobile */
1165
+ position: fixed;
1166
+ bottom: 0;
1167
+ left: 0;
1168
+ right: 0;
1169
+ height: calc(68px + env(safe-area-inset-bottom));
1170
+ padding-bottom: env(safe-area-inset-bottom);
1171
+ background: #000000;
1172
+ border-top: 1px solid #1a1a1a;
1173
+ z-index: 40;
1174
+ align-items: stretch;
1175
+ }
1176
+
1177
+ .mob-nav-item {
1178
+ flex: 1;
1179
+ display: flex;
1180
+ flex-direction: column;
1181
+ align-items: center;
1182
+ justify-content: center;
1183
+ gap: 3px;
1184
+ cursor: pointer;
1185
+ color: #444444;
1186
+ font-size: 0;
1187
+ font-weight: 700;
1188
+ text-transform: uppercase;
1189
+ letter-spacing: 0.05em;
1190
+ transition: color 0.15s ease;
1191
+ border: none;
1192
+ background: none;
1193
+ padding: 8px 2px;
1194
+ -webkit-tap-highlight-color: transparent;
1195
+ }
1196
+
1197
+ .mob-nav-item i {
1198
+ font-size: 22px;
1199
+ transition: color 0.15s ease;
1200
+ }
1201
+
1202
+ .mob-nav-item.active {
1203
+ color: var(--cocoa-l);
1204
+ }
1205
+
1206
+ .mob-nav-item.active i {
1207
+ color: var(--cocoa-l);
1208
+ }
1209
+
1210
+ .mob-nav-item:active {
1211
+ color: var(--cocoa-xl);
1212
+ }
1213
+
1214
+ /* Show bottom nav only on mobile */
1215
+ @media (max-width: 1023px) {
1216
+ .mobile-bottom-nav {
1217
+ display: flex !important;
1218
+ }
1219
+ }
1220
+
1221
+ /* =============================================
1222
+ MEDIUM TABLET (640px–1023px) adjustments
1223
+ ============================================= */
1224
+ @media (min-width: 640px) and (max-width: 1023px) {
1225
+
1226
+ /* 2-column grids on tablet where it fits */
1227
+ #tab-overview>div {
1228
+ min-height: 280px;
1229
+ }
1230
+
1231
+ #reports-grid,
1232
+ #reports-pending {
1233
+ grid-template-columns: repeat(2, 1fr) !important;
1234
+ }
1235
+
1236
+ #fb-priorities {
1237
+ grid-template-columns: repeat(2, 1fr) !important;
1238
+ }
1239
+
1240
+ #tab-about .grid.grid-cols-3 {
1241
+ grid-template-columns: repeat(2, 1fr) !important;
1242
+ }
1243
+ }
1244
+
1245
+ /* =============================================
1246
+ TOUCH DEVICES — remove hover jank
1247
+ ============================================= */
1248
+ @media (hover: none) and (pointer: coarse) {
1249
+ .nav-item-inactive:hover {
1250
+ color: #555555 !important;
1251
+ background-color: transparent !important;
1252
+ }
1253
+
1254
+ .chip:hover {
1255
+ border-color: #333333;
1256
+ }
1257
+
1258
+ .chip.active:hover {
1259
+ background: var(--cocoa-l);
1260
+ }
1261
+
1262
+ .s-stepper button:hover {
1263
+ background: transparent;
1264
+ color: #666666;
1265
+ }
1266
+
1267
+ /* Make all interactive elements minimum 44px tall */
1268
+ button,
1269
+ .fb-emoji-btn,
1270
+ .mob-nav-item {
1271
+ min-height: 44px;
1272
+ }
1273
+ }
1274
+
1275
+ /* ============================================================
1276
+ Custom Select Dropdown (uf-select)
1277
+ Replaces native <select> to prevent OS picker sheet on mobile
1278
+ ============================================================ */
1279
+ .uf-select-wrap {
1280
+ position: relative;
1281
+ display: inline-block;
1282
+ min-width: 110px;
1283
+ }
1284
+
1285
+ .uf-select-wrap.w-full {
1286
+ display: block;
1287
+ width: 100%;
1288
+ }
1289
+
1290
+ .uf-select-trigger {
1291
+ display: flex;
1292
+ align-items: center;
1293
+ justify-content: space-between;
1294
+ gap: 6px;
1295
+ padding: 5px 10px;
1296
+ background: #111111;
1297
+ border: 1px solid #222222;
1298
+ border-radius: 6px;
1299
+ font-size: 11px;
1300
+ font-weight: 600;
1301
+ color: #ffffff;
1302
+ cursor: pointer;
1303
+ user-select: none;
1304
+ -webkit-tap-highlight-color: transparent;
1305
+ transition: border-color 0.15s;
1306
+ white-space: nowrap;
1307
+ }
1308
+
1309
+ .uf-select-trigger:hover,
1310
+ .uf-select-trigger:active {
1311
+ border-color: #444444;
1312
+ }
1313
+
1314
+ .uf-select-arrow {
1315
+ font-size: 9px;
1316
+ color: #666666;
1317
+ transition: transform 0.2s ease;
1318
+ flex-shrink: 0;
1319
+ }
1320
+
1321
+ .uf-select-arrow-open {
1322
+ transform: rotate(180deg);
1323
+ }
1324
+
1325
+ /* Dropdown panel — opens downward by default */
1326
+ .uf-select-dropdown {
1327
+ position: absolute;
1328
+ top: calc(100% + 4px);
1329
+ left: 0;
1330
+ min-width: 100%;
1331
+ background: #111111;
1332
+ border: 1px solid #2a2a2a;
1333
+ border-radius: 8px;
1334
+ z-index: 9999;
1335
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.8);
1336
+ overflow: hidden;
1337
+ max-height: 240px;
1338
+ overflow-y: auto;
1339
+ }
1340
+
1341
+ /* Upward variant — anchors above trigger, for bottom-of-screen selects */
1342
+ .uf-select-dropdown-up {
1343
+ top: auto;
1344
+ bottom: calc(100% + 4px);
1345
+ }
1346
+
1347
+ .uf-select-option {
1348
+ padding: 10px 14px;
1349
+ font-size: 11px;
1350
+ font-weight: 600;
1351
+ color: #aaaaaa;
1352
+ cursor: pointer;
1353
+ transition: background 0.1s, color 0.1s;
1354
+ -webkit-tap-highlight-color: transparent;
1355
+ }
1356
+
1357
+ .uf-select-option:hover,
1358
+ .uf-select-option:active {
1359
+ background: #1a1a1a;
1360
+ color: #ffffff;
1361
+ }
1362
+
1363
+ .uf-select-option-active {
1364
+ color: var(--cocoa-l);
1365
+ background: #0a0a0a;
1366
+ }
1367
+
1368
+ /* Hide scrollbar inside dropdown — options fit within max-height */
1369
+ .uf-select-dropdown::-webkit-scrollbar {
1370
+ width: 0;
1371
+ height: 0;
1372
+ }
1373
+
1374
+ /* Desktop: Vehicle Classification thin grey scrollbar (matches reference) */
1375
+ @media (min-width: 1024px) {
1376
+ #class-breakdown::-webkit-scrollbar {
1377
+ width: 4px;
1378
+ }
1379
+
1380
+ #class-breakdown::-webkit-scrollbar-track {
1381
+ background: #000000;
1382
+ }
1383
+
1384
+ #class-breakdown::-webkit-scrollbar-thumb {
1385
+ background: #333333;
1386
+ border-radius: 4px;
1387
+ }
1388
+
1389
+ #class-breakdown::-webkit-scrollbar-thumb:hover {
1390
+ background: #444444;
1391
+ }
1392
+ }
1393
+
1394
+ /* ---- Profile & Sidebar PFP ---- */
1395
+ #sidebar-profile-pfp-wrap img,
1396
+ #mob-pfp-wrap img {
1397
+ width: 100%;
1398
+ height: 100%;
1399
+ object-fit: cover;
1400
+ border-radius: 50%;
1401
+ }
1402
+
1403
+ /* Fix fallback icon visibility (parent font-size is 0) */
1404
+ #sidebar-profile-pfp-wrap i,
1405
+ #mob-pfp-wrap i {
1406
+ font-size: 1.2rem;
1407
+ color: #555;
1408
+ }
1409
+
1410
+ .mob-nav-item i {
1411
+ transition: transform 0.2s ease;
1412
+ }
1413
+
1414
+ .mob-nav-item:active i {
1415
+ transform: scale(0.9);
1416
+ }
1417
+
1418
+ /* ---- Legal Menu Dropdown ---- */
1419
+ #legal-menu, #legal-menu-profile {
1420
+ animation: menuFadeIn 0.2s ease-out forwards;
1421
+ transform-origin: top right;
1422
+ box-shadow: 0 10px 40px rgba(0,0,0,0.8);
1423
+ }
1424
+
1425
+ @keyframes menuFadeIn {
1426
+ from { opacity: 0; transform: translateY(-10px) scale(0.95); }
1427
+ to { opacity: 1; transform: translateY(0) scale(1); }
1428
+ }
1429
+
1430
+ /* ---- Profile Tab specific overrides ---- */
1431
+ #tab-profile input[type="text"] {
1432
+ border: 1px solid #222 !important;
1433
+ }
1434
+
1435
+ #tab-profile input[type="text"]:focus {
1436
+ border-color: var(--cocoa-l) !important;
1437
+ background: #000 !important;
1438
+ }
1439
+
1440
+ @media (max-width: 1023px) {
1441
+ /* Zero tab side padding on mobile */
1442
+ #tab-results,
1443
+ #tab-profile,
1444
+ #tab-overview {
1445
+ padding-left: 0 !important;
1446
+ padding-right: 0 !important;
1447
+ padding-bottom: calc(var(--mob-nav-h) + 12px) !important;
1448
+ }
1449
+
1450
+ /* Results content wrap — tighter spacing */
1451
+ #results-content-wrap {
1452
+ gap: 16px !important;
1453
+ }
1454
+
1455
+ /* Telemetry cards inner padding */
1456
+ #run-results-card > div.p-8 {
1457
+ padding: 12px !important;
1458
+ }
1459
+ #run-results-content {
1460
+ gap: 12px !important;
1461
+ }
1462
+
1463
+ /* Technical context grid — stack on mobile */
1464
+ #tab-results .grid.grid-cols-2 {
1465
+ grid-template-columns: 1fr !important;
1466
+ gap: 12px !important;
1467
+ }
1468
+
1469
+ /* Panel inner padding */
1470
+ #panel-video,
1471
+ #panel-perf,
1472
+ #panel-model,
1473
+ #panel-infer {
1474
+ padding: 12px !important;
1475
+ }
1476
+
1477
+ /* Telemetry section — reduce top margin */
1478
+ #tab-results details .mt-8 {
1479
+ margin-top: 16px !important;
1480
+ }
1481
+ #tab-results .mt-12 {
1482
+ margin-top: 24px !important;
1483
+ padding-top: 24px !important;
1484
+ }
1485
+
1486
+ /* Profile card — tighter mobile padding */
1487
+ #tab-profile .bg-neutral-950 {
1488
+ border-radius: 12px !important;
1489
+ }
1490
+ #tab-profile .p-6 {
1491
+ padding: 16px !important;
1492
+ }
1493
+ #tab-profile .p-5 {
1494
+ padding: 14px !important;
1495
+ }
1496
+
1497
+ /* Chart cards — reduce padding */
1498
+ #tab-results .bg-black.rounded-xl {
1499
+ border-radius: 10px !important;
1500
+ }
1501
+ #tab-results .bg-neutral-950.rounded-xl {
1502
+ border-radius: 10px !important;
1503
+ }
1504
+ }
1505
+
1506
+ /* Ensure mobile results scrolling */
1507
+ #tab-results:not(.hidden) {
1508
+ display: flex !important;
1509
+ flex-direction: column !important;
1510
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/initial.html CHANGED
@@ -11,7 +11,8 @@
11
  }
12
  </script>
13
  <meta charset="UTF-8">
14
- <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
 
15
  <title>UrbanFlow</title>
16
  <link rel="icon" type="image/png" sizes="512x512" href="assets/shuriken.png">
17
  <link rel="manifest" href="manifest.json">
@@ -175,10 +176,6 @@
175
  });
176
  }
177
  }
178
-
179
- document.addEventListener('DOMContentLoaded', function() {
180
- injectLegalModals();
181
- });
182
  </script>
183
 
184
 
 
11
  }
12
  </script>
13
  <meta charset="UTF-8">
14
+ <!-- viewport-fit=cover for notched devices; zoom left enabled (WCAG 1.4.4) -->
15
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
16
  <title>UrbanFlow</title>
17
  <link rel="icon" type="image/png" sizes="512x512" href="assets/shuriken.png">
18
  <link rel="manifest" href="manifest.json">
 
176
  });
177
  }
178
  }
 
 
 
 
179
  </script>
180
 
181
 
frontend/js/auth.js CHANGED
@@ -262,6 +262,9 @@ function _submitOnboarding() {
262
  })
263
  .then(() => {
264
  user.username = username;
 
 
 
265
  saveAuthSession(user);
266
  _hideAuthOverlay();
267
  if (overlay._onboardCallback) overlay._onboardCallback(user);
@@ -297,15 +300,7 @@ function executeLogout() {
297
  clearAuthSession();
298
  sessionStorage.clear();
299
  hideLogoutConfirm();
300
- if (typeof showOnboardingPhase === 'function') {
301
- showOnboardingPhase();
302
- if (typeof initApp === 'function') {
303
- const sp = document.getElementById('sidebar-profile');
304
- if (sp) sp.style.display = 'none';
305
- }
306
- } else {
307
- window.location.replace('/');
308
- }
309
  }
310
 
311
  // ---- Consent Modal ----
 
262
  })
263
  .then(() => {
264
  user.username = username;
265
+ // Clear the flag: downstream handlers re-check it and would show
266
+ // this same form again once consent completes.
267
+ user.new_user = false;
268
  saveAuthSession(user);
269
  _hideAuthOverlay();
270
  if (overlay._onboardCallback) overlay._onboardCallback(user);
 
300
  clearAuthSession();
301
  sessionStorage.clear();
302
  hideLogoutConfirm();
303
+ window.location.replace('/');
 
 
 
 
 
 
 
 
304
  }
305
 
306
  // ---- Consent Modal ----
frontend/js/initial.js CHANGED
@@ -15,6 +15,10 @@ function showStep(name) {
15
  const target = document.getElementById('step-' + name);
16
  if (target) target.classList.remove('hidden');
17
 
 
 
 
 
18
  if (name === 'upload') {
19
  document.getElementById('upload-progress-container').classList.add('hidden');
20
  document.getElementById('dropzone').classList.remove('hidden');
@@ -23,7 +27,6 @@ function showStep(name) {
23
  document.getElementById('upload-text').innerText = 'Uploading...';
24
  document.getElementById('upload-text').classList.remove('text-red-500');
25
  }
26
- if (name === 'draw') loadFirstFrame();
27
  }
28
 
29
  // ---- File input / dropzone ----
@@ -55,6 +58,8 @@ if (dropzone) {
55
  // ---- Upload ----
56
  let currentXHR = null;
57
 
 
 
58
  function uploadFile(file) {
59
  if (currentXHR) currentXHR.abort();
60
 
@@ -64,31 +69,56 @@ function uploadFile(file) {
64
  const pct = document.getElementById('upload-percentage');
65
  const txt = document.getElementById('upload-text');
66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  if (dropzoneEl) dropzoneEl.classList.add('hidden');
68
  if (prog) prog.classList.remove('hidden');
69
 
70
  // ---- Simulated progress (proxy-buffer-safe) ----
71
- // Estimate upload duration: ~1 MB/s conservative, capped between 3s and 60s
72
- const fileMB = file.size / (1024 * 1024);
 
 
 
73
  const estDurationMs = Math.min(Math.max(fileMB * 1000, 3000), 60000);
74
- const targetPct = 98; // stop simulation at 98%, snap to 100% on load
75
- const tickMs = 200; // update every 200ms
76
- const totalTicks = estDurationMs / tickMs;
77
- const stepPerTick = targetPct / totalTicks;
78
 
79
  let simPct = 0;
80
- let simInterval = setInterval(() => {
 
 
 
 
 
 
81
  if (simPct < targetPct) {
82
- simPct = Math.min(simPct + stepPerTick, targetPct);
83
- bar.style.width = simPct.toFixed(1) + '%';
84
- pct.innerText = Math.floor(simPct) + '%';
 
 
85
  }
86
  }, tickMs);
87
 
88
  const form = new FormData();
89
  form.append('file', file);
90
-
91
- fetch('/api/event', {
92
  method: 'POST',
93
  headers: { 'Content-Type': 'application/json' },
94
  body: JSON.stringify({ event: 'UPLOAD_STARTED', meta: { size: fileMB.toFixed(2) } })
@@ -97,69 +127,62 @@ function uploadFile(file) {
97
  const xhr = new XMLHttpRequest();
98
  currentXHR = xhr;
99
  xhr.open('POST', 'upload');
 
100
 
101
- // Real progress override — fires if proxy reports actual bytes (rare but handle it)
102
  xhr.upload.onprogress = e => {
103
- if (e.lengthComputable) {
104
- const realPct = Math.round(e.loaded / e.total * 100);
105
- // Only override simulation if real progress is AHEAD of it
106
- if (realPct > simPct) {
107
- simPct = realPct;
108
- bar.style.width = simPct + '%';
109
- pct.innerText = simPct + '%';
110
- }
111
- }
112
  };
113
 
114
- xhr.onerror = () => {
115
- clearInterval(simInterval);
116
- txt.innerText = 'Error: Network failure';
117
- txt.classList.add('text-red-500');
118
- if (fileInput) fileInput.value = '';
119
- };
120
 
121
  xhr.onload = () => {
122
  clearInterval(simInterval);
123
 
124
- if (xhr.status !== 200) {
125
- txt.innerText = 'Error: ' + xhr.status;
126
- txt.classList.add('text-red-500');
127
- if (fileInput) fileInput.value = '';
 
 
128
  return;
129
  }
130
 
131
- // Snap to 95% on successful upload response
132
- bar.style.width = '95%';
133
- pct.innerText = '95%';
134
-
135
- const res = JSON.parse(xhr.responseText);
136
  videoId = res.video_id;
137
- txt.innerText = 'Extracting Metadata...';
 
138
 
139
  fetch('config/' + videoId)
140
- .then(r => r.json())
141
  .then(cfg => {
142
- bar.style.width = '100%';
143
- pct.innerText = '100%';
144
  runConfig = cfg;
145
  runConfig.conf = 0.12;
146
  runConfig.iou = 0.60;
147
- txt.innerText = 'Initialization Complete';
148
-
149
- fetch('/api/event', {
150
  method: 'POST',
151
  headers: { 'Content-Type': 'application/json' },
152
  body: JSON.stringify({ event: 'UPLOAD_SUCCESS', meta: { video_id: videoId } })
153
  }).catch(()=>{});
154
-
155
  if (fileInput) fileInput.value = '';
156
- setTimeout(() => showStep('draw'), 800);
 
 
 
157
  })
158
- .catch(() => {
159
- txt.innerText = 'Metadata Failed';
160
- txt.classList.add('text-red-500');
161
- if (fileInput) fileInput.value = '';
162
- });
 
 
 
 
163
  };
164
 
165
  xhr.send(form);
@@ -174,31 +197,41 @@ const ctx = canvas ? canvas.getContext('2d') : null;
174
  let points = [];
175
  let imgNatW = 0, imgNatH = 0;
176
 
 
 
177
  function loadFirstFrame() {
178
- const img = document.getElementById('frame-img');
179
- const placeholder = document.getElementById('frame-placeholder');
180
-
181
- img.onerror = () => {
182
- console.error('Failed to load first frame');
183
- if (placeholder) {
184
- placeholder.innerHTML =
185
- '<i class="fa-solid fa-circle-exclamation text-4xl mb-3 opacity-50" style="color:#c89a6c"></i>' +
186
- '<span class="font-bold text-[10px] uppercase tracking-widest opacity-50 block mt-2">Frame Load Error</span>';
187
- }
188
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
 
190
- img.src = 'first-frame/' + videoId;
191
- img.onload = () => {
192
- imgNatW = img.naturalWidth;
193
- imgNatH = img.naturalHeight;
194
- img.style.display = 'block';
195
- if (placeholder) placeholder.style.display = 'none';
196
- initCanvas();
197
- };
198
  }
199
 
200
  function initCanvas() {
201
- if (!canvas) return;
202
  canvas.width = canvas.offsetWidth;
203
  canvas.height = canvas.offsetHeight;
204
  // Redraw existing points after resize
@@ -208,13 +241,41 @@ function initCanvas() {
208
  window.addEventListener('resize', initCanvas);
209
 
210
  // ---- Coordinate helpers ----
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
  function getCanvasCoords(clientX, clientY) {
212
  const rect = canvas.getBoundingClientRect();
213
- const cx = clientX - rect.left;
214
- const cy = clientY - rect.top;
215
- const rx = (cx / canvas.width) * imgNatW;
216
- const ry = (cy / canvas.height) * imgNatH;
217
- return { cx, cy, rx: Math.round(rx), ry: Math.round(ry) };
 
 
 
 
 
 
 
 
 
 
 
 
 
218
  }
219
 
220
  function addPoint(coords) {
@@ -229,8 +290,9 @@ function addPoint(coords) {
229
  function redrawCanvas() {
230
  if (!ctx) return;
231
  ctx.clearRect(0, 0, canvas.width, canvas.height);
232
- points.forEach(p => drawDot(p.cx, p.cy));
233
- if (points.length === 2) drawLine();
 
234
  }
235
 
236
  // ---- Mouse events (desktop) ----
@@ -268,11 +330,11 @@ function drawDot(x, y) {
268
  ctx.stroke();
269
  }
270
 
271
- function drawLine() {
272
- if (!ctx || points.length < 2) return;
273
  ctx.beginPath();
274
- ctx.moveTo(points[0].cx, points[0].cy);
275
- ctx.lineTo(points[1].cx, points[1].cy);
276
  ctx.strokeStyle = '#c89a6c';
277
  ctx.lineWidth = 3;
278
  ctx.stroke();
@@ -286,6 +348,12 @@ function resetCanvas() {
286
 
287
  function startRun() {
288
  if (points.length < 2) return;
 
 
 
 
 
 
289
  const line = [[points[0].rx, points[0].ry], [points[1].rx, points[1].ry]];
290
  sessionStorage.setItem('funky_run', JSON.stringify({
291
  video_id: videoId,
@@ -294,19 +362,13 @@ function startRun() {
294
  }));
295
  sessionStorage.setItem('uf_active_tab', 'settings');
296
 
297
- fetch('/api/event', {
298
  method: 'POST',
299
  headers: { 'Content-Type': 'application/json' },
300
  body: JSON.stringify({ event: 'DRAW_LINE_COMPLETED', meta: { video_id: videoId } })
301
  }).catch(()=>{});
302
-
303
- // SPA Navigation
304
- if (typeof showDashboard === 'function') {
305
- showDashboard();
306
- if (typeof initApp === 'function') initApp();
307
- } else {
308
- window.location.replace('/vehicles');
309
- }
310
  }
311
 
312
  // =============================================
 
15
  const target = document.getElementById('step-' + name);
16
  if (target) target.classList.remove('hidden');
17
 
18
+ // The canvas has no measurable size while its step is hidden, so it can
19
+ // only be sized once the step is actually on screen.
20
+ if (name === 'draw') initCanvas();
21
+
22
  if (name === 'upload') {
23
  document.getElementById('upload-progress-container').classList.add('hidden');
24
  document.getElementById('dropzone').classList.remove('hidden');
 
27
  document.getElementById('upload-text').innerText = 'Uploading...';
28
  document.getElementById('upload-text').classList.remove('text-red-500');
29
  }
 
30
  }
31
 
32
  // ---- File input / dropzone ----
 
58
  // ---- Upload ----
59
  let currentXHR = null;
60
 
61
+ const MAX_UPLOAD_BYTES = 500 * 1024 * 1024;
62
+
63
  function uploadFile(file) {
64
  if (currentXHR) currentXHR.abort();
65
 
 
69
  const pct = document.getElementById('upload-percentage');
70
  const txt = document.getElementById('upload-text');
71
 
72
+ const fail = (msg) => {
73
+ txt.innerText = msg;
74
+ txt.classList.add('text-red-500');
75
+ if (fileInput) fileInput.value = '';
76
+ // Give the dropzone back so the user can retry without a page reload.
77
+ setTimeout(() => { if (!videoId) showStep('upload'); }, 2600);
78
+ };
79
+
80
+ if (file.size > MAX_UPLOAD_BYTES) {
81
+ if (dropzoneEl) dropzoneEl.classList.add('hidden');
82
+ if (prog) prog.classList.remove('hidden');
83
+ fail('File too large — 500MB maximum');
84
+ return;
85
+ }
86
+
87
  if (dropzoneEl) dropzoneEl.classList.add('hidden');
88
  if (prog) prog.classList.remove('hidden');
89
 
90
  // ---- Simulated progress (proxy-buffer-safe) ----
91
+ // The HF proxy buffers the body, so upload.onprogress usually reports 100%
92
+ // long before the server has actually stored the file. Simulate up to 90%,
93
+ // then hand the remaining band to the real post-upload stages so the bar
94
+ // only ever moves forward.
95
+ const fileMB = file.size / (1024 * 1024);
96
  const estDurationMs = Math.min(Math.max(fileMB * 1000, 3000), 60000);
97
+ const targetPct = 90;
98
+ const tickMs = 200;
99
+ const stepPerTick = targetPct / (estDurationMs / tickMs);
 
100
 
101
  let simPct = 0;
102
+ const setPct = (v) => {
103
+ simPct = Math.max(simPct, v); // never move backwards
104
+ bar.style.width = simPct.toFixed(1) + '%';
105
+ pct.innerText = Math.floor(simPct) + '%';
106
+ };
107
+
108
+ const simInterval = setInterval(() => {
109
  if (simPct < targetPct) {
110
+ setPct(simPct + stepPerTick);
111
+ } else {
112
+ // Plateau reached: the bytes are sent, the server is still working.
113
+ // Say so instead of leaving a frozen bar with no explanation.
114
+ txt.innerText = 'Processing on server...';
115
  }
116
  }, tickMs);
117
 
118
  const form = new FormData();
119
  form.append('file', file);
120
+
121
+ fetch('api/event', {
122
  method: 'POST',
123
  headers: { 'Content-Type': 'application/json' },
124
  body: JSON.stringify({ event: 'UPLOAD_STARTED', meta: { size: fileMB.toFixed(2) } })
 
127
  const xhr = new XMLHttpRequest();
128
  currentXHR = xhr;
129
  xhr.open('POST', 'upload');
130
+ xhr.timeout = 10 * 60 * 1000; // large files on a cold Space are slow
131
 
 
132
  xhr.upload.onprogress = e => {
133
+ if (e.lengthComputable) setPct(Math.min(e.loaded / e.total * targetPct, targetPct));
 
 
 
 
 
 
 
 
134
  };
135
 
136
+ xhr.onerror = () => { clearInterval(simInterval); fail('Network error — check your connection and retry'); };
137
+ xhr.ontimeout = () => { clearInterval(simInterval); fail('Upload timed out — try a shorter clip'); };
138
+ xhr.onabort = () => { clearInterval(simInterval); };
 
 
 
139
 
140
  xhr.onload = () => {
141
  clearInterval(simInterval);
142
 
143
+ let res = null;
144
+ try { res = JSON.parse(xhr.responseText); } catch (e) { /* non-JSON error page */ }
145
+
146
+ if (xhr.status !== 200 || !res || !res.video_id) {
147
+ // Surface the server's own message (rate limit, too large, bad type).
148
+ fail((res && res.error) || `Upload failed (${xhr.status || 'no response'})`);
149
  return;
150
  }
151
 
 
 
 
 
 
152
  videoId = res.video_id;
153
+ setPct(94);
154
+ txt.innerText = 'Extracting metadata...';
155
 
156
  fetch('config/' + videoId)
157
+ .then(r => r.ok ? r.json() : Promise.reject(new Error('config')))
158
  .then(cfg => {
159
+ setPct(97);
 
160
  runConfig = cfg;
161
  runConfig.conf = 0.12;
162
  runConfig.iou = 0.60;
163
+ txt.innerText = 'Loading preview frame...';
164
+
165
+ fetch('api/event', {
166
  method: 'POST',
167
  headers: { 'Content-Type': 'application/json' },
168
  body: JSON.stringify({ event: 'UPLOAD_SUCCESS', meta: { video_id: videoId } })
169
  }).catch(()=>{});
170
+
171
  if (fileInput) fileInput.value = '';
172
+
173
+ // Only hit 100% and switch screens once the frame is decoded and
174
+ // on screen — otherwise the user gets a blank step after "100%".
175
+ return loadFirstFrame();
176
  })
177
+ .then(ok => {
178
+ // Without a decoded frame there is nothing to draw the counting
179
+ // line on, and taps would map to meaningless coordinates.
180
+ if (!ok) { videoId = null; fail('Could not read this video — try another file'); return; }
181
+ setPct(100);
182
+ txt.innerText = 'Ready';
183
+ setTimeout(() => showStep('draw'), 400);
184
+ })
185
+ .catch(() => { videoId = null; fail('Could not read this video — try another file'); });
186
  };
187
 
188
  xhr.send(form);
 
197
  let points = [];
198
  let imgNatW = 0, imgNatH = 0;
199
 
200
+ // Resolves true once the frame is decoded and painted, false if it failed.
201
+ // Never rejects — a missing preview should not abort the whole flow.
202
  function loadFirstFrame() {
203
+ return new Promise(resolve => {
204
+ const img = document.getElementById('frame-img');
205
+ const placeholder = document.getElementById('frame-placeholder');
206
+ if (!img) { resolve(false); return; }
207
+
208
+ img.onload = () => {
209
+ imgNatW = img.naturalWidth;
210
+ imgNatH = img.naturalHeight;
211
+ img.style.display = 'block';
212
+ if (placeholder) placeholder.style.display = 'none';
213
+ initCanvas();
214
+ resolve(true);
215
+ };
216
+
217
+ img.onerror = () => {
218
+ console.error('Failed to load first frame');
219
+ img.style.display = 'none';
220
+ if (placeholder) {
221
+ placeholder.style.display = 'flex';
222
+ placeholder.innerHTML =
223
+ '<i class="fa-solid fa-circle-exclamation text-4xl mb-3 opacity-50" style="color:#c89a6c"></i>' +
224
+ '<span class="font-bold text-[10px] uppercase tracking-widest opacity-50 block mt-2">Frame Load Error</span>';
225
+ }
226
+ resolve(false);
227
+ };
228
 
229
+ img.src = 'first-frame/' + videoId;
230
+ });
 
 
 
 
 
 
231
  }
232
 
233
  function initCanvas() {
234
+ if (!canvas || !canvas.offsetWidth) return; // hidden step has no size yet
235
  canvas.width = canvas.offsetWidth;
236
  canvas.height = canvas.offsetHeight;
237
  // Redraw existing points after resize
 
241
  window.addEventListener('resize', initCanvas);
242
 
243
  // ---- Coordinate helpers ----
244
+ // The frame uses object-contain, so it is letterboxed whenever the canvas
245
+ // aspect ratio differs from the video's (it always does on mobile, where CSS
246
+ // forces 4/3). Mapping canvas pixels straight to image pixels therefore put
247
+ // the counting line in the wrong place. Everything below goes through the
248
+ // displayed image rect instead.
249
+ function getImageRect() {
250
+ if (!imgNatW || !imgNatH) {
251
+ return { x: 0, y: 0, w: canvas.width, h: canvas.height };
252
+ }
253
+ const scale = Math.min(canvas.width / imgNatW, canvas.height / imgNatH);
254
+ const w = imgNatW * scale;
255
+ const h = imgNatH * scale;
256
+ return { x: (canvas.width - w) / 2, y: (canvas.height - h) / 2, w, h };
257
+ }
258
+
259
  function getCanvasCoords(clientX, clientY) {
260
  const rect = canvas.getBoundingClientRect();
261
+ const box = getImageRect();
262
+ // Clamp into the visible frame so taps on the letterbox bars still land.
263
+ const cx = Math.min(Math.max(clientX - rect.left, box.x), box.x + box.w);
264
+ const cy = Math.min(Math.max(clientY - rect.top, box.y), box.y + box.h);
265
+ return {
266
+ rx: Math.round((cx - box.x) / box.w * imgNatW),
267
+ ry: Math.round((cy - box.y) / box.h * imgNatH),
268
+ };
269
+ }
270
+
271
+ // Image coords -> canvas coords, recomputed on every draw so the line stays
272
+ // correct after a resize or an orientation change.
273
+ function toCanvasPoint(p) {
274
+ const box = getImageRect();
275
+ return {
276
+ x: box.x + (p.rx / (imgNatW || 1)) * box.w,
277
+ y: box.y + (p.ry / (imgNatH || 1)) * box.h,
278
+ };
279
  }
280
 
281
  function addPoint(coords) {
 
290
  function redrawCanvas() {
291
  if (!ctx) return;
292
  ctx.clearRect(0, 0, canvas.width, canvas.height);
293
+ const pts = points.map(toCanvasPoint);
294
+ pts.forEach(p => drawDot(p.x, p.y));
295
+ if (pts.length === 2) drawLine(pts);
296
  }
297
 
298
  // ---- Mouse events (desktop) ----
 
330
  ctx.stroke();
331
  }
332
 
333
+ function drawLine(pts) {
334
+ if (!ctx || !pts || pts.length < 2) return;
335
  ctx.beginPath();
336
+ ctx.moveTo(pts[0].x, pts[0].y);
337
+ ctx.lineTo(pts[1].x, pts[1].y);
338
  ctx.strokeStyle = '#c89a6c';
339
  ctx.lineWidth = 3;
340
  ctx.stroke();
 
348
 
349
  function startRun() {
350
  if (points.length < 2) return;
351
+ // Two points in the same spot define no line — the backend would compute a
352
+ // zero-length segment and silently count nothing.
353
+ if (points[0].rx === points[1].rx && points[0].ry === points[1].ry) {
354
+ resetCanvas();
355
+ return;
356
+ }
357
  const line = [[points[0].rx, points[0].ry], [points[1].rx, points[1].ry]];
358
  sessionStorage.setItem('funky_run', JSON.stringify({
359
  video_id: videoId,
 
362
  }));
363
  sessionStorage.setItem('uf_active_tab', 'settings');
364
 
365
+ fetch('api/event', {
366
  method: 'POST',
367
  headers: { 'Content-Type': 'application/json' },
368
  body: JSON.stringify({ event: 'DRAW_LINE_COMPLETED', meta: { video_id: videoId } })
369
  }).catch(()=>{});
370
+
371
+ window.location.replace('/vehicles');
 
 
 
 
 
 
372
  }
373
 
374
  // =============================================
frontend/js/shared.js CHANGED
@@ -136,19 +136,17 @@ Email: <strong style="color:#f0c674">support.urbanflow365@gmail.com</strong></p>
136
  },
137
  };
138
 
 
 
 
139
  function injectLegalModals() {
 
 
140
  const container = document.createElement('div');
141
  container.id = 'legal-modals-container';
142
-
143
- // Privacy Modal
144
- const p = LEGAL_CONTENT.privacy;
145
- const privacyHTML = getPrivacyModalTemplate(p);
146
-
147
- // Terms Modal
148
- const t = LEGAL_CONTENT.terms;
149
- const termsHTML = getTermsModalTemplate(t);
150
-
151
- container.innerHTML = privacyHTML + termsHTML;
152
  document.body.appendChild(container);
153
  }
154
 
@@ -168,6 +166,8 @@ const SHORTCUTS = [
168
  ];
169
 
170
  function injectShortcutsModal() {
 
 
171
  const rows = SHORTCUTS.map(s =>
172
  `<div class="shortcut-row">
173
  <span class="shortcut-label">${s.label}</span>
@@ -182,21 +182,6 @@ function injectShortcutsModal() {
182
  document.body.appendChild(container.firstElementChild);
183
  }
184
 
185
- // =============================================
186
- // Mobile Legal Menu Toggle
187
- // =============================================
188
-
189
- function toggleLegalMenu(e) {
190
- if (e) e.stopPropagation();
191
- const menu = document.getElementById('legal-menu');
192
- if (menu) menu.classList.toggle('hidden');
193
- }
194
-
195
- document.addEventListener('click', function() {
196
- const menu = document.getElementById('legal-menu');
197
- if (menu) menu.classList.add('hidden');
198
- });
199
-
200
  // =============================================
201
  // Global Key Handler
202
  // =============================================
@@ -207,7 +192,6 @@ document.addEventListener('keydown', function(e) {
207
  closeAppModal('termsModal');
208
  closeAppModal('shortcutsModal');
209
  if (typeof hideLogoutConfirm === 'function') hideLogoutConfirm();
210
- if (typeof closeLandingProfileMenu === 'function') closeLandingProfileMenu();
211
  const legalMenu = document.getElementById('legal-menu');
212
  if (legalMenu) legalMenu.classList.add('hidden');
213
  }
@@ -219,42 +203,6 @@ document.addEventListener('keydown', function(e) {
219
 
220
  document.addEventListener('DOMContentLoaded', injectLegalModals);
221
 
222
- // =============================================
223
- // Navigation — Single Source of Truth
224
- // =============================================
225
-
226
- const NAV_ITEMS = [
227
- { id: 'about', icon: 'fa-circle-info', label: 'About' },
228
- { id: 'overview', icon: 'fa-desktop', label: 'Overview' },
229
- { id: 'results', icon: 'fa-file-lines', label: 'Results' },
230
- { id: 'settings', icon: 'fa-gear', label: 'Settings' },
231
- { id: 'help', icon: 'fa-circle-question', label: 'Guide' },
232
- { id: 'feedback', icon: 'fa-comment-dots', label: 'Feedback' },
233
- { id: 'profile', icon: 'fa-circle-user', label: 'Profile' },
234
- ];
235
-
236
- function injectNavigation() {
237
- // Sidebar nav (desktop)
238
- const sidebarNav = document.getElementById('sidebar-nav');
239
- if (sidebarNav) {
240
- sidebarNav.innerHTML = NAV_ITEMS.map(n =>
241
- `<a onclick="switchTab('${n.id}')" id="nav-${n.id}" class="flex items-center px-4 py-2.5 rounded-lg transition cursor-pointer nav-item-inactive">
242
- <i class="fa-solid ${n.icon} w-6"></i> <span class="font-medium">${n.label}</span>
243
- </a>`
244
- ).join('');
245
- }
246
-
247
- // Mobile bottom nav
248
- const bottomNav = document.getElementById('mobile-bottom-nav');
249
- if (bottomNav) {
250
- bottomNav.innerHTML = NAV_ITEMS.map(n =>
251
- `<button class="mob-nav-item" id="mob-nav-${n.id}" onclick="switchTab('${n.id}')">
252
- <i class="fa-solid ${n.icon}"></i>
253
- </button>`
254
- ).join('');
255
- }
256
- }
257
-
258
  // =============================================
259
  // Service Worker Registration
260
  // =============================================
 
136
  },
137
  };
138
 
139
+ // Idempotent: both pages call this directly *and* it runs on DOMContentLoaded,
140
+ // which used to inject two copies of every modal (duplicate element IDs, so
141
+ // closeAppModal only ever hit the first one).
142
  function injectLegalModals() {
143
+ if (document.getElementById('legal-modals-container')) return;
144
+
145
  const container = document.createElement('div');
146
  container.id = 'legal-modals-container';
147
+ container.innerHTML =
148
+ getPrivacyModalTemplate(LEGAL_CONTENT.privacy) +
149
+ getTermsModalTemplate(LEGAL_CONTENT.terms);
 
 
 
 
 
 
 
150
  document.body.appendChild(container);
151
  }
152
 
 
166
  ];
167
 
168
  function injectShortcutsModal() {
169
+ if (document.getElementById('appModal-shortcutsModal')) return;
170
+
171
  const rows = SHORTCUTS.map(s =>
172
  `<div class="shortcut-row">
173
  <span class="shortcut-label">${s.label}</span>
 
182
  document.body.appendChild(container.firstElementChild);
183
  }
184
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
  // =============================================
186
  // Global Key Handler
187
  // =============================================
 
192
  closeAppModal('termsModal');
193
  closeAppModal('shortcutsModal');
194
  if (typeof hideLogoutConfirm === 'function') hideLogoutConfirm();
 
195
  const legalMenu = document.getElementById('legal-menu');
196
  if (legalMenu) legalMenu.classList.add('hidden');
197
  }
 
203
 
204
  document.addEventListener('DOMContentLoaded', injectLegalModals);
205
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
  // =============================================
207
  // Service Worker Registration
208
  // =============================================
frontend/js/templates.js CHANGED
@@ -106,17 +106,26 @@ function getShortcutsModalTemplate(rows) {
106
  </div>`;
107
  };
108
 
 
 
 
 
 
 
 
 
109
  function getOnboardFormTemplate(user) {
 
110
  return `
111
  <div class="auth-card-header">
112
- <img src="${user.picture}" alt="" class="auth-avatar" referrerpolicy="no-referrer">
113
  <h2 class="auth-title">Welcome</h2>
114
  <p class="auth-subtitle">Choose a display name for your account</p>
115
  </div>
116
  <div class="auth-onboard-form">
117
  <label class="auth-label">Display Name</label>
118
  <input id="auth-username-input" type="text" class="auth-input" maxlength="30"
119
- placeholder="e.g. Aarav" value="${user.name.split(' ')[0] || ''}" autocomplete="off">
120
  <p id="auth-onboard-error" class="auth-error hidden"></p>
121
  <button id="auth-onboard-submit" class="auth-submit-btn" onclick="_submitOnboarding()">
122
  Continue
 
106
  </div>`;
107
  };
108
 
109
+ // Profile fields come from Google, but they still land inside markup — escape
110
+ // them so a quote or angle bracket in a display name cannot break the template.
111
+ function escapeHTML(v) {
112
+ return String(v == null ? '' : v).replace(/[&<>"']/g, c => (
113
+ { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
114
+ ));
115
+ }
116
+
117
  function getOnboardFormTemplate(user) {
118
+ const firstName = (user.name || '').split(' ')[0] || '';
119
  return `
120
  <div class="auth-card-header">
121
+ <img src="${escapeHTML(user.picture)}" alt="" class="auth-avatar" referrerpolicy="no-referrer">
122
  <h2 class="auth-title">Welcome</h2>
123
  <p class="auth-subtitle">Choose a display name for your account</p>
124
  </div>
125
  <div class="auth-onboard-form">
126
  <label class="auth-label">Display Name</label>
127
  <input id="auth-username-input" type="text" class="auth-input" maxlength="30"
128
+ placeholder="e.g. Aarav" value="${escapeHTML(firstName)}" autocomplete="off">
129
  <p id="auth-onboard-error" class="auth-error hidden"></p>
130
  <button id="auth-onboard-submit" class="auth-submit-btn" onclick="_submitOnboarding()">
131
  Continue
frontend/js/vehicles.js CHANGED
@@ -154,16 +154,11 @@ document.addEventListener('click', e => {
154
 
155
  // ---- Tab switching — updates both sidebar + mobile bottom nav ----
156
  function switchTab(tab) {
157
- console.log('[UrbanFlow] Switching to tab:', tab);
158
-
159
  const allTabs = ['about', 'overview', 'results', 'settings', 'help', 'feedback', 'profile'];
160
 
161
  allTabs.forEach(t => {
162
  const el = document.getElementById('tab-' + t);
163
- if (el) {
164
- el.classList.toggle('hidden', tab !== t);
165
- if (tab === t) console.log('[UrbanFlow] Tab visible:', t);
166
- }
167
 
168
  const nav = document.getElementById('nav-' + t);
169
  if (nav) {
@@ -263,13 +258,19 @@ window.switchTab = switchTab;
263
  el.classList.add('active');
264
  }
265
 
 
 
 
 
 
 
 
 
 
266
  async function submitFeedback() {
267
- const typeEl = document.getElementById('fb-type');
268
- const typeText = typeEl.selectedIndex >= 0 ? typeEl.options[typeEl.selectedIndex].text : "";
269
-
270
- const usecaseEl = document.getElementById('fb-usecase');
271
- const usecaseText = usecaseEl.selectedIndex >= 0 ? usecaseEl.options[usecaseEl.selectedIndex].text : "";
272
-
273
  const text = document.getElementById('fb-text').value.trim();
274
 
275
  const priorities = [];
@@ -297,11 +298,17 @@ window.switchTab = switchTab;
297
  if (session && session.email) {
298
  payload.user_email = session.email;
299
  }
300
- const res = await fetch('api/feedback', {
301
- method: 'POST',
302
- headers: { 'Content-Type': 'application/json' },
303
- body: JSON.stringify(payload)
304
- });
 
 
 
 
 
 
305
  if (res.ok) {
306
  showToast('Thank you for your feedback!', 'success');
307
  document.getElementById('fb-text').value = '';
@@ -365,6 +372,8 @@ window.switchTab = switchTab;
365
  }
366
  }
367
  requestAnimationFrame(updateSmoothBar);
 
 
368
  } else {
369
  showToast('Failed to submit — please try again', 'error');
370
  }
@@ -475,11 +484,6 @@ window.switchTab = switchTab;
475
  </div>`;
476
  }
477
 
478
- function boolBadge(val) {
479
- if (val) return `<span class="inline-flex items-center bg-green-50 text-green-700 text-[10px] font-bold px-2 py-0.5 rounded border border-green-200"><i class="fa-solid fa-check mr-1"></i>TRUE</span>`;
480
- return `<span class="text-[10px] font-bold text-slate-300">FALSE</span>`;
481
- }
482
-
483
  function populateRunDetails(c) {
484
  const res = c.resolution || [0, 0];
485
 
@@ -550,10 +554,11 @@ window.switchTab = switchTab;
550
  let congChart, doughChart, domChart, flowChart;
551
 
552
  async function initApp() {
553
- // Read settings from sessionStorage
554
- const rawRun = sessionStorage.getItem('funky_run');
555
- const runSettings = rawRun ? (JSON.parse(rawRun).settings || {}) : {};
556
- currentPalette = runSettings.palette || 'default';
 
557
  activePalette = PALETTES[currentPalette];
558
 
559
  // =========== Charts ===========
@@ -647,20 +652,25 @@ window.switchTab = switchTab;
647
  // Original init() logic
648
  const raw = sessionStorage.getItem('funky_run');
649
  if (!raw) {
650
- if (typeof showOnboardingPhase === 'function') {
651
- showOnboardingPhase();
652
- } else {
653
- window.location.replace('/');
654
- }
655
  return;
656
  }
657
 
658
  _params = JSON.parse(raw);
659
 
660
- const cRes = await fetch('constants');
661
- const cData = await cRes.json();
662
- MODEL_CLASSES = cData.classes;
663
- BUSINESS_MAP = cData.business_map;
 
 
 
 
 
 
 
 
 
664
 
665
  populateAndInit(_params);
666
  sessionStorage.removeItem('funky_run');
@@ -679,7 +689,9 @@ window.switchTab = switchTab;
679
  // =========== Live Palette Switching ===========
680
  function applyPalette(key) {
681
  activePalette = PALETTES[key] || PALETTES.default;
682
- currentPalette = key;
 
 
683
 
684
  // Congestion
685
  congChart.data.datasets[0].borderColor = activePalette.congestion;
@@ -723,42 +735,38 @@ window.switchTab = switchTab;
723
  }
724
  }
725
 
726
- function populateSettingsTab(config, settings) {
727
  // Populate stepper values from config
728
- document.getElementById('sv-imgsz').textContent = config.imgsz || 640;
729
  document.getElementById('sv-conf').textContent = (config.conf || 0.12).toFixed(2);
730
  document.getElementById('sv-iou').textContent = (config.iou || 0.60).toFixed(2);
731
  document.getElementById('sv-stride').textContent = config.detect_stride || 2;
732
 
733
- // Populate export settings
734
- const selReport = document.getElementById('sv-report');
735
- if (selReport) selReport.value = settings.reportFormat || 'png';
736
- const togAnnot = document.getElementById('sv-annotated');
737
- if (togAnnot && settings.annotatedVideo) togAnnot.classList.add('active');
738
-
739
- // Set live palette dropdown
740
  const sel = document.getElementById('live-palette-select');
741
  if (sel) sel.value = currentPalette;
 
 
742
  renderPalettePreview(currentPalette);
743
  }
744
 
745
  // =========== Settings Stepper Logic ===========
 
 
746
  const PARAM_LIMITS = {
747
- imgsz: { min: 640, max: 1280 },
748
  conf: { min: 0.10, max: 0.95 },
749
  iou: { min: 0.50, max: 0.95 },
750
- stride: { min: 1, max: 10 },
751
- smoothing: { min: 0.05, max: 0.95 }
752
  };
753
 
754
  function stepParam(param, delta) {
755
  const el = document.getElementById('sv-' + param);
756
- if (!el) return;
757
  const limits = PARAM_LIMITS[param];
 
758
  let val = parseFloat(el.textContent);
759
  val = Math.round((val + delta) * 100) / 100;
760
  val = Math.max(limits.min, Math.min(limits.max, val));
761
- el.textContent = (param === 'conf' || param === 'iou' || param === 'smoothing') ? val.toFixed(2) : val;
762
  }
763
 
764
  function lockSettings() {
@@ -787,12 +795,7 @@ window.switchTab = switchTab;
787
  function startNewAnalysis() {
788
  sessionStorage.clear();
789
  _params = null;
790
- // SPA Router
791
- if (typeof showOnboardingPhase === 'function') {
792
- showOnboardingPhase();
793
- } else {
794
- window.location.replace('/');
795
- }
796
  }
797
  function updateBreakdown(classIn, classOut) {
798
  const container = document.getElementById('class-breakdown');
@@ -888,7 +891,7 @@ window.switchTab = switchTab;
888
 
889
  function populateAndInit(params) {
890
  populateRunDetails(params.config);
891
- populateSettingsTab(params.config, params.settings || {});
892
  }
893
 
894
  function startProcessingFromSettings() {
@@ -937,13 +940,13 @@ window.switchTab = switchTab;
937
  switchTab('overview');
938
  document.getElementById('proc-label').innerText = 'Connecting...';
939
 
940
- // Analytics Funnel
941
- if (typeof trackFunnel === 'function') {
942
- trackFunnel('PROCESS_STARTED');
943
- }
 
944
 
945
  // Reset Run Tab Results to Awaiting
946
-
947
  document.getElementById('run-results-content').innerHTML = `
948
  <div class="flex flex-col items-center justify-center p-8 bg-black/40 border border-slate-800 rounded-2xl col-span-3 text-slate-500">
949
  <i class="fa-solid fa-spinner fa-spin text-2xl mb-3 text-white"></i>
@@ -1065,10 +1068,10 @@ window.switchTab = switchTab;
1065
 
1066
 
1067
  // GLOW NOTIFICATION: Let the user know artifacts are ready
1068
- const resultsMob = document.getElementById('mob-nav-results');
1069
- if (resultsMob) {
1070
- resultsMob.classList.add('notify-glow');
1071
- }
1072
 
1073
  // Show results content immediately (telemetry first, reports load async)
1074
  const rPendingMsg = document.getElementById('reports-pending-message');
@@ -1096,25 +1099,11 @@ window.switchTab = switchTab;
1096
  });
1097
  }
1098
 
1099
- // Disable Auto-Download toggle after completion
1100
- const adToggle = document.getElementById('sv-auto-download');
1101
- if (adToggle) {
1102
- adToggle.closest('.s-row').classList.add('disabled');
1103
- }
1104
- const jsonToggle = document.getElementById('sv-export-json');
1105
- if (jsonToggle) {
1106
- jsonToggle.closest('.s-row').classList.add('disabled');
1107
- }
1108
-
1109
- // NOTIFY USER: Glow the results icon in mobile nav
1110
- const resultsNav = document.getElementById('mob-nav-results');
1111
- if (resultsNav) {
1112
- resultsNav.classList.add('notify-glow');
1113
- }
1114
- const csvToggle = document.getElementById('sv-export-csv');
1115
- if (csvToggle) {
1116
- csvToggle.closest('.s-row').classList.add('disabled');
1117
- }
1118
 
1119
  // Show New Analysis button in Settings
1120
  const newWrap = document.getElementById('new-analysis-wrap');
@@ -1198,9 +1187,20 @@ window.switchTab = switchTab;
1198
  };
1199
 
1200
  async function loadReports(videoId) {
1201
- const res = await fetch(`reports/${videoId}`, { method: 'POST' });
1202
- const data = await res.json();
1203
- if (!data.files || !data.files.length) return null;
 
 
 
 
 
 
 
 
 
 
 
1204
 
1205
  const rPending = document.getElementById('reports-pending');
1206
  if (rPending) rPending.classList.add('hidden');
@@ -1359,14 +1359,14 @@ window.switchTab = switchTab;
1359
  bubble.style.pointerEvents = 'auto';
1360
  portal.appendChild(bubble);
1361
 
1362
- if (isMobile) {
1363
  // 7 icons in bottom nav. Settings = 4th (index 3).
1364
  // Center of 4th icon = (3 + 0.5) / 7 = 50% of viewport width.
1365
- const navH = parseInt(
1366
- getComputedStyle(document.documentElement)
1367
- .getPropertyValue('--mob-nav-h') || '68', 10
1368
- );
1369
- const vpW = window.innerWidth;
1370
  const settingsCenterX = (3.5 / 7) * vpW;
1371
  const bubbleW = bubble.offsetWidth || 220;
1372
  const leftPx = Math.max(8, Math.min(settingsCenterX - bubbleW / 2, vpW - bubbleW - 8));
@@ -1391,31 +1391,6 @@ window.switchTab = switchTab;
1391
  borderRight: '8px solid transparent',
1392
  borderTop: '8px solid #c89a6c',
1393
  });
1394
-
1395
- } else {
1396
- // Desktop sidebar <aside class="w-60"> = 240px.
1397
- // Settings is 5th nav link: logo(112px) + 4 items × 44px + 22px = 310px from top.
1398
- const settingsY = 310;
1399
- Object.assign(bubble.style, {
1400
- left: '256px',
1401
- top: (settingsY - 40) + 'px',
1402
- bottom: 'auto',
1403
- right: 'auto',
1404
- transform: 'none',
1405
- textAlign: 'left',
1406
- });
1407
-
1408
- const arrow = bubble.querySelector('#retry-bubble-arrow');
1409
- Object.assign(arrow.style, {
1410
- position: 'absolute',
1411
- left: '-8px',
1412
- top: '50%',
1413
- transform: 'translateY(-50%)',
1414
- width: '0', height: '0',
1415
- borderTop: '8px solid transparent',
1416
- borderBottom: '8px solid transparent',
1417
- borderRight: '8px solid #c89a6c',
1418
- });
1419
  }
1420
 
1421
  bubble.addEventListener('click', () => bubble.remove());
@@ -1443,15 +1418,6 @@ window.switchTab = switchTab;
1443
  if (mobPfp && session.picture) {
1444
  mobPfp.innerHTML = `<img src="${session.picture}" alt="" class="w-full h-full object-cover rounded-full" referrerpolicy="no-referrer">`;
1445
  }
1446
-
1447
- // Sync Palette Preference
1448
- const savedPalette = localStorage.getItem('uf_pref_palette') || 'default';
1449
- const paletteInp = document.getElementById('pref-palette');
1450
- if (paletteInp) {
1451
- paletteInp.value = savedPalette;
1452
- const label = document.getElementById('pref-palette-label');
1453
- if (label) label.innerText = savedPalette.charAt(0).toUpperCase() + savedPalette.slice(1);
1454
- }
1455
  }
1456
 
1457
  function populateProfileTab() {
@@ -1532,15 +1498,6 @@ window.switchTab = switchTab;
1532
  window.addEventListener('click', closer);
1533
  }
1534
 
1535
- // Palette Persistence
1536
- document.addEventListener('change', (e) => {
1537
- if (e.target.id === 'pref-palette') {
1538
- localStorage.setItem('uf_pref_palette', e.target.value);
1539
- showToast(`Palette preference saved: ${e.target.value}`, 'success');
1540
- }
1541
- });
1542
-
1543
-
1544
  document.addEventListener('DOMContentLoaded', () => {
1545
  // Phase 1: instant visual — show the shell immediately on first paint
1546
  const activeTab = sessionStorage.getItem('uf_active_tab') || 'settings';
 
154
 
155
  // ---- Tab switching — updates both sidebar + mobile bottom nav ----
156
  function switchTab(tab) {
 
 
157
  const allTabs = ['about', 'overview', 'results', 'settings', 'help', 'feedback', 'profile'];
158
 
159
  allTabs.forEach(t => {
160
  const el = document.getElementById('tab-' + t);
161
+ if (el) el.classList.toggle('hidden', tab !== t);
 
 
 
162
 
163
  const nav = document.getElementById('nav-' + t);
164
  if (nav) {
 
258
  el.classList.add('active');
259
  }
260
 
261
+ // These are uf-select widgets (hidden input + label), not <select>, so
262
+ // the chosen text lives on the label — reading .options gave "" always.
263
+ function ufSelectText(id) {
264
+ const hidden = document.getElementById(id);
265
+ if (!hidden || !hidden.value) return "";
266
+ const label = document.getElementById(id + '-label');
267
+ return label ? label.textContent.trim() : hidden.value;
268
+ }
269
+
270
  async function submitFeedback() {
271
+ const typeText = ufSelectText('fb-type');
272
+ const usecaseText = ufSelectText('fb-usecase');
273
+
 
 
 
274
  const text = document.getElementById('fb-text').value.trim();
275
 
276
  const priorities = [];
 
298
  if (session && session.email) {
299
  payload.user_email = session.email;
300
  }
301
+ let res;
302
+ try {
303
+ res = await fetch('api/feedback', {
304
+ method: 'POST',
305
+ headers: { 'Content-Type': 'application/json' },
306
+ body: JSON.stringify(payload)
307
+ });
308
+ } catch (err) {
309
+ showToast('Network error — please try again', 'error');
310
+ return;
311
+ }
312
  if (res.ok) {
313
  showToast('Thank you for your feedback!', 'success');
314
  document.getElementById('fb-text').value = '';
 
372
  }
373
  }
374
  requestAnimationFrame(updateSmoothBar);
375
+ } else if (res.status === 429) {
376
+ showToast('Too many submissions — please wait a minute', 'error');
377
  } else {
378
  showToast('Failed to submit — please try again', 'error');
379
  }
 
484
  </div>`;
485
  }
486
 
 
 
 
 
 
487
  function populateRunDetails(c) {
488
  const res = c.resolution || [0, 0];
489
 
 
554
  let congChart, doughChart, domChart, flowChart;
555
 
556
  async function initApp() {
557
+ // Honour the palette saved on the Profile tab. This used to read a
558
+ // `settings` key off funky_run that nothing ever wrote, so the
559
+ // saved preference was silently ignored on every run.
560
+ const savedPalette = localStorage.getItem('uf_pref_palette');
561
+ currentPalette = PALETTES[savedPalette] ? savedPalette : 'default';
562
  activePalette = PALETTES[currentPalette];
563
 
564
  // =========== Charts ===========
 
652
  // Original init() logic
653
  const raw = sessionStorage.getItem('funky_run');
654
  if (!raw) {
655
+ window.location.replace('/');
 
 
 
 
656
  return;
657
  }
658
 
659
  _params = JSON.parse(raw);
660
 
661
+ try {
662
+ const cRes = await fetch('constants');
663
+ if (!cRes.ok) throw new Error('constants ' + cRes.status);
664
+ const cData = await cRes.json();
665
+ MODEL_CLASSES = cData.classes;
666
+ BUSINESS_MAP = cData.business_map;
667
+ } catch (err) {
668
+ // Without the class map the dashboard renders empty breakdowns,
669
+ // so say what happened rather than failing silently.
670
+ console.error('[UrbanFlow]', err);
671
+ showToast('Could not reach the server — please reload', 'error');
672
+ return;
673
+ }
674
 
675
  populateAndInit(_params);
676
  sessionStorage.removeItem('funky_run');
 
689
  // =========== Live Palette Switching ===========
690
  function applyPalette(key) {
691
  activePalette = PALETTES[key] || PALETTES.default;
692
+ currentPalette = PALETTES[key] ? key : 'default';
693
+ // Persist so the choice survives the next run (initApp reads this).
694
+ try { localStorage.setItem('uf_pref_palette', currentPalette); } catch (e) {}
695
 
696
  // Congestion
697
  congChart.data.datasets[0].borderColor = activePalette.congestion;
 
735
  }
736
  }
737
 
738
+ function populateSettingsTab(config) {
739
  // Populate stepper values from config
740
+ document.getElementById('sv-imgsz').textContent = config.imgsz || 736;
741
  document.getElementById('sv-conf').textContent = (config.conf || 0.12).toFixed(2);
742
  document.getElementById('sv-iou').textContent = (config.iou || 0.60).toFixed(2);
743
  document.getElementById('sv-stride').textContent = config.detect_stride || 2;
744
 
745
+ // Reflect the restored palette in the header dropdown + its label.
 
 
 
 
 
 
746
  const sel = document.getElementById('live-palette-select');
747
  if (sel) sel.value = currentPalette;
748
+ const palLabel = document.getElementById('live-palette-label');
749
+ if (palLabel) palLabel.textContent = currentPalette.charAt(0).toUpperCase() + currentPalette.slice(1);
750
  renderPalettePreview(currentPalette);
751
  }
752
 
753
  // =========== Settings Stepper Logic ===========
754
+ // imgsz is absent on purpose: the OpenVINO graph is compiled at a fixed
755
+ // input size, so it is displayed but not tunable.
756
  const PARAM_LIMITS = {
 
757
  conf: { min: 0.10, max: 0.95 },
758
  iou: { min: 0.50, max: 0.95 },
759
+ stride: { min: 1, max: 10 }
 
760
  };
761
 
762
  function stepParam(param, delta) {
763
  const el = document.getElementById('sv-' + param);
 
764
  const limits = PARAM_LIMITS[param];
765
+ if (!el || !limits) return;
766
  let val = parseFloat(el.textContent);
767
  val = Math.round((val + delta) * 100) / 100;
768
  val = Math.max(limits.min, Math.min(limits.max, val));
769
+ el.textContent = (param === 'conf' || param === 'iou') ? val.toFixed(2) : val;
770
  }
771
 
772
  function lockSettings() {
 
795
  function startNewAnalysis() {
796
  sessionStorage.clear();
797
  _params = null;
798
+ window.location.replace('/');
 
 
 
 
 
799
  }
800
  function updateBreakdown(classIn, classOut) {
801
  const container = document.getElementById('class-breakdown');
 
891
 
892
  function populateAndInit(params) {
893
  populateRunDetails(params.config);
894
+ populateSettingsTab(params.config);
895
  }
896
 
897
  function startProcessingFromSettings() {
 
940
  switchTab('overview');
941
  document.getElementById('proc-label').innerText = 'Connecting...';
942
 
943
+ fetch('api/event', {
944
+ method: 'POST',
945
+ headers: { 'Content-Type': 'application/json' },
946
+ body: JSON.stringify({ event: 'PROCESS_STARTED', meta: { video_id: _params.video_id } })
947
+ }).catch(() => {});
948
 
949
  // Reset Run Tab Results to Awaiting
 
950
  document.getElementById('run-results-content').innerHTML = `
951
  <div class="flex flex-col items-center justify-center p-8 bg-black/40 border border-slate-800 rounded-2xl col-span-3 text-slate-500">
952
  <i class="fa-solid fa-spinner fa-spin text-2xl mb-3 text-white"></i>
 
1068
 
1069
 
1070
  // GLOW NOTIFICATION: Let the user know artifacts are ready
1071
+ ['mob-nav-results', 'nav-results'].forEach(id => {
1072
+ const el = document.getElementById(id);
1073
+ if (el) el.classList.add('notify-glow');
1074
+ });
1075
 
1076
  // Show results content immediately (telemetry first, reports load async)
1077
  const rPendingMsg = document.getElementById('reports-pending-message');
 
1099
  });
1100
  }
1101
 
1102
+ // Lock the export toggles — the run they configure is over.
1103
+ ['sv-auto-download', 'sv-export-json', 'sv-export-csv'].forEach(id => {
1104
+ const row = document.getElementById(id);
1105
+ if (row && row.closest('.s-row')) row.closest('.s-row').classList.add('disabled');
1106
+ });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1107
 
1108
  // Show New Analysis button in Settings
1109
  const newWrap = document.getElementById('new-analysis-wrap');
 
1187
  };
1188
 
1189
  async function loadReports(videoId) {
1190
+ let data;
1191
+ try {
1192
+ const res = await fetch(`reports/${videoId}`, { method: 'POST' });
1193
+ if (!res.ok) throw new Error('reports ' + res.status);
1194
+ data = await res.json();
1195
+ } catch (err) {
1196
+ console.error('[UrbanFlow]', err);
1197
+ showToast('Report generation failed — telemetry is still available', 'error');
1198
+ return null;
1199
+ }
1200
+ if (!data.files || !data.files.length) {
1201
+ showToast('No artifacts were produced for this run', 'info');
1202
+ return null;
1203
+ }
1204
 
1205
  const rPending = document.getElementById('reports-pending');
1206
  if (rPending) rPending.classList.add('hidden');
 
1359
  bubble.style.pointerEvents = 'auto';
1360
  portal.appendChild(bubble);
1361
 
1362
+ {
1363
  // 7 icons in bottom nav. Settings = 4th (index 3).
1364
  // Center of 4th icon = (3 + 0.5) / 7 = 50% of viewport width.
1365
+ // Measure the nav directly: --mob-nav-h is a calc() with a
1366
+ // safe-area env() in it and does not parse as a number.
1367
+ const navEl = document.getElementById('mobile-bottom-nav');
1368
+ const navH = navEl ? navEl.offsetHeight : 68;
1369
+ const vpW = window.innerWidth;
1370
  const settingsCenterX = (3.5 / 7) * vpW;
1371
  const bubbleW = bubble.offsetWidth || 220;
1372
  const leftPx = Math.max(8, Math.min(settingsCenterX - bubbleW / 2, vpW - bubbleW - 8));
 
1391
  borderRight: '8px solid transparent',
1392
  borderTop: '8px solid #c89a6c',
1393
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1394
  }
1395
 
1396
  bubble.addEventListener('click', () => bubble.remove());
 
1418
  if (mobPfp && session.picture) {
1419
  mobPfp.innerHTML = `<img src="${session.picture}" alt="" class="w-full h-full object-cover rounded-full" referrerpolicy="no-referrer">`;
1420
  }
 
 
 
 
 
 
 
 
 
1421
  }
1422
 
1423
  function populateProfileTab() {
 
1498
  window.addEventListener('click', closer);
1499
  }
1500
 
 
 
 
 
 
 
 
 
 
1501
  document.addEventListener('DOMContentLoaded', () => {
1502
  // Phase 1: instant visual — show the shell immediately on first paint
1503
  const activeTab = sessionStorage.getItem('uf_active_tab') || 'settings';
frontend/sw.js CHANGED
@@ -1,4 +1,4 @@
1
- const CACHE_NAME = 'urbanflow-v4';
2
  const ASSETS = [
3
  './css/initial.css',
4
  './css/vehicles.css',
@@ -13,15 +13,26 @@ self.addEventListener('install', (e) => {
13
  e.waitUntil(caches.open(CACHE_NAME).then(c => c.addAll(ASSETS)));
14
  });
15
 
 
 
 
 
 
 
 
 
 
16
  self.addEventListener('fetch', (e) => {
 
 
 
 
 
17
  const url = new URL(e.request.url);
18
-
19
- // NEVER cache WebSockets or API calls
20
- if (url.pathname.includes('/ws/') || url.pathname.includes('/reports/') || url.pathname.includes('/bundle/')) {
21
- return;
22
- }
23
 
24
- e.respondWith(
25
- fetch(e.request).catch(() => caches.match(e.request))
26
- );
 
27
  });
 
1
+ const CACHE_NAME = 'urbanflow-v5';
2
  const ASSETS = [
3
  './css/initial.css',
4
  './css/vehicles.css',
 
13
  e.waitUntil(caches.open(CACHE_NAME).then(c => c.addAll(ASSETS)));
14
  });
15
 
16
+ // Drop caches from previous versions instead of letting them accumulate.
17
+ self.addEventListener('activate', (e) => {
18
+ e.waitUntil(
19
+ caches.keys().then(keys =>
20
+ Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
21
+ )
22
+ );
23
+ });
24
+
25
  self.addEventListener('fetch', (e) => {
26
+ // Only GETs are cacheable. Passing an upload POST through respondWith()
27
+ // re-issues the request body and surfaces any hiccup to the page as an
28
+ // opaque "network failure", so leave every non-GET to the browser.
29
+ if (e.request.method !== 'GET') return;
30
+
31
  const url = new URL(e.request.url);
32
+ if (url.origin !== self.location.origin) return;
 
 
 
 
33
 
34
+ // Dynamic endpoints must never be served from, or fall back to, the cache.
35
+ if (/^\/(ws|api|upload|config|first-frame|reports|bundle|constants)\b/.test(url.pathname)) return;
36
+
37
+ e.respondWith(fetch(e.request).catch(() => caches.match(e.request)));
38
  });
frontend/vehicles.html CHANGED
@@ -4,7 +4,8 @@
4
  <head>
5
  <meta charset="UTF-8">
6
  <meta name="color-scheme" content="dark">
7
- <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
 
8
  <title>UrbanFlow</title>
9
  <link rel="icon" type="image/png" href="assets/shuriken.png">
10
  <link rel="manifest" href="manifest.json">
@@ -64,8 +65,7 @@
64
  <button onclick="openAppModal('termsModal'); toggleLegalMenu(event)" class="w-full text-left px-4 py-3.5 text-[10px] font-bold uppercase tracking-widest text-[#a89f97] hover:text-white hover:bg-[#111] border-b border-[#1a1a1a] transition-all">
65
  Terms & Conditions
66
  </button>
67
- <!-- Sign Out (populated by JS) -->
68
- <button id="mobile-signout-btn" class="mobile-signout-btn" style="display:none" onclick="showLogoutConfirm()">Sign Out</button>
69
  </div>
70
  </div>
71
  </div>
@@ -120,23 +120,6 @@
120
  </div>
121
  </aside>
122
 
123
- <!-- Mobile Navigation (hidden on desktop) -->
124
- <div
125
- class="mobile-nav hidden fixed top-0 left-0 right-0 z-30 bg-black border-b border-slate-800 px-4 py-2 items-center justify-between">
126
- <img src="assets/uf_rf.png" alt="UF" class="h-8">
127
- <div class="flex items-center gap-4">
128
- <div class="dropdown relative">
129
- <button onclick="toggleLegalMenu(event)" class="text-slate-500 hover:text-white transition">
130
- <i class="fa-solid fa-ellipsis-vertical"></i>
131
- </button>
132
- <div id="legal-menu" class="hidden absolute right-0 mt-2 w-48 bg-neutral-900 border border-neutral-800 rounded-lg shadow-xl py-2 z-50">
133
- <a onclick="openAppModal('privacyModal')" class="block px-4 py-2 text-xs text-slate-300 hover:bg-neutral-800 hover:text-white cursor-pointer">Privacy Policy</a>
134
- <a onclick="openAppModal('termsModal')" class="block px-4 py-2 text-xs text-slate-300 hover:bg-neutral-800 hover:text-white cursor-pointer">Terms &amp; Conditions</a>
135
- </div>
136
- </div>
137
- </div>
138
- </div>
139
-
140
  <!-- Toast Container -->
141
  <div id="toast-container"></div>
142
 
@@ -512,11 +495,9 @@
512
  for the resolution compiled into the OpenVINO weights.</span>
513
  </span>
514
  </div>
515
- <div class="text-[10px] text-slate-500">Model input resolution</div>
516
  </div>
517
- <div class="s-stepper"><button onclick="stepParam('imgsz',-32)">&#8249;</button><span
518
- class="s-val" id="sv-imgsz">640</span><button
519
- onclick="stepParam('imgsz',32)">&#8250;</button></div>
520
  </div>
521
  <div class="s-row" data-param="conf">
522
  <div>
@@ -673,7 +654,7 @@
673
  </div>
674
 
675
  <!-- Start Button -->
676
- <div class="col-span-3 pb-4 flex justify-center" id="settings-start-wrap">
677
  <button id="btn-start-processing" onclick="startProcessingFromSettings()"
678
  class="w-fit px-16 py-4 font-bold text-sm rounded-full transition flex items-center justify-center gap-2 shadow-lg hover:scale-105 active:scale-95"
679
  style="background:#0a0a0a;border:1px solid var(--cocoa);color:var(--cocoa-l)">
@@ -682,7 +663,7 @@
682
  </div>
683
 
684
  <!-- Home Button (visible only after processing completes) -->
685
- <div class="col-span-3 pb-4 hidden flex justify-center" id="new-analysis-wrap">
686
  <button onclick="startNewAnalysis()"
687
  class="w-fit px-16 py-4 font-bold text-sm rounded-full transition flex items-center justify-center gap-2 shadow-lg hover:scale-105 active:scale-95"
688
  style="background:#0a0a0a;border:1px solid var(--cocoa);color:var(--cocoa-l)">
@@ -1200,46 +1181,42 @@
1200
 
1201
 
1202
 
1203
- // Inject shared components (modals, shortcuts, nav)
1204
  injectLegalModals();
1205
  injectShortcutsModal();
1206
- // Auto-show keyboard shortcuts on first desktop visit
1207
- if (window.matchMedia('(hover: hover) and (pointer: fine)').matches) {
 
 
 
1208
  setTimeout(function () { openAppModal('shortcutsModal'); }, 800);
1209
  }
1210
  </script>
1211
- <nav class="mobile-bottom-nav" id="mobile-bottom-nav">
1212
- <button class="mob-nav-item" id="mob-nav-about" onclick="switchTab('about')">
1213
- <i class="fa-solid fa-circle-info"></i>
1214
  </button>
1215
- <button class="mob-nav-item" id="mob-nav-overview" onclick="switchTab('overview')">
1216
- <i class="fa-solid fa-desktop"></i>
1217
  </button>
1218
- <button class="mob-nav-item" id="mob-nav-results" onclick="switchTab('results')">
1219
- <i class="fa-solid fa-file-lines"></i>
1220
  </button>
1221
- <button class="mob-nav-item" id="mob-nav-settings" onclick="switchTab('settings')">
1222
- <i class="fa-solid fa-gear"></i>
1223
  </button>
1224
- <button class="mob-nav-item" id="mob-nav-help" onclick="switchTab('help')">
1225
- <i class="fa-solid fa-circle-question"></i>
1226
  </button>
1227
- <button class="mob-nav-item" id="mob-nav-feedback" onclick="switchTab('feedback')">
1228
- <i class="fa-solid fa-comment-dots"></i>
1229
  </button>
1230
- <button class="mob-nav-item" id="mob-nav-profile" onclick="switchTab('profile')">
1231
  <div id="mob-pfp-wrap" class="w-7 h-7 rounded-full overflow-hidden bg-neutral-800 flex items-center justify-center mx-auto">
1232
- <i class="fa-solid fa-circle-user"></i>
1233
  </div>
1234
  </button>
1235
- </nav>
1236
- <script>
1237
- if ('serviceWorker' in navigator) {
1238
- window.addEventListener('load', () => {
1239
- navigator.serviceWorker.register('./sw.js');
1240
- });
1241
- }
1242
- </script>
1243
  </body>
1244
 
1245
  </html>
 
4
  <head>
5
  <meta charset="UTF-8">
6
  <meta name="color-scheme" content="dark">
7
+ <!-- viewport-fit=cover for notched devices; zoom left enabled (WCAG 1.4.4) -->
8
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
9
  <title>UrbanFlow</title>
10
  <link rel="icon" type="image/png" href="assets/shuriken.png">
11
  <link rel="manifest" href="manifest.json">
 
65
  <button onclick="openAppModal('termsModal'); toggleLegalMenu(event)" class="w-full text-left px-4 py-3.5 text-[10px] font-bold uppercase tracking-widest text-[#a89f97] hover:text-white hover:bg-[#111] border-b border-[#1a1a1a] transition-all">
66
  Terms & Conditions
67
  </button>
68
+ <button id="mobile-signout-btn" class="mobile-signout-btn" onclick="showLogoutConfirm()">Sign Out</button>
 
69
  </div>
70
  </div>
71
  </div>
 
120
  </div>
121
  </aside>
122
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  <!-- Toast Container -->
124
  <div id="toast-container"></div>
125
 
 
495
  for the resolution compiled into the OpenVINO weights.</span>
496
  </span>
497
  </div>
498
+ <div class="text-[10px] text-slate-500">Fixed by the compiled model graph</div>
499
  </div>
500
+ <div class="s-stepper"><span class="s-val" id="sv-imgsz">736</span></div>
 
 
501
  </div>
502
  <div class="s-row" data-param="conf">
503
  <div>
 
654
  </div>
655
 
656
  <!-- Start Button -->
657
+ <div class="col-span-2 pb-4 flex justify-center" id="settings-start-wrap">
658
  <button id="btn-start-processing" onclick="startProcessingFromSettings()"
659
  class="w-fit px-16 py-4 font-bold text-sm rounded-full transition flex items-center justify-center gap-2 shadow-lg hover:scale-105 active:scale-95"
660
  style="background:#0a0a0a;border:1px solid var(--cocoa);color:var(--cocoa-l)">
 
663
  </div>
664
 
665
  <!-- Home Button (visible only after processing completes) -->
666
+ <div class="col-span-2 pb-4 hidden flex justify-center" id="new-analysis-wrap">
667
  <button onclick="startNewAnalysis()"
668
  class="w-fit px-16 py-4 font-bold text-sm rounded-full transition flex items-center justify-center gap-2 shadow-lg hover:scale-105 active:scale-95"
669
  style="background:#0a0a0a;border:1px solid var(--cocoa);color:var(--cocoa-l)">
 
1181
 
1182
 
1183
 
1184
+ // Inject shared components (modals, shortcuts)
1185
  injectLegalModals();
1186
  injectShortcutsModal();
1187
+ // Auto-show keyboard shortcuts on the FIRST desktop visit only —
1188
+ // it used to interrupt every single run.
1189
+ if (window.matchMedia('(hover: hover) and (pointer: fine)').matches
1190
+ && !localStorage.getItem('uf_shortcuts_seen')) {
1191
+ localStorage.setItem('uf_shortcuts_seen', '1');
1192
  setTimeout(function () { openAppModal('shortcutsModal'); }, 800);
1193
  }
1194
  </script>
1195
+ <nav class="mobile-bottom-nav" id="mobile-bottom-nav" aria-label="Main">
1196
+ <button class="mob-nav-item" id="mob-nav-about" onclick="switchTab('about')" aria-label="About">
1197
+ <i class="fa-solid fa-circle-info" aria-hidden="true"></i>
1198
  </button>
1199
+ <button class="mob-nav-item" id="mob-nav-overview" onclick="switchTab('overview')" aria-label="Overview">
1200
+ <i class="fa-solid fa-desktop" aria-hidden="true"></i>
1201
  </button>
1202
+ <button class="mob-nav-item" id="mob-nav-results" onclick="switchTab('results')" aria-label="Results">
1203
+ <i class="fa-solid fa-file-lines" aria-hidden="true"></i>
1204
  </button>
1205
+ <button class="mob-nav-item" id="mob-nav-settings" onclick="switchTab('settings')" aria-label="Settings">
1206
+ <i class="fa-solid fa-gear" aria-hidden="true"></i>
1207
  </button>
1208
+ <button class="mob-nav-item" id="mob-nav-help" onclick="switchTab('help')" aria-label="Guide">
1209
+ <i class="fa-solid fa-circle-question" aria-hidden="true"></i>
1210
  </button>
1211
+ <button class="mob-nav-item" id="mob-nav-feedback" onclick="switchTab('feedback')" aria-label="Feedback">
1212
+ <i class="fa-solid fa-comment-dots" aria-hidden="true"></i>
1213
  </button>
1214
+ <button class="mob-nav-item" id="mob-nav-profile" onclick="switchTab('profile')" aria-label="Profile">
1215
  <div id="mob-pfp-wrap" class="w-7 h-7 rounded-full overflow-hidden bg-neutral-800 flex items-center justify-center mx-auto">
1216
+ <i class="fa-solid fa-circle-user" aria-hidden="true"></i>
1217
  </div>
1218
  </button>
1219
+ </nav>
 
 
 
 
 
 
 
1220
  </body>
1221
 
1222
  </html>