Subh775 commited on
Commit
5cd1866
·
1 Parent(s): dde3ae9

RELEASE: auth; pf section added; bug/crash/ major improvements & fixes; refactoring pending..

Browse files
backend/auth.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Google OAuth token verification and lightweight user store.
3
+
4
+ Uses google-auth to verify JWT credentials issued by Google Identity Services.
5
+ User profiles are stored in a JSON file (/tmp) — ephemeral on HF Spaces,
6
+ which is acceptable for a demo. Client-side localStorage persists the session.
7
+ """
8
+
9
+ import json
10
+ import os
11
+ from pathlib import Path
12
+ from datetime import datetime, timezone
13
+
14
+ from google.oauth2 import id_token
15
+ from google.auth.transport import requests as google_requests
16
+
17
+ GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID", "")
18
+ USER_STORE_PATH = Path(os.getenv("USER_STORE_PATH", "/tmp/urbanflow_users.json"))
19
+
20
+
21
+ def _load_users() -> dict:
22
+ if USER_STORE_PATH.exists():
23
+ return json.loads(USER_STORE_PATH.read_text())
24
+ return {}
25
+
26
+
27
+ def _save_users(users: dict):
28
+ USER_STORE_PATH.write_text(json.dumps(users, indent=2))
29
+
30
+
31
+ def verify_google_token(credential: str) -> dict:
32
+ """
33
+ Verify a Google ID token (JWT) and return the decoded payload.
34
+ Raises ValueError on invalid/expired tokens.
35
+ """
36
+ idinfo = id_token.verify_oauth2_token(
37
+ credential,
38
+ google_requests.Request(),
39
+ GOOGLE_CLIENT_ID,
40
+ )
41
+ return {
42
+ "email": idinfo["email"],
43
+ "name": idinfo.get("name", ""),
44
+ "picture": idinfo.get("picture", ""),
45
+ }
46
+
47
+
48
+ def get_or_create_user(email: str, name: str, picture: str) -> dict:
49
+ """
50
+ Look up a user by email. If they don't exist, create a stub record.
51
+ Returns the user record with a `new_user` flag.
52
+ """
53
+ users = _load_users()
54
+ is_new = email not in users
55
+
56
+ if is_new:
57
+ users[email] = {
58
+ "username": "",
59
+ "name": name,
60
+ "picture": picture,
61
+ "created_at": datetime.now(timezone.utc).isoformat(),
62
+ }
63
+ _save_users(users)
64
+
65
+ user = users[email]
66
+ return {
67
+ "email": email,
68
+ "username": user.get("username", ""),
69
+ "name": user.get("name", name),
70
+ "picture": user.get("picture", picture),
71
+ "new_user": is_new or not user.get("username"),
72
+ }
73
+
74
+
75
+ def set_username(email: str, username: str) -> bool:
76
+ """
77
+ Save a display username for a first-time user.
78
+ Returns True on success, False if the user record doesn't exist.
79
+ """
80
+ users = _load_users()
81
+ if email not in users:
82
+ return False
83
+ users[email]["username"] = username.strip()
84
+ _save_users(users)
85
+ return True
backend/email_template.html ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;600;700;800;900&display=swap" rel="stylesheet">
5
+ </head>
6
+ <body style="margin: 0; padding: 0; background-color: #ffffff;">
7
+ <div style="font-family: 'Montserrat', sans-serif; color: #333; max-width: 600px; margin: 40px auto; padding: 0 20px;">
8
+ {final_content}
9
+
10
+ <div style="margin-top: 60px; padding-top: 30px; border-top: 1px solid #eee; text-align: center;">
11
+ <p style="font-size: 10px; color: #bbb; text-transform: uppercase; letter-spacing: 2px; font-weight: 700; margin: 0;">Inference Engine Feedback Capture</p>
12
+ <p style="font-size: 9px; color: #ddd; margin-top: 5px;">&copy; 2026 UrbanFlow. All rights reserved.</p>
13
+ </div>
14
+ </div>
15
+ </body>
16
+ </html>
backend/engine.py CHANGED
@@ -9,19 +9,7 @@ from collections import defaultdict
9
  from pcu import compute_pcu, MODEL_CLASSES
10
  from tracker_config import get_tracker_path
11
  from speed import estimate_speeds
12
-
13
-
14
- def _side(p, a, b):
15
- return np.sign((b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0]))
16
-
17
-
18
- def _point_to_segment_dist(px, py, ax, ay, bx, by):
19
- A = np.array([ax, ay], dtype=float)
20
- B = np.array([bx, by], dtype=float)
21
- P = np.array([px, py], dtype=float)
22
- AB = B - A
23
- t = np.clip(np.dot(P - A, AB) / np.dot(AB, AB), 0, 1)
24
- return np.linalg.norm(P - (A + t * AB))
25
 
26
 
27
  # Lightweight drawing colors (BGR for OpenCV)
@@ -160,97 +148,103 @@ def run(model, video_path, line, config, on_frame, save_annotated=False, annotat
160
  batch=2 # MUST match OpenVINO export batch size
161
  )
162
 
163
- a = line[0]
164
- b = line[1]
 
165
 
166
  iterator = iter(enumerate(results))
167
- while True:
168
- try:
169
- frame_idx, r = next(iterator)
170
- except StopIteration:
171
- break
172
- except RuntimeError as e:
173
- if "incompatible" in str(e) and "shape=" in str(e):
174
- print(f"[BACKEND] Ignored OpenVINO shape mismatch on final trailing batch.")
175
  break
176
- raise e
177
- active = 0
178
- cur_boxes = None
179
- cur_ids = None
180
-
181
- if r.boxes.id is not None:
182
- ids = r.boxes.id.cpu().numpy()
183
- cls = r.boxes.cls.cpu().numpy()
184
- xyxy = r.boxes.xyxy.cpu().numpy()
185
-
186
- active = len(ids)
187
- confs = r.boxes.conf.cpu().numpy().tolist()
188
- conf_scores.extend(confs)
189
-
190
- cur_boxes = xyxy
191
- cur_ids = ids
192
-
193
- for obj_id, c, box in zip(ids, cls, xyxy):
194
- cx = int((box[0] + box[2]) / 2)
195
- cy = int((box[1] + box[3]) / 2)
196
-
197
- heatmap_points.append([cx, cy, float(r.boxes.conf.cpu().numpy()[list(ids).index(obj_id)])])
198
- track_positions[obj_id].append((frame_idx, cx, cy))
199
-
200
- current = _side((cx, cy), a, b)
201
-
202
- # Skip if centroid is exactly on the line (cross-product == 0)
203
- # — avoids misfired crossings due to floating-point boundary hits
204
- if current == 0:
205
- continue
206
-
207
- if obj_id in prev_side and obj_id not in counted_ids:
208
- if prev_side[obj_id] != current:
209
- dist = _point_to_segment_dist(cx, cy, a[0], a[1], b[0], b[1])
210
- if dist < cross_dist:
211
- t = frame_idx * stride / fps
212
- flow_times.append(round(t, 2))
213
-
214
- if current > 0:
215
- class_in[int(c)] += 1
216
- raw_events.append([frame_idx + 1, round(t, 2), int(obj_id), MODEL_CLASSES.get(int(c), f"cls_{int(c)}"), "IN"])
217
- else:
218
- class_out[int(c)] += 1
219
- raw_events.append([frame_idx + 1, round(t, 2), int(obj_id), MODEL_CLASSES.get(int(c), f"cls_{int(c)}"), "OUT"])
220
-
221
- counted_ids.add(obj_id)
222
-
223
- prev_side[obj_id] = current
224
-
225
- # Write annotated frame
226
- cur_clses = cls if r.boxes.id is not None else None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
  if writer is not None:
228
- frame = r.orig_img.copy()
229
- _draw_annotations(frame, cur_boxes, cur_ids, cur_clses, [a, b], annotated_options)
230
- writer.write(frame)
231
-
232
- congestion.append(active)
233
-
234
- elapsed = time.time() - start
235
-
236
- update = {
237
- "frame_index": frame_idx + 1,
238
- "total_iters": total_iters,
239
- "total_frames": total,
240
- "active": active,
241
- "congestion_len": len(congestion), # just the length, not the full list
242
- "congestion_last": congestion[-1] if congestion else 0, # only latest value
243
- "class_in": {str(k): v for k, v in class_in.items()},
244
- "class_out": {str(k): v for k, v in class_out.items()},
245
- "flow_count": len(flow_times), # just the count
246
- "elapsed": round(elapsed, 2),
247
- "fps": round((frame_idx + 1) / elapsed, 2) if elapsed > 0 else 0,
248
- }
249
-
250
- on_frame(update)
251
-
252
- if writer is not None:
253
- writer.stop()
254
 
255
  processing_time = round(time.time() - start, 2)
256
  actual_fps = round(total / processing_time, 2) if processing_time > 0 else 0
 
9
  from pcu import compute_pcu, MODEL_CLASSES
10
  from tracker_config import get_tracker_path
11
  from speed import estimate_speeds
12
+ from geometry import _side, _point_to_segment_dist
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
 
15
  # Lightweight drawing colors (BGR for OpenCV)
 
148
  batch=2 # MUST match OpenVINO export batch size
149
  )
150
 
151
+ a = line[0] if line and len(line) > 1 else [None, None]
152
+ b = line[1] if line and len(line) > 1 else [None, None]
153
+ valid_line = not (a[0] is None or a[1] is None or b[0] is None or b[1] is None)
154
 
155
  iterator = iter(enumerate(results))
156
+ try:
157
+ while True:
158
+ try:
159
+ frame_idx, r = next(iterator)
160
+ except StopIteration:
 
 
 
161
  break
162
+ except RuntimeError as e:
163
+ if "incompatible" in str(e) and "shape=" in str(e):
164
+ print(f"[BACKEND] Ignored OpenVINO shape mismatch on final trailing batch.")
165
+ break
166
+ raise e
167
+ active = 0
168
+ cur_boxes = None
169
+ cur_ids = None
170
+
171
+ if r.boxes.id is not None:
172
+ ids = r.boxes.id.cpu().numpy()
173
+ cls = r.boxes.cls.cpu().numpy()
174
+ xyxy = r.boxes.xyxy.cpu().numpy()
175
+
176
+ active = len(ids)
177
+ confs = r.boxes.conf.cpu().numpy().tolist()
178
+ conf_scores.extend(confs)
179
+
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:
191
+ current = 0
192
+ else:
193
+ current = _side((cx, cy), a, b)
194
+
195
+ # Skip if centroid is exactly on the line (cross-product == 0)
196
+ # avoids misfired crossings due to floating-point boundary hits
197
+ if current == 0:
198
+ continue
199
+
200
+ if obj_id in prev_side and obj_id not in counted_ids:
201
+ if prev_side[obj_id] != current:
202
+ dist = _point_to_segment_dist(cx, cy, a[0], a[1], b[0], b[1])
203
+ if dist < cross_dist:
204
+ t = frame_idx * stride / fps
205
+ flow_times.append(round(t, 2))
206
+
207
+ if current > 0:
208
+ class_in[int(c)] += 1
209
+ raw_events.append([frame_idx + 1, round(t, 2), int(obj_id), MODEL_CLASSES.get(int(c), f"cls_{int(c)}"), "IN"])
210
+ else:
211
+ class_out[int(c)] += 1
212
+ raw_events.append([frame_idx + 1, round(t, 2), int(obj_id), MODEL_CLASSES.get(int(c), f"cls_{int(c)}"), "OUT"])
213
+
214
+ counted_ids.add(obj_id)
215
+
216
+ prev_side[obj_id] = current
217
+
218
+ # Write annotated frame
219
+ cur_clses = cls if r.boxes.id is not None else None
220
+ if writer is not None:
221
+ frame = r.orig_img.copy()
222
+ _draw_annotations(frame, cur_boxes, cur_ids, cur_clses, [a, b], annotated_options)
223
+ writer.write(frame)
224
+
225
+ congestion.append(active)
226
+
227
+ elapsed = time.time() - start
228
+
229
+ update = {
230
+ "frame_index": frame_idx + 1,
231
+ "total_iters": total_iters,
232
+ "total_frames": total,
233
+ "active": active,
234
+ "congestion_len": len(congestion), # just the length, not the full list
235
+ "congestion_last": congestion[-1] if congestion else 0, # only latest value
236
+ "class_in": {str(k): v for k, v in class_in.items()},
237
+ "class_out": {str(k): v for k, v in class_out.items()},
238
+ "flow_count": len(flow_times), # just the count
239
+ "elapsed": round(elapsed, 2),
240
+ "fps": round((frame_idx + 1) / elapsed, 2) if elapsed > 0 else 0,
241
+ }
242
+
243
+ on_frame(update)
244
+
245
+ finally:
246
  if writer is not None:
247
+ writer.stop()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
248
 
249
  processing_time = round(time.time() - start, 2)
250
  actual_fps = round(total / processing_time, 2) if processing_time > 0 else 0
backend/geometry.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+
3
+ def _side(p, a, b):
4
+ return np.sign((b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0]))
5
+
6
+ def _point_to_segment_dist(px, py, ax, ay, bx, by):
7
+ A = np.array([ax, ay], dtype=float)
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))
backend/server.py CHANGED
@@ -8,15 +8,18 @@ from pathlib import Path
8
  import zipfile
9
 
10
  import cv2
11
- from fastapi import FastAPI, WebSocket, UploadFile, File, BackgroundTasks
12
- from fastapi.responses import FileResponse, Response
 
13
  from fastapi.staticfiles import StaticFiles
 
14
  import resend
15
 
16
  from model import load_model
17
  from config import get_optimal_config
18
  from engine import run
19
  from pcu import MODEL_CLASSES
 
20
  from visualize import generate_all
21
 
22
  BUSINESS_MAP = {
@@ -43,6 +46,23 @@ video_info = {}
43
  run_results = {}
44
  model = None
45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
 
47
  @asynccontextmanager
48
  async def lifespan(app: FastAPI):
@@ -62,6 +82,15 @@ async def lifespan(app: FastAPI):
62
 
63
  app = FastAPI(lifespan=lifespan)
64
 
 
 
 
 
 
 
 
 
 
65
 
66
  @app.get("/")
67
  def index():
@@ -69,12 +98,23 @@ def index():
69
 
70
 
71
  @app.get("/vehicles.html")
 
72
  def vehicles():
73
  return FileResponse(FRONTEND / "vehicles.html")
74
 
75
 
76
  @app.post("/upload")
77
- async def upload(file: UploadFile = File(...)):
 
 
 
 
 
 
 
 
 
 
78
  video_id = str(uuid.uuid4())[:8]
79
  path = UPLOAD_DIR / f"{video_id}.mp4"
80
 
@@ -91,14 +131,20 @@ async def upload(file: UploadFile = File(...)):
91
  shutil.copyfileobj(file.file, f)
92
 
93
  file_size = os.path.getsize(path)
 
 
 
 
94
  print(f"[BACKEND] Successfully stored: {path} ({file_size} bytes)")
95
 
96
  videos[video_id] = str(path)
97
  video_info[video_id] = file.filename
 
 
98
  return {"video_id": video_id}
99
  except Exception as e:
100
  print(f"[BACKEND] Upload failed: {str(e)}")
101
- return Response(content=str(e), status_code=500)
102
 
103
 
104
  @app.get("/config/{video_id}")
@@ -142,9 +188,12 @@ def generate_reports(video_id: str):
142
 
143
  @app.get("/reports/{video_id}/{name}")
144
  def get_report(video_id: str, name: str):
145
- path = REPORT_DIR / video_id / name
 
 
 
146
  if not path.exists():
147
- return Response(status_code=404)
148
  media = "image/png"
149
  if name.endswith(".pdf"):
150
  media = "application/pdf"
@@ -164,7 +213,7 @@ def download_all_reports(video_id: str):
164
  base_path = REPORT_DIR / video_id
165
  if not base_path.exists():
166
  print(f"[BACKEND] Error: {base_path} not found")
167
- return Response(content=f"Report directory not found for {video_id}", status_code=404)
168
 
169
  try:
170
  zip_filename = f"bundle_{video_id}.zip"
@@ -174,12 +223,8 @@ def download_all_reports(video_id: str):
174
  zip_path.unlink()
175
 
176
  print(f"[BACKEND] Creating ZIP: {zip_path}")
177
- with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
178
- for root, _, files in os.walk(base_path):
179
- for file in files:
180
- file_path = os.path.join(root, file)
181
- arcname = os.path.relpath(file_path, base_path)
182
- zipf.write(file_path, arcname)
183
 
184
  if not zip_path.exists():
185
  raise Exception("Zip file was not created")
@@ -196,7 +241,7 @@ def download_all_reports(video_id: str):
196
  except Exception as e:
197
  import traceback
198
  print(f"[BACKEND] ZIP Error: {str(e)}\n{traceback.format_exc()}")
199
- return Response(content=str(e), status_code=500)
200
 
201
 
202
  FEEDBACK_PATH = Path(tempfile.gettempdir()) / "urbanflow_feedback.json"
@@ -269,11 +314,22 @@ def send_feedback_email(api_key, feedback):
269
  """
270
 
271
  # Header with Rating
 
 
 
 
 
 
 
 
 
 
272
  header_rating = f"""
273
  <div style="text-align: center; margin-bottom: 40px; padding: 20px; background: linear-gradient(180deg, #fff 0%, #fafafa 100%); border-radius: 20px;">
274
  <h2 style="color: #8b5e3c; margin: 0; font-size: 26px; font-weight: 900; letter-spacing: -1px;">UrbanFlow Intelligence</h2>
275
  <div style="margin-top: 15px; font-size: 22px; color: #c89a6c; letter-spacing: 4px;">{'★' * rating}{'☆' * (5-rating)}</div>
276
  <p style="color: #aaa; font-size: 10px; text-transform: uppercase; letter-spacing: 2px; margin-top: 10px; font-weight: 700;">Overall Experience: {rating}/5 Stars</p>
 
277
  </div>
278
  """
279
 
@@ -289,45 +345,80 @@ def send_feedback_email(api_key, feedback):
289
  {detailed_feedback_html}
290
  """
291
 
292
- html_body = f"""
293
- <!DOCTYPE html>
294
- <html>
295
- <head>
296
- <link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;600;700;800;900&display=swap" rel="stylesheet">
297
- </head>
298
- <body style="margin: 0; padding: 0; background-color: #ffffff;">
299
- <div style="font-family: 'Montserrat', sans-serif; color: #333; max-width: 600px; margin: 40px auto; padding: 0 20px;">
300
- {final_content}
301
-
302
- <div style="margin-top: 60px; padding-top: 30px; border-top: 1px solid #eee; text-align: center;">
303
- <p style="font-size: 10px; color: #bbb; text-transform: uppercase; letter-spacing: 2px; font-weight: 700; margin: 0;">Inference Engine Feedback Capture</p>
304
- <p style="font-size: 9px; color: #ddd; margin-top: 5px;">&copy; 2026 UrbanFlow. All rights reserved.</p>
305
- </div>
306
- </div>
307
- </body>
308
- </html>
309
- """
310
 
311
  resend.Emails.send({
312
  "from": "UrbanFlow <onboarding@resend.dev>",
313
  "to": "support.urbanflow365@gmail.com",
314
- "subject": f"Feedback: {fb_type} - {rating}/5 Stars",
315
  "html": html_body
316
  })
317
  print(f"[BACKEND] Feedback email successfully transmitted via Resend.")
318
  except Exception as e:
319
  print(f"[BACKEND] Resend Error: {str(e)}")
320
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
321
  @app.post("/api/feedback")
322
  async def submit_feedback(background_tasks: BackgroundTasks, request_data: dict = None):
323
  from datetime import datetime, timezone
324
  feedback = request_data or {}
325
- entries = []
326
- if FEEDBACK_PATH.exists():
327
- entries = json.loads(FEEDBACK_PATH.read_text())
328
  feedback["timestamp"] = datetime.now(timezone.utc).isoformat()
329
- entries.append(feedback)
330
- FEEDBACK_PATH.write_text(json.dumps(entries, indent=2))
 
 
 
 
 
 
 
 
 
 
331
 
332
  # Trigger Email via Resend if API key is present
333
  resend_key = os.getenv("RESEND_API_KEY")
@@ -395,6 +486,7 @@ async def ws_run(ws: WebSocket):
395
  result["export_json"] = data.get("export_json", False)
396
  result["export_csv"] = data.get("export_csv", False)
397
  run_results[video_id] = result
 
398
  await ws.send_text(json.dumps({
399
  "done": True,
400
  "video_id": video_id,
 
8
  import zipfile
9
 
10
  import cv2
11
+ import time
12
+ from fastapi import FastAPI, WebSocket, UploadFile, File, BackgroundTasks, Request
13
+ from fastapi.responses import FileResponse, Response, JSONResponse
14
  from fastapi.staticfiles import StaticFiles
15
+ from starlette.middleware.base import BaseHTTPMiddleware
16
  import resend
17
 
18
  from model import load_model
19
  from config import get_optimal_config
20
  from engine import run
21
  from pcu import MODEL_CLASSES
22
+ from auth import verify_google_token, get_or_create_user, set_username, GOOGLE_CLIENT_ID
23
  from visualize import generate_all
24
 
25
  BUSINESS_MAP = {
 
46
  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
66
 
67
  @asynccontextmanager
68
  async def lifespan(app: FastAPI):
 
82
 
83
  app = FastAPI(lifespan=lifespan)
84
 
85
+ class SecurityHeaders(BaseHTTPMiddleware):
86
+ async def dispatch(self, request, call_next):
87
+ response = await call_next(request)
88
+ # response.headers["X-Frame-Options"] = "SAMEORIGIN" # Removed for HF Spaces compatibility
89
+ response.headers["X-Content-Type-Options"] = "nosniff"
90
+ response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
91
+ return response
92
+
93
+ app.add_middleware(SecurityHeaders)
94
 
95
  @app.get("/")
96
  def index():
 
98
 
99
 
100
  @app.get("/vehicles.html")
101
+ @app.get("/vehicles")
102
  def vehicles():
103
  return FileResponse(FRONTEND / "vehicles.html")
104
 
105
 
106
  @app.post("/upload")
107
+ async def upload(request: Request, file: UploadFile = File(...)):
108
+ client_ip = request.client.host if request.client else "unknown"
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
 
 
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}")
 
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()):
194
+ return JSONResponse({"error": "Invalid path"}, status_code=400)
195
  if not path.exists():
196
+ return JSONResponse({"error": "File not found"}, status_code=404)
197
  media = "image/png"
198
  if name.endswith(".pdf"):
199
  media = "application/pdf"
 
213
  base_path = REPORT_DIR / video_id
214
  if not base_path.exists():
215
  print(f"[BACKEND] Error: {base_path} not found")
216
+ return JSONResponse({"error": f"Report directory not found for {video_id}"}, status_code=404)
217
 
218
  try:
219
  zip_filename = f"bundle_{video_id}.zip"
 
223
  zip_path.unlink()
224
 
225
  print(f"[BACKEND] Creating ZIP: {zip_path}")
226
+ # shutil.make_archive adds the .zip extension, so we strip it from the target path
227
+ shutil.make_archive(str(REPORT_DIR / f"bundle_{video_id}"), 'zip', str(base_path))
 
 
 
 
228
 
229
  if not zip_path.exists():
230
  raise Exception("Zip file was not created")
 
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"
 
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"""
321
+ <div style="margin-top: 14px; padding: 10px 20px; background: #fff; border: 1px solid #eee; border-radius: 10px; display: inline-block;">
322
+ <span style="font-size: 9px; font-weight: 800; color: #999; text-transform: uppercase; letter-spacing: 1px;">Submitted by</span><br>
323
+ <a href="mailto:{user_email}" style="color: #8b5e3c; font-size: 13px; font-weight: 700; text-decoration: none;">{user_email}</a>
324
+ </div>
325
+ """
326
+
327
  header_rating = f"""
328
  <div style="text-align: center; margin-bottom: 40px; padding: 20px; background: linear-gradient(180deg, #fff 0%, #fafafa 100%); border-radius: 20px;">
329
  <h2 style="color: #8b5e3c; margin: 0; font-size: 26px; font-weight: 900; letter-spacing: -1px;">UrbanFlow Intelligence</h2>
330
  <div style="margin-top: 15px; font-size: 22px; color: #c89a6c; letter-spacing: 4px;">{'★' * rating}{'☆' * (5-rating)}</div>
331
  <p style="color: #aaa; font-size: 10px; text-transform: uppercase; letter-spacing: 2px; margin-top: 10px; font-weight: 700;">Overall Experience: {rating}/5 Stars</p>
332
+ {user_email_html}
333
  </div>
334
  """
335
 
 
345
  {detailed_feedback_html}
346
  """
347
 
348
+ template_path = BASE / "backend" / "email_template.html"
349
+ html_template = template_path.read_text(encoding="utf-8") if template_path.exists() else "{final_content}"
350
+ html_body = html_template.format(final_content=final_content)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
351
 
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.")
359
  except Exception as e:
360
  print(f"[BACKEND] Resend Error: {str(e)}")
361
 
362
+ # =========== Auth Endpoints ===========
363
+
364
+ @app.get("/api/auth/client-id")
365
+ def auth_client_id():
366
+ if not GOOGLE_CLIENT_ID:
367
+ return JSONResponse({"error": "GOOGLE_CLIENT_ID not configured"}, status_code=500)
368
+ return {"client_id": GOOGLE_CLIENT_ID}
369
+
370
+
371
+ @app.post("/api/auth/verify")
372
+ async def auth_verify(request_data: dict):
373
+ credential = request_data.get("credential", "")
374
+ if not credential:
375
+ return JSONResponse({"error": "Missing credential"}, status_code=400)
376
+ try:
377
+ token_info = verify_google_token(credential)
378
+ except ValueError as e:
379
+ return JSONResponse({"error": str(e)}, status_code=401)
380
+ user = get_or_create_user(token_info["email"], token_info["name"], token_info["picture"])
381
+ return user
382
+
383
+
384
+ @app.post("/api/auth/onboard")
385
+ async def auth_onboard(request_data: dict):
386
+ email = request_data.get("email", "")
387
+ username = request_data.get("username", "")
388
+ if not email or not username:
389
+ return JSONResponse({"error": "Email and username required"}, status_code=400)
390
+ ok = set_username(email, username)
391
+ if not ok:
392
+ return JSONResponse({"error": "User not found"}, status_code=404)
393
+ return {"status": "ok"}
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
+
402
+
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):
412
+ entries = []
413
+ if FEEDBACK_PATH.exists():
414
+ try:
415
+ entries = json.loads(FEEDBACK_PATH.read_text())
416
+ except Exception:
417
+ pass
418
+ entries.append(fb)
419
+ FEEDBACK_PATH.write_text(json.dumps(entries, indent=2))
420
+
421
+ background_tasks.add_task(write_feedback, feedback)
422
 
423
  # Trigger Email via Resend if API key is present
424
  resend_key = os.getenv("RESEND_API_KEY")
 
486
  result["export_json"] = data.get("export_json", False)
487
  result["export_csv"] = data.get("export_csv", False)
488
  run_results[video_id] = result
489
+ evict_old(run_results)
490
  await ws.send_text(json.dumps({
491
  "done": True,
492
  "video_id": video_id,
frontend/css/auth.css ADDED
@@ -0,0 +1,323 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================
2
+ UrbanFlow — auth.css
3
+ Styles for auth overlay, onboarding, profile,
4
+ and logout confirmation. Matches the existing
5
+ design system (dark theme, #c89a6c accent).
6
+ ============================================ */
7
+
8
+ /* ---- Auth Overlay (fullscreen backdrop) ---- */
9
+ .auth-overlay {
10
+ display: none;
11
+ position: fixed;
12
+ inset: 0;
13
+ background: rgba(0, 0, 0, 0.88);
14
+ z-index: 10000;
15
+ align-items: center;
16
+ justify-content: center;
17
+ padding: 24px;
18
+ backdrop-filter: blur(6px);
19
+ -webkit-backdrop-filter: blur(6px);
20
+ }
21
+
22
+ .auth-card {
23
+ background: #0a0a0a;
24
+ border: 1px solid #2a2a2a;
25
+ border-radius: 18px;
26
+ max-width: 400px;
27
+ width: 100%;
28
+ padding: 40px 32px;
29
+ text-align: center;
30
+ animation: authFadeIn 0.3s ease;
31
+ }
32
+
33
+ @keyframes authFadeIn {
34
+ from { opacity: 0; transform: translateY(12px); }
35
+ to { opacity: 1; transform: translateY(0); }
36
+ }
37
+
38
+ .auth-card-header {
39
+ margin-bottom: 28px;
40
+ }
41
+
42
+ .auth-title {
43
+ color: #f0ece6;
44
+ font-size: 1.4rem;
45
+ font-weight: 800;
46
+ font-family: 'Montserrat', sans-serif;
47
+ margin: 0 0 6px;
48
+ }
49
+
50
+ .auth-subtitle {
51
+ color: #777;
52
+ font-size: 12px;
53
+ font-weight: 500;
54
+ margin: 0;
55
+ line-height: 1.6;
56
+ }
57
+
58
+ .auth-google-btn {
59
+ display: flex;
60
+ justify-content: center;
61
+ margin: 24px 0 16px;
62
+ }
63
+
64
+ .auth-error {
65
+ color: #ef4444;
66
+ font-size: 11px;
67
+ font-weight: 600;
68
+ margin-top: 12px;
69
+ }
70
+
71
+ .auth-footer {
72
+ color: #555;
73
+ font-size: 10px;
74
+ margin-top: 20px;
75
+ }
76
+
77
+ .auth-link {
78
+ color: #c89a6c;
79
+ background: none;
80
+ border: none;
81
+ font-weight: 700;
82
+ cursor: pointer;
83
+ text-decoration: underline;
84
+ text-underline-offset: 3px;
85
+ font-size: inherit;
86
+ padding: 0;
87
+ }
88
+
89
+ .auth-link:hover {
90
+ color: #f0ece6;
91
+ }
92
+
93
+ /* ---- Onboarding Form ---- */
94
+ .auth-avatar {
95
+ width: 56px;
96
+ height: 56px;
97
+ border-radius: 50%;
98
+ margin: 0 auto 16px;
99
+ border: 2px solid #2a2a2a;
100
+ display: block;
101
+ }
102
+
103
+ .auth-label {
104
+ display: block;
105
+ text-align: left;
106
+ color: #a89f97;
107
+ font-size: 10px;
108
+ font-weight: 800;
109
+ text-transform: uppercase;
110
+ letter-spacing: 1px;
111
+ margin-bottom: 8px;
112
+ }
113
+
114
+ .auth-input {
115
+ width: 100%;
116
+ background: #111;
117
+ border: 1px solid #2a2a2a;
118
+ border-radius: 10px;
119
+ padding: 12px 16px;
120
+ color: #f0ece6;
121
+ font-family: 'Montserrat', sans-serif;
122
+ font-size: 14px;
123
+ font-weight: 600;
124
+ outline: none;
125
+ transition: border-color 0.2s;
126
+ box-sizing: border-box;
127
+ }
128
+
129
+ .auth-input:focus {
130
+ border-color: #c89a6c;
131
+ }
132
+
133
+ .auth-input::placeholder {
134
+ color: #555;
135
+ font-weight: 400;
136
+ }
137
+
138
+ .auth-onboard-form {
139
+ text-align: left;
140
+ }
141
+
142
+ .auth-submit-btn {
143
+ width: 100%;
144
+ margin-top: 18px;
145
+ padding: 12px 24px;
146
+ background: #c89a6c;
147
+ color: #000;
148
+ border: none;
149
+ border-radius: 10px;
150
+ font-family: 'Montserrat', sans-serif;
151
+ font-size: 13px;
152
+ font-weight: 800;
153
+ text-transform: uppercase;
154
+ letter-spacing: 1px;
155
+ cursor: pointer;
156
+ transition: opacity 0.2s, transform 0.1s;
157
+ }
158
+
159
+ .auth-submit-btn:hover {
160
+ opacity: 0.9;
161
+ }
162
+
163
+ .auth-submit-btn:active {
164
+ transform: scale(0.97);
165
+ }
166
+
167
+ .auth-submit-btn:disabled {
168
+ opacity: 0.5;
169
+ cursor: not-allowed;
170
+ }
171
+
172
+ .auth-cancel-btn {
173
+ padding: 10px 24px;
174
+ background: transparent;
175
+ color: #a89f97;
176
+ border: 1px solid #2a2a2a;
177
+ border-radius: 10px;
178
+ font-family: 'Montserrat', sans-serif;
179
+ font-size: 12px;
180
+ font-weight: 700;
181
+ cursor: pointer;
182
+ transition: border-color 0.2s, color 0.2s;
183
+ }
184
+
185
+ .auth-cancel-btn:hover {
186
+ border-color: #555;
187
+ color: #f0ece6;
188
+ }
189
+
190
+ /* ---- Profile Section (Desktop Sidebar) ---- */
191
+ .sidebar-profile {
192
+ display: flex;
193
+ align-items: center;
194
+ gap: 10px;
195
+ padding: 12px 16px;
196
+ border-bottom: 1px solid #1a1a1a;
197
+ margin-bottom: 8px;
198
+ }
199
+
200
+ .sidebar-profile-pic {
201
+ width: 32px;
202
+ height: 32px;
203
+ border-radius: 50%;
204
+ border: 1.5px solid #2a2a2a;
205
+ flex-shrink: 0;
206
+ object-fit: cover;
207
+ }
208
+
209
+ .sidebar-profile-info {
210
+ flex: 1;
211
+ min-width: 0;
212
+ }
213
+
214
+ .sidebar-profile-name {
215
+ color: #f0ece6;
216
+ font-size: 12px;
217
+ font-weight: 700;
218
+ white-space: nowrap;
219
+ overflow: hidden;
220
+ text-overflow: ellipsis;
221
+ }
222
+
223
+ .sidebar-profile-email {
224
+ color: #666;
225
+ font-size: 9px;
226
+ font-weight: 500;
227
+ white-space: nowrap;
228
+ overflow: hidden;
229
+ text-overflow: ellipsis;
230
+ }
231
+
232
+ .sidebar-signout-btn {
233
+ background: none;
234
+ border: none;
235
+ color: #555;
236
+ font-size: 14px;
237
+ cursor: pointer;
238
+ padding: 4px;
239
+ flex-shrink: 0;
240
+ transition: color 0.2s;
241
+ }
242
+
243
+ .sidebar-signout-btn:hover {
244
+ color: #ef4444;
245
+ }
246
+
247
+ /* ---- Profile in Mobile Three-Dot Menu ---- */
248
+ .mobile-profile-row {
249
+ display: flex;
250
+ align-items: center;
251
+ gap: 10px;
252
+ padding: 10px 16px;
253
+ border-bottom: 1px solid #1a1a1a;
254
+ }
255
+
256
+ .mobile-profile-pic {
257
+ width: 32px;
258
+ height: 32px;
259
+ border-radius: 50%;
260
+ border: 1px solid #2a2a2a;
261
+ flex-shrink: 0;
262
+ object-fit: cover;
263
+ }
264
+
265
+ .mobile-profile-name {
266
+ color: #f0ece6;
267
+ font-size: 11px;
268
+ font-weight: 700;
269
+ white-space: nowrap;
270
+ overflow: hidden;
271
+ text-overflow: ellipsis;
272
+ flex: 1;
273
+ }
274
+
275
+ .mobile-signout-btn {
276
+ width: 100%;
277
+ text-align: left;
278
+ padding: 10px 16px;
279
+ color: #ef4444;
280
+ font-size: 10px;
281
+ font-weight: 800;
282
+ text-transform: uppercase;
283
+ letter-spacing: 1px;
284
+ background: none;
285
+ border: none;
286
+ border-top: 1px solid #1a1a1a;
287
+ cursor: pointer;
288
+ transition: background 0.2s;
289
+ }
290
+
291
+ .mobile-signout-btn:hover {
292
+ background: #111;
293
+ }
294
+
295
+ /* ---- Custom "Continue with Google" Button ---- */
296
+ .gsi-custom-btn {
297
+ display: inline-flex;
298
+ align-items: center;
299
+ gap: 12px;
300
+ background: #1a1a1a;
301
+ border: 1px solid #333;
302
+ border-radius: 24px;
303
+ padding: 12px 28px;
304
+ color: #f0ece6;
305
+ font-family: 'Montserrat', sans-serif;
306
+ font-size: 13px;
307
+ font-weight: 700;
308
+ cursor: pointer;
309
+ transition: background 0.2s, border-color 0.2s, transform 0.1s;
310
+ white-space: nowrap;
311
+ }
312
+
313
+ .gsi-custom-btn:hover {
314
+ background: #222;
315
+ border-color: #555;
316
+ }
317
+
318
+ .gsi-custom-btn:active {
319
+ transform: scale(0.97);
320
+ }
321
+
322
+ /* ---- Utility ---- */
323
+ .hidden { display: none !important; }
frontend/css/initial.css CHANGED
@@ -29,10 +29,10 @@ body {
29
  color: var(--t1);
30
  margin: 0;
31
  -webkit-tap-highlight-color: transparent;
32
- scrollbar-width: none; /* Firefox */
33
  }
34
  body::-webkit-scrollbar {
35
- display: none; /* Chrome, Safari, Edge */
36
  }
37
 
38
  /* ---- Fade animation ---- */
@@ -44,20 +44,27 @@ body::-webkit-scrollbar {
44
  to { opacity: 1; transform: translateY(0); }
45
  }
46
 
47
- /* ---- Feature List Animation ---- */
48
- @keyframes fadeSlideIn {
49
- from { opacity: 0; transform: translateX(-8px); }
50
- to { opacity: 1; transform: translateX(0); }
 
 
 
 
 
51
  }
52
- .hero-text-section ul li {
53
- opacity: 0;
54
- animation: fadeSlideIn 0.4s ease-out forwards;
 
 
 
 
 
 
 
55
  }
56
- .hero-text-section ul li:nth-child(1) { animation-delay: 0.1s; }
57
- .hero-text-section ul li:nth-child(2) { animation-delay: 0.2s; }
58
- .hero-text-section ul li:nth-child(3) { animation-delay: 0.3s; }
59
- .hero-text-section ul li:nth-child(4) { animation-delay: 0.4s; }
60
- .hero-text-section ul li:nth-child(5) { animation-delay: 0.5s; }
61
 
62
  /* ---- Card ---- */
63
  .traffic-dynamics-card {
@@ -93,8 +100,8 @@ body::-webkit-scrollbar {
93
  pointer-events: none;
94
  }
95
  @keyframes borderShimmer {
96
- 0% { background-position: 200% 0; }
97
- 100% { background-position: -200% 0; }
98
  }
99
  .traffic-dynamics-card:hover::before {
100
  animation-duration: 1.2s;
@@ -184,19 +191,23 @@ body::-webkit-scrollbar {
184
  #upload-bar {
185
  transition: width 0.3s ease-out;
186
  position: relative;
 
187
  }
188
 
189
  /* Shimmer overlay */
190
  #upload-bar::after {
191
  content: '';
192
  position: absolute;
193
- top: 0; right: 0; bottom: 0; left: 0;
194
- background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
195
- transform: translateX(-100%);
196
- animation: shimmer 1.5s infinite;
 
 
197
  }
198
  @keyframes shimmer {
199
- 100% { transform: translateX(100%); }
 
200
  }
201
 
202
  /* =============================================
@@ -285,6 +296,7 @@ body::-webkit-scrollbar {
285
  overflow-x: hidden !important;
286
  }
287
 
 
288
  /* ---- Header — compact logo ---- */
289
  header {
290
  margin-top: 2rem !important;
@@ -411,19 +423,21 @@ body::-webkit-scrollbar {
411
  max-height: 40vh; /* Keeps continue button above fold */
412
  }
413
 
414
- /* ---- Proceed button — full width ---- */
415
  #btn-proceed {
416
- width: 100% !important;
 
417
  padding: 14px !important;
418
  font-size: 0.9rem !important;
419
  border-radius: 999px !important;
420
  }
421
 
422
- /* ---- Reset / Back buttons ---- */
423
  button[onclick="resetCanvas()"],
424
  button[onclick="showStep('modules')"] {
425
  font-size: 11px !important;
426
- padding: 10px 0 !important;
 
427
  min-height: 40px;
428
  }
429
 
@@ -446,6 +460,8 @@ body::-webkit-scrollbar {
446
  }
447
 
448
 
 
 
449
  /* =============================================
450
  TOUCH: remove drag-related cursor on mobile
451
  ============================================= */
@@ -473,4 +489,4 @@ body::-webkit-scrollbar {
473
  #drawing-canvas {
474
  cursor: crosshair;
475
  }
476
- }
 
29
  color: var(--t1);
30
  margin: 0;
31
  -webkit-tap-highlight-color: transparent;
32
+ scrollbar-width: none;
33
  }
34
  body::-webkit-scrollbar {
35
+ display: none;
36
  }
37
 
38
  /* ---- Fade animation ---- */
 
44
  to { opacity: 1; transform: translateY(0); }
45
  }
46
 
47
+ /* ---- Hero Description Block ---- */
48
+ .hero-desc {
49
+ max-width: 520px;
50
+ }
51
+ .hero-desc-lead {
52
+ font-size: clamp(1rem, 1.8vw, 1.15rem);
53
+ font-weight: 700;
54
+ letter-spacing: 0.01em;
55
+ line-height: 1.4;
56
  }
57
+ .hero-desc-body {
58
+ font-size: clamp(0.82rem, 1.3vw, 0.95rem);
59
+ font-weight: 500;
60
+ line-height: 1.8;
61
+ letter-spacing: 0.01em;
62
+ }
63
+ @media (max-width: 640px) {
64
+ .hero-desc { max-width: 100%; }
65
+ .hero-desc-lead { font-size: 1rem; }
66
+ .hero-desc-body { font-size: 0.85rem; line-height: 1.75; }
67
  }
 
 
 
 
 
68
 
69
  /* ---- Card ---- */
70
  .traffic-dynamics-card {
 
100
  pointer-events: none;
101
  }
102
  @keyframes borderShimmer {
103
+ 0% { background-position: 0% 50%; }
104
+ 100% { background-position: 200% 50%; }
105
  }
106
  .traffic-dynamics-card:hover::before {
107
  animation-duration: 1.2s;
 
191
  #upload-bar {
192
  transition: width 0.3s ease-out;
193
  position: relative;
194
+ overflow: hidden;
195
  }
196
 
197
  /* Shimmer overlay */
198
  #upload-bar::after {
199
  content: '';
200
  position: absolute;
201
+ top: 0;
202
+ bottom: 0;
203
+ left: -60%;
204
+ width: 60%;
205
+ background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.45), transparent);
206
+ animation: shimmer 1.4s ease-in-out infinite;
207
  }
208
  @keyframes shimmer {
209
+ 0% { left: -60%; }
210
+ 100% { left: 160%; }
211
  }
212
 
213
  /* =============================================
 
296
  overflow-x: hidden !important;
297
  }
298
 
299
+
300
  /* ---- Header — compact logo ---- */
301
  header {
302
  margin-top: 2rem !important;
 
423
  max-height: 40vh; /* Keeps continue button above fold */
424
  }
425
 
426
+ /* ---- Proceed button — narrowed further ---- */
427
  #btn-proceed {
428
+ width: 60% !important;
429
+ max-width: 220px;
430
  padding: 14px !important;
431
  font-size: 0.9rem !important;
432
  border-radius: 999px !important;
433
  }
434
 
435
+ /* ---- Reset / Back buttons — fixed padding for rounded style ---- */
436
  button[onclick="resetCanvas()"],
437
  button[onclick="showStep('modules')"] {
438
  font-size: 11px !important;
439
+ padding: 10px 24px !important;
440
+ width: auto !important;
441
  min-height: 40px;
442
  }
443
 
 
460
  }
461
 
462
 
463
+
464
+
465
  /* =============================================
466
  TOUCH: remove drag-related cursor on mobile
467
  ============================================= */
 
489
  #drawing-canvas {
490
  cursor: crosshair;
491
  }
492
+ }
frontend/css/shared.css ADDED
@@ -0,0 +1,281 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* =============================================
2
+ UrbanFlow — shared.css
3
+ Reusable component classes shared across all pages.
4
+ Single source of truth for the design system.
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 {
18
+ display: none;
19
+ position: fixed;
20
+ inset: 0;
21
+ background: rgba(0, 0, 0, 0.85);
22
+ z-index: 10001;
23
+ align-items: center;
24
+ justify-content: center;
25
+ padding: 24px;
26
+ }
27
+
28
+ /* ---- Modal Card ---- */
29
+ .modal-card {
30
+ background: #0a0a0a;
31
+ border: 1px solid #2a2a2a;
32
+ border-radius: 14px;
33
+ max-width: 480px;
34
+ width: 100%;
35
+ padding: 32px;
36
+ position: relative;
37
+ max-height: 80vh;
38
+ overflow-y: auto;
39
+ }
40
+ .modal-card-sm {
41
+ max-width: 380px;
42
+ padding: 28px;
43
+ max-height: none;
44
+ overflow-y: visible;
45
+ }
46
+
47
+ .modal-close-btn {
48
+ position: absolute;
49
+ top: 16px;
50
+ right: 18px;
51
+ background: none;
52
+ border: none;
53
+ color: #a89f97;
54
+ font-size: 18px;
55
+ cursor: pointer;
56
+ transition: color 0.2s;
57
+ }
58
+ .modal-close-btn:hover {
59
+ color: #f0ece6;
60
+ }
61
+
62
+ .modal-title {
63
+ color: #f0ece6;
64
+ font-size: 1.1rem;
65
+ font-weight: 700;
66
+ margin-bottom: 8px;
67
+ }
68
+
69
+ .modal-subtitle {
70
+ color: #a89f97;
71
+ font-size: 11px;
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;
186
+ justify-content: space-between;
187
+ align-items: center;
188
+ padding: 6px 0;
189
+ border-bottom: 1px solid #1a1a1a;
190
+ }
191
+ .shortcut-row:last-child {
192
+ border-bottom: none;
193
+ }
194
+ .shortcut-label {
195
+ color: #a89f97;
196
+ font-size: 11px;
197
+ font-weight: 500;
198
+ }
199
+ .kbd-key {
200
+ background: #1a1a1a;
201
+ color: #c89a6c;
202
+ font-size: 11px;
203
+ font-weight: 700;
204
+ padding: 3px 10px;
205
+ border-radius: 6px;
206
+ border: 1px solid #2a2a2a;
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; }
frontend/css/vehicles.css CHANGED
@@ -1,1410 +1,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
- --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
- /* Main — no 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
- /* Settings — 2 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
- /* Post-process cards */
737
- #post-process-cards {
738
- grid-template-columns: repeat(2, 1fr) !important;
739
- }
740
-
741
- /* Insights panel */
742
- #insights-panel .grid {
743
- grid-template-columns: repeat(2, 1fr) !important;
744
- }
745
- }
746
-
747
- /* =============================================
748
- MOBILE (< 1024px) — full mobile overhaul
749
- ============================================= */
750
- @media (max-width: 1023px) {
751
-
752
- /* --- Hide desktop sidebar --- */
753
- aside.w-60 {
754
- display: none !important;
755
- }
756
-
757
- /* --- Hide old top mobile nav bar --- */
758
- .mobile-nav {
759
- display: none !important;
760
- }
761
-
762
- /* --- Body layout --- */
763
- body {
764
- height: 100dvh;
765
- /* dynamic viewport height — accounts for mobile browser chrome */
766
- overflow: hidden;
767
- }
768
-
769
- /* --- Main content — room for top and bottom nav --- */
770
- main {
771
- padding: 70px 12px calc(var(--mob-nav-h) + 8px) 12px !important;
772
- gap: 12px !important;
773
- display: flex !important;
774
- flex-direction: column !important;
775
- height: 100dvh !important;
776
- }
777
-
778
- /* --- Tab Scrolling Fixes — force flex-1 to push progress bar down --- */
779
- #tab-about,
780
- #tab-overview,
781
- #tab-run-details,
782
- #tab-reports,
783
- #tab-settings,
784
- #tab-help,
785
- #tab-feedback {
786
- flex: 1 !important;
787
- min-height: 0 !important;
788
- padding-bottom: 20px !important;
789
- overscroll-behavior: contain;
790
- -webkit-overflow-scrolling: touch;
791
- overflow-y: auto !important;
792
- }
793
-
794
- /* --- About tab specific spacing --- */
795
- #tab-about .space-y-8 {
796
- gap: 16px !important;
797
- }
798
-
799
- #tab-about .pt-8 {
800
- padding-top: 16px !important;
801
- }
802
-
803
- #tab-overview:not(.hidden) {
804
- display: flex !important;
805
- flex-direction: column !important;
806
- overflow-y: auto !important;
807
- overflow-x: hidden !important;
808
- -webkit-overflow-scrolling: touch;
809
- overscroll-behavior: contain;
810
- padding-bottom: calc(var(--mob-nav-h) + 24px) !important;
811
- gap: 16px !important;
812
- }
813
-
814
- #tab-overview>div:not(#stats-empty-state) {
815
- grid-column: span 1 !important;
816
- min-height: 280px;
817
- flex-shrink: 0;
818
- }
819
-
820
- .stats-empty-overlay {
821
- position: fixed !important;
822
- top: 58px;
823
- /* below mobile top bar */
824
- left: 0;
825
- right: 0;
826
- bottom: var(--mob-nav-h);
827
- height: auto !important;
828
- z-index: 100;
829
- background: rgba(0, 0, 0, 0.98);
830
- display: flex;
831
- flex-direction: column;
832
- align-items: center;
833
- justify-content: center;
834
- }
835
-
836
- /* CRITICAL: hide overlay when its parent tab is hidden */
837
- #tab-overview.hidden .stats-empty-overlay {
838
- display: none !important;
839
- }
840
-
841
- /* Hide charts when curtain is up to prevent scroll jank */
842
- #tab-overview.curtain-active {
843
- overflow: hidden !important;
844
- }
845
-
846
- /* --- Settings tab — tighter layout --- */
847
- #tab-settings>div[class*="grid"] {
848
- display: flex !important;
849
- flex-direction: column !important;
850
- gap: 12px !important;
851
- }
852
-
853
- #tab-settings {
854
- overflow-x: hidden !important;
855
- }
856
-
857
- /* Collapse chip panel completely when not shown — removes gap */
858
- #chip-selector.hidden-chip-container {
859
- display: none !important;
860
- margin: 0 !important;
861
- padding: 0 !important;
862
- height: 0 !important;
863
- }
864
-
865
- /* When visible, give it breathing room */
866
- #chip-selector:not(.hidden-chip-container) {
867
- display: flex !important;
868
- flex-wrap: wrap !important;
869
- gap: 8px !important;
870
- margin-top: 12px !important;
871
- padding: 0 !important;
872
- }
873
-
874
- /* Ensure all s-row items are uniform flex rows */
875
- .s-row {
876
- display: flex !important;
877
- flex-direction: row !important;
878
- flex-wrap: nowrap !important;
879
- align-items: center !important;
880
- justify-content: space-between !important;
881
- padding: 14px 0 !important;
882
- border-bottom: 1px solid #1a1a1a !important;
883
- gap: 12px !important;
884
- }
885
-
886
- .s-row:last-child {
887
- border-bottom: none !important;
888
- }
889
-
890
- /* Never let mobile flex override Tailwind .hidden utility */
891
- .s-row.hidden {
892
- display: none !important;
893
- }
894
-
895
- /* chip-selector sits as a sibling below the annotated s-row on mobile */
896
- #chip-selector:not(.hidden-chip-container) {
897
- margin-top: 0 !important;
898
- border-top: none !important;
899
- padding-top: 0 !important;
900
- padding-bottom: 12px !important;
901
- }
902
-
903
- /* Lock toggle in annotated row — must never wrap or shrink */
904
- .s-row[data-param="annotated"]>.toggle-track {
905
- flex-shrink: 0 !important;
906
- flex-grow: 0 !important;
907
- flex-basis: 36px !important;
908
- width: 36px !important;
909
- min-width: 36px !important;
910
- align-self: center !important;
911
- }
912
-
913
- /* Label side must absorb remaining space and never overflow */
914
- .s-row[data-param="annotated"]>div:first-child {
915
- flex: 1 1 0 !important;
916
- min-width: 0 !important;
917
- overflow: hidden !important;
918
- }
919
-
920
- .s-stepper {
921
- width: 140px !important;
922
- /* Compact fixed width */
923
- scale: 0.9;
924
- transform-origin: right;
925
- display: inline-flex !important;
926
- }
927
-
928
- .toggle-track {
929
- width: 36px !important;
930
- scale: 0.9;
931
- transform-origin: right;
932
- }
933
-
934
- @media (max-width: 480px) {
935
- .s-row {
936
- flex-direction: row !important;
937
- flex-wrap: nowrap !important;
938
- align-items: center !important;
939
- justify-content: space-between !important;
940
- gap: 12px !important;
941
- padding: 10px 16px !important;
942
- width: 100% !important;
943
- box-sizing: border-box !important;
944
- }
945
-
946
- /* chip panel below annotated row — remove extra top gap */
947
- #chip-selector:not(.hidden-chip-container) {
948
- margin-top: 0 !important;
949
- padding-bottom: 12px !important;
950
- padding-left: 16px !important;
951
- padding-right: 16px !important;
952
- border-top: none !important;
953
- }
954
-
955
- .s-row {
956
- padding: 12px 16px !important;
957
- }
958
-
959
- #tab-run-details .p-8 {
960
- padding: 20px !important;
961
- }
962
-
963
- #run-results-content {
964
- grid-template-columns: 1fr !important;
965
- gap: 16px !important;
966
- }
967
-
968
- #panel-video .flex,
969
- #panel-perf .flex,
970
- #panel-model .flex,
971
- #panel-infer .flex {
972
- padding-bottom: 8px !important;
973
- }
974
-
975
- .s-row .info-wrap {
976
- display: inline-flex !important;
977
- vertical-align: middle;
978
- }
979
-
980
- .s-row>div:first-child {
981
- width: auto !important;
982
- max-width: 75% !important;
983
- flex: 1 !important;
984
- }
985
-
986
- .toggle-track {
987
- width: 36px !important;
988
- min-width: 36px !important;
989
- height: 20px !important;
990
- flex-shrink: 0 !important;
991
- display: block !important;
992
- position: relative !important;
993
- }
994
-
995
- #run-results-card .text-[10px] {
996
- font-size: 9px !important;
997
- letter-spacing: 0.05em !important;
998
- }
999
-
1000
- .s-row>.s-stepper {
1001
- width: 130px !important;
1002
- flex-shrink: 0 !important;
1003
- display: inline-flex !important;
1004
- flex-direction: row !important;
1005
- }
1006
-
1007
- .chip-container {
1008
- display: grid !important;
1009
- grid-template-columns: 1fr 1fr !important;
1010
- gap: 6px !important;
1011
- margin-top: 12px !important;
1012
- padding: 10px !important;
1013
- background: rgba(255, 255, 255, 0.03);
1014
- border-radius: 8px;
1015
- border: 1px solid #1a1a1a;
1016
- width: 100% !important;
1017
- box-sizing: border-box !important;
1018
- }
1019
-
1020
- .chip {
1021
- padding: 6px !important;
1022
- font-size: 9px !important;
1023
- min-height: 32px !important;
1024
- border-radius: 6px !important;
1025
- justify-content: center !important;
1026
- width: 100% !important;
1027
- white-space: nowrap !important;
1028
- }
1029
-
1030
- .s-stepper {
1031
- width: 130px !important;
1032
- min-width: 130px !important;
1033
- display: inline-flex !important;
1034
- flex-direction: row !important;
1035
- align-items: center !important;
1036
- justify-content: space-between !important;
1037
- transform-origin: right !important;
1038
- }
1039
-
1040
- .toggle-track {
1041
- transform-origin: right !important;
1042
- }
1043
- }
1044
-
1045
- /* --- Progress bar wrapper — remove extra margin to fix huge gap --- */
1046
- #progress-bar-wrapper {
1047
- width: 100% !important;
1048
- max-width: 100% !important;
1049
- box-sizing: border-box !important;
1050
- margin-top: auto !important;
1051
- margin-bottom: 4px !important;
1052
- padding: 8px 12px !important;
1053
- flex-direction: column !important;
1054
- align-items: flex-start !important;
1055
- gap: 6px !important;
1056
- position: relative;
1057
- z-index: 10;
1058
- }
1059
-
1060
- #progress-bar-wrapper>div:first-child {
1061
- width: 100% !important;
1062
- flex: 1 !important;
1063
- min-width: 0 !important;
1064
- margin-right: 0 !important;
1065
- }
1066
-
1067
- #progress-bar-wrapper>div:last-child {
1068
- width: 100% !important;
1069
- justify-content: space-between !important;
1070
- font-size: 10px !important;
1071
- }
1072
-
1073
- /* --- All other grids collapse to single column --- */
1074
- .grid-cols-3,
1075
- .grid-cols-2,
1076
- .lg\:grid-cols-2,
1077
- .xl\:grid-cols-3 {
1078
- grid-template-columns: 1fr !important;
1079
- }
1080
-
1081
- /* --- Run details tab --- */
1082
- #run-results-content {
1083
- grid-template-columns: 1fr !important;
1084
- }
1085
-
1086
- #tab-run-details .grid-cols-2,
1087
- #tab-run-details .grid-cols-3 {
1088
- grid-template-columns: 1fr !important;
1089
- }
1090
-
1091
- /* --- Reports grid --- */
1092
- #reports-grid,
1093
- #reports-pending {
1094
- grid-template-columns: 1fr !important;
1095
- }
1096
-
1097
- /* --- About tab grid --- */
1098
- #tab-about .grid.grid-cols-3 {
1099
- grid-template-columns: 1fr !important;
1100
- }
1101
-
1102
- /* --- Post-process cards --- */
1103
- #post-process-cards {
1104
- grid-template-columns: 1fr !important;
1105
- }
1106
-
1107
- /* --- Insights panel --- */
1108
- #insights-panel .grid {
1109
- grid-template-columns: 1fr !important;
1110
- }
1111
-
1112
- /* --- Feedback tab --- */
1113
- #tab-feedback .grid {
1114
- grid-template-columns: 1fr !important;
1115
- }
1116
-
1117
- /* --- About tab cards --- */
1118
- #tab-about .bg-black.border.rounded-xl {
1119
- padding: 20px !important;
1120
- }
1121
-
1122
- /* --- Stepper — ensure full tap area --- */
1123
- .s-stepper button {
1124
- padding: 10px 14px;
1125
- min-width: 40px;
1126
- min-height: 40px;
1127
- }
1128
-
1129
- /* --- s-row label text — allow wrap --- */
1130
- .s-row>div:first-child .text-xs {
1131
- font-size: 11px;
1132
- }
1133
-
1134
- /* --- Help accordion buttons --- */
1135
- #tab-help button.w-full {
1136
- min-height: 52px;
1137
- padding: 14px 16px !important;
1138
- }
1139
-
1140
- /* --- Feedback priority chips grid --- */
1141
- #fb-priorities {
1142
- grid-template-columns: 1fr !important;
1143
- }
1144
-
1145
- /* --- Keyboard shortcut modal --- */
1146
- #appModal-shortcutsModal>div {
1147
- max-width: 95% !important;
1148
- padding: 20px !important;
1149
- }
1150
-
1151
- /* --- Privacy / Terms modals --- */
1152
- [id^="appModal-"]>div {
1153
- max-width: 95% !important;
1154
- max-height: 80dvh !important;
1155
- overflow-y: auto !important;
1156
- }
1157
-
1158
- #tab-overview>div:last-child {
1159
- min-height: 300px !important;
1160
- padding-bottom: 4px !important;
1161
- margin-bottom: 0 !important;
1162
- }
1163
-
1164
- /* --- Vehicle Classification Internal Scroll --- */
1165
- #tab-overview>div:nth-child(4) {
1166
- max-height: 380px !important;
1167
- display: flex !important;
1168
- flex-direction: column !important;
1169
- }
1170
-
1171
- #tab-overview>div:nth-child(4) #class-breakdown {
1172
- flex: 1 !important;
1173
- overflow-y: auto !important;
1174
- min-height: 0 !important;
1175
- }
1176
- }
1177
-
1178
- /* =============================================
1179
- BOTTOM NAVIGATION BAR — mobile only
1180
- ============================================= */
1181
- .mobile-bottom-nav {
1182
- display: none;
1183
- /* hidden by default, shown on mobile */
1184
- position: fixed;
1185
- bottom: 0;
1186
- left: 0;
1187
- right: 0;
1188
- height: 68px;
1189
- background: #000000;
1190
- border-top: 1px solid #1a1a1a;
1191
- z-index: 40;
1192
- align-items: stretch;
1193
- }
1194
-
1195
- .mob-nav-item {
1196
- flex: 1;
1197
- display: flex;
1198
- flex-direction: column;
1199
- align-items: center;
1200
- justify-content: center;
1201
- gap: 3px;
1202
- cursor: pointer;
1203
- color: #444444;
1204
- font-size: 0;
1205
- font-weight: 700;
1206
- text-transform: uppercase;
1207
- letter-spacing: 0.05em;
1208
- transition: color 0.15s ease;
1209
- border: none;
1210
- background: none;
1211
- padding: 8px 2px;
1212
- -webkit-tap-highlight-color: transparent;
1213
- }
1214
-
1215
- .mob-nav-item i {
1216
- font-size: 22px;
1217
- transition: color 0.15s ease;
1218
- }
1219
-
1220
- .mob-nav-item.active {
1221
- color: var(--cocoa-l);
1222
- }
1223
-
1224
- .mob-nav-item.active i {
1225
- color: var(--cocoa-l);
1226
- }
1227
-
1228
- .mob-nav-item:active {
1229
- color: var(--cocoa-xl);
1230
- }
1231
-
1232
- /* Show bottom nav only on mobile */
1233
- @media (max-width: 1023px) {
1234
- .mobile-bottom-nav {
1235
- display: flex !important;
1236
- }
1237
- }
1238
-
1239
- /* =============================================
1240
- MEDIUM TABLET (640px–1023px) adjustments
1241
- ============================================= */
1242
- @media (min-width: 640px) and (max-width: 1023px) {
1243
-
1244
- /* 2-column grids on tablet where it fits */
1245
- #tab-overview>div {
1246
- min-height: 280px;
1247
- }
1248
-
1249
- #reports-grid,
1250
- #reports-pending {
1251
- grid-template-columns: repeat(2, 1fr) !important;
1252
- }
1253
-
1254
- #fb-priorities {
1255
- grid-template-columns: repeat(2, 1fr) !important;
1256
- }
1257
-
1258
- #tab-about .grid.grid-cols-3 {
1259
- grid-template-columns: repeat(2, 1fr) !important;
1260
- }
1261
- }
1262
-
1263
- /* =============================================
1264
- TOUCH DEVICES — remove hover jank
1265
- ============================================= */
1266
- @media (hover: none) and (pointer: coarse) {
1267
- .nav-item-inactive:hover {
1268
- color: #555555 !important;
1269
- background-color: transparent !important;
1270
- }
1271
-
1272
- .chip:hover {
1273
- border-color: #333333;
1274
- }
1275
-
1276
- .chip.active:hover {
1277
- background: var(--cocoa-l);
1278
- }
1279
-
1280
- .s-stepper button:hover {
1281
- background: transparent;
1282
- color: #666666;
1283
- }
1284
-
1285
- /* Make all interactive elements minimum 44px tall */
1286
- button,
1287
- .fb-emoji-btn,
1288
- .mob-nav-item {
1289
- min-height: 44px;
1290
- }
1291
- }
1292
-
1293
- /* ============================================================
1294
- Custom Select Dropdown (uf-select)
1295
- Replaces native <select> to prevent OS picker sheet on mobile
1296
- ============================================================ */
1297
- .uf-select-wrap {
1298
- position: relative;
1299
- display: inline-block;
1300
- min-width: 110px;
1301
- }
1302
-
1303
- .uf-select-wrap.w-full {
1304
- display: block;
1305
- width: 100%;
1306
- }
1307
-
1308
- .uf-select-trigger {
1309
- display: flex;
1310
- align-items: center;
1311
- justify-content: space-between;
1312
- gap: 6px;
1313
- padding: 5px 10px;
1314
- background: #111111;
1315
- border: 1px solid #222222;
1316
- border-radius: 6px;
1317
- font-size: 11px;
1318
- font-weight: 600;
1319
- color: #ffffff;
1320
- cursor: pointer;
1321
- user-select: none;
1322
- -webkit-tap-highlight-color: transparent;
1323
- transition: border-color 0.15s;
1324
- white-space: nowrap;
1325
- }
1326
-
1327
- .uf-select-trigger:hover,
1328
- .uf-select-trigger:active {
1329
- border-color: #444444;
1330
- }
1331
-
1332
- .uf-select-arrow {
1333
- font-size: 9px;
1334
- color: #666666;
1335
- transition: transform 0.2s ease;
1336
- flex-shrink: 0;
1337
- }
1338
-
1339
- .uf-select-arrow-open {
1340
- transform: rotate(180deg);
1341
- }
1342
-
1343
- /* Dropdown panel — opens downward by default */
1344
- .uf-select-dropdown {
1345
- position: absolute;
1346
- top: calc(100% + 4px);
1347
- left: 0;
1348
- min-width: 100%;
1349
- background: #111111;
1350
- border: 1px solid #2a2a2a;
1351
- border-radius: 8px;
1352
- z-index: 9999;
1353
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.8);
1354
- overflow: hidden;
1355
- max-height: 240px;
1356
- overflow-y: auto;
1357
- }
1358
-
1359
- /* Upward variant — anchors above trigger, for bottom-of-screen selects */
1360
- .uf-select-dropdown-up {
1361
- top: auto;
1362
- bottom: calc(100% + 4px);
1363
- }
1364
-
1365
- .uf-select-option {
1366
- padding: 10px 14px;
1367
- font-size: 11px;
1368
- font-weight: 600;
1369
- color: #aaaaaa;
1370
- cursor: pointer;
1371
- transition: background 0.1s, color 0.1s;
1372
- -webkit-tap-highlight-color: transparent;
1373
- }
1374
-
1375
- .uf-select-option:hover,
1376
- .uf-select-option:active {
1377
- background: #1a1a1a;
1378
- color: #ffffff;
1379
- }
1380
-
1381
- .uf-select-option-active {
1382
- color: var(--cocoa-l);
1383
- background: #0a0a0a;
1384
- }
1385
-
1386
- /* Hide scrollbar inside dropdown — options fit within max-height */
1387
- .uf-select-dropdown::-webkit-scrollbar {
1388
- width: 0;
1389
- height: 0;
1390
- }
1391
-
1392
- /* Desktop: Vehicle Classification thin grey scrollbar (matches reference) */
1393
- @media (min-width: 1024px) {
1394
- #class-breakdown::-webkit-scrollbar {
1395
- width: 4px;
1396
- }
1397
-
1398
- #class-breakdown::-webkit-scrollbar-track {
1399
- background: #000000;
1400
- }
1401
-
1402
- #class-breakdown::-webkit-scrollbar-thumb {
1403
- background: #333333;
1404
- border-radius: 4px;
1405
- }
1406
-
1407
- #class-breakdown::-webkit-scrollbar-thumb:hover {
1408
- background: #444444;
1409
- }
1410
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ /* Main — no 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
+ /* Settings — 2 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
+ }
frontend/initial.html CHANGED
@@ -23,11 +23,14 @@
23
  <script src="https://cdn.tailwindcss.com"></script>
24
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
25
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Montserrat:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
 
26
  <link rel="stylesheet" href="css/initial.css">
 
27
  </head>
28
 
29
  <body class="bg-black text-white min-h-screen w-full flex flex-col items-center selection:bg-white selection:text-black overflow-x-hidden">
30
 
 
31
  <header class="mt-16 flex flex-col items-center flex-shrink-0 w-full z-10">
32
  <img src="assets/uf_rf.png" alt="UrbanFlow Logo" class="h-44 md:h-52 w-auto object-contain mb-3">
33
  </header>
@@ -44,22 +47,21 @@
44
  </h1>
45
  <p class="font-bold mb-8 text-sm uppercase tracking-[0.2em] flex items-center justify-center sm:justify-start" style="color:#a89f97">
46
  <span class="core-badge px-3 py-1 rounded-full text-[10px] mr-3">Beta</span>
47
- MVP for traffic intelligence
48
  </p>
49
- <ul class="space-y-4 xl:space-y-5 text-base xl:text-lg font-medium" style="color:#a89f97">
50
- <li class="flex items-center"><i class="fa-solid fa-check mr-3 md:mr-5 text-xl" style="color:#c89a6c"></i> Works with CCTV, webcam or mobile recorded clips</li>
51
- <li class="flex items-center"><i class="fa-solid fa-check mr-3 md:mr-5 text-xl" style="color:#c89a6c"></i> Your Data is secure &amp; private</li>
52
- <li class="flex items-center"><i class="fa-solid fa-check mr-3 md:mr-5 text-xl" style="color:#c89a6c"></i> Built for Indian Roads, conditions &amp; traffic patterns</li>
53
- <li class="flex items-center"><i class="fa-solid fa-check mr-3 md:mr-5 text-xl" style="color:#c89a6c"></i> No Hardware Installation; rapid &amp; scalable analysis.</li>
54
- <li class="flex items-center"><i class="fa-solid fa-check mr-3 md:mr-5 text-xl" style="color:#c89a6c"></i> Tested on real field conditions</li>
55
- </ul>
 
 
56
 
57
  <div class="mt-10 flex justify-center sm:justify-start">
58
- <button onclick="openOnboarding()" class="pr-6 pl-2 py-2 rounded-full transition-all active:scale-95 flex items-center gap-4 hover:border-[#c89a6c] shadow-lg group cursor-pointer" style="background:#050505; border: 1px solid #1a1a1a">
59
- <div class="w-8 h-8 rounded-full flex items-center justify-center transition-transform group-hover:scale-110" style="background:#c89a6c; color:#000">
60
- <i class="fa-solid fa-play text-[10px] ml-0.5"></i>
61
- </div>
62
- <span class="uppercase tracking-widest text-[11px] font-bold" style="color:#c89a6c">Experience Guided Tour</span>
63
  </button>
64
  </div>
65
  </div>
@@ -71,7 +73,7 @@
71
  <h2 class="text-3xl font-bold mb-2 text-center" style="color:#f0ece6">UrbanFlow</h2>
72
  <p class="text-[13px] font-medium mb-8 text-center" style="color:#a89f97">Proceed to analysis</p>
73
  <div class="flex justify-center w-full">
74
- <div onclick="showStep('upload')"
75
  class="group relative border-2 rounded-[2rem] p-8 cursor-pointer hover:-translate-y-1 transition-all duration-300 text-center max-w-sm w-full traffic-dynamics-card">
76
  <div class="absolute top-4 right-6 text-[9px] font-bold px-2.5 py-1 rounded-full uppercase tracking-wider"
77
  style="background:#c89a6c;color:#000">BETA</div>
@@ -121,15 +123,19 @@
121
  </div>
122
  <canvas id="drawing-canvas" class="absolute inset-0 w-full h-full"></canvas>
123
  </div>
 
 
 
 
124
  <div class="flex flex-col items-center gap-3">
125
  <button id="btn-proceed" onclick="startRun()"
126
  class="w-fit px-16 py-3.5 rounded-full font-bold transition-all text-center text-sm shadow-lg hover:scale-105 active:scale-95"
127
- style="background:#c89a6c;color:#000">
128
  Continue &nbsp;&rarr;
129
  </button>
130
  <button onclick="resetCanvas()"
131
- class="text-[10px] font-bold uppercase tracking-widest text-slate-500 hover:text-white transition px-4 py-2 mt-2"
132
- style="background:none;border:none;">Reset </button>
133
  </div>
134
  </div>
135
 
@@ -138,103 +144,44 @@
138
 
139
  <footer class="w-full max-w-[90rem] mx-auto px-10 mt-auto z-10 text-[11px] font-bold uppercase tracking-[0.2em]" style="color:#777">
140
 
141
- <!-- Desktop: Privacy Policy left | T&C center | © right — single row -->
142
- <div class="hidden md:grid md:grid-cols-3 items-center py-6">
143
- <div class="text-left">
144
- <button onclick="openAppModal('privacyModal')" class="hover:text-white transition">Privacy Policy</button>
145
- </div>
146
- <div class="text-center">
147
- <button onclick="openAppModal('termsModal')" class="hover:text-white transition">Terms &amp; Conditions</button>
148
- </div>
149
- <div class="text-right">
150
- &copy; 2026 UrbanFlow. All rights reserved.
151
- </div>
152
- </div>
153
-
154
- <!-- Mobile: Privacy Policy left | T&C right, then © centered below -->
155
- <div class="md:hidden py-4">
156
- <div class="flex items-center justify-between mb-2">
157
- <button onclick="openAppModal('privacyModal')" class="hover:text-white transition">Privacy Policy</button>
158
- <button onclick="openAppModal('termsModal')" class="hover:text-white transition">Terms &amp; Conditions</button>
159
- </div>
160
- <div class="text-center">
161
- &copy; 2026 UrbanFlow. All rights reserved.
162
  </div>
163
  </div>
164
 
165
  </footer>
166
 
 
 
 
167
  <script src="js/initial.js"></script>
168
  <script>
169
- function openAppModal(id) {
170
- const el = document.getElementById('appModal-' + id);
171
- if (el) { el.style.display = 'flex'; document.body.style.overflow = 'hidden'; }
172
- }
173
- function closeAppModal(id) {
174
- const el = document.getElementById('appModal-' + id);
175
- if (el) { el.style.display = 'none'; document.body.style.overflow = ''; }
 
 
176
  }
177
- document.addEventListener('keydown', function (e) {
178
- if (e.key === 'Escape') { closeAppModal('privacyModal'); closeAppModal('termsModal'); }
 
179
  });
180
  </script>
181
 
182
- <!-- Privacy Modal -->
183
- <div id="appModal-privacyModal" onclick="if(event.target===this)closeAppModal('privacyModal')"
184
- style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.85);z-index:9999;align-items:center;justify-content:center;padding:24px">
185
- <div
186
- style="background:#0a0a0a;border:1px solid #2a2a2a;border-radius:14px;max-width:480px;width:100%;padding:32px;position:relative;max-height:80vh;overflow-y:auto">
187
- <button onclick="closeAppModal('privacyModal')"
188
- style="position:absolute;top:16px;right:18px;background:none;border:none;color:#a89f97;font-size:18px;cursor:pointer">&times;</button>
189
- <h2 style="color:#f0ece6;font-size:1.1rem;font-weight:700;margin-bottom:8px">Privacy Policy</h2>
190
- <p style="color:#a89f97;font-size:11px;margin-bottom:20px">We keep this simple and honest.</p>
191
- <ul style="color:#a89f97;font-size:11px;line-height:1.9;padding-left:16px;list-style:disc;text-align:left">
192
- <li>This is a <strong style="color:#f0ece6">public demo</strong> hosted on Hugging Face Spaces. It is not yet a complete production service.</li>
193
- <li>UrbanFlow provides an estimated accuracy of ±5–8% on dense mixed-traffic footage. Results may vary across runs due to the nature of real-time frame-by-frame inference.</li>
194
- <li>Footage you submit is processed with minimal delay and <strong style="color:#f0ece6">discarded immediately</strong> after the session ends. Nothing is stored on our servers.</li>
195
- <li>We do not use your footage to train models, sell it, or share it with any third party.</li>
196
- <li>Reports and annotated videos are generated temporarily and delivered to your device. We do not retain copies.</li>
197
- <li>We do not use advertising cookies, behavioral tracking, or analytics scripts on this platform.</li>
198
- <li>Your use of this demo may inform product requirements. No personally identifiable data is collected in that process.</li>
199
- <li>For any queries: <strong style="color:#c89a6c">support.urbanflow365@gmail.com</strong></li>
200
- </ul>
201
- <p style="color:#555;font-size:10px;margin-top:20px;text-align:left">&mdash; Team UrbanFlow</p>
202
- </div>
203
- </div>
204
 
205
- <!-- Terms Modal -->
206
- <div id="appModal-termsModal" onclick="if(event.target===this)closeAppModal('termsModal')"
207
- style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.85);z-index:9999;align-items:center;justify-content:center;padding:24px">
208
- <div
209
- style="background:#0a0a0a;border:1px solid #2a2a2a;border-radius:14px;max-width:480px;width:100%;padding:32px;position:relative;max-height:80vh;overflow-y:auto">
210
- <button onclick="closeAppModal('termsModal')"
211
- style="position:absolute;top:16px;right:18px;background:none;border:none;color:#a89f97;font-size:18px;cursor:pointer">&times;</button>
212
- <h2 style="color:#f0ece6;font-size:1.1rem;font-weight:700;margin-bottom:8px">Terms &amp; Conditions</h2>
213
- <p style="color:#a89f97;font-size:11px;margin-bottom:20px">By using this application, you agree to the
214
- following terms.</p>
215
- <p style="color:#c89a6c;font-size:11px;font-weight:700;margin-bottom:6px;text-align:left">You can:</p>
216
- <ul
217
- style="color:#a89f97;font-size:11px;line-height:1.9;padding-left:16px;list-style:disc;margin-bottom:16px;text-align:left">
218
- <li>Use this application to evaluate UrbanFlow&rsquo;s traffic detection and analytics capabilities.</li>
219
- <li>Export reports, annotated video outputs, and data artifacts to your own device.</li>
220
- <li>Share feedback, feature requests, or questions with us at <strong style="color:#c89a6c">support.urbanflow365@gmail.com</strong>.</li>
221
- <li>Reference this application in research or internal evaluation, with proper attribution.</li>
222
- </ul>
223
- <p style="color:#c89a6c;font-size:11px;font-weight:700;margin-bottom:6px;text-align:left">You cannot:</p>
224
- <ul
225
- style="color:#a89f97;font-size:11px;line-height:1.9;padding-left:16px;list-style:disc;margin-bottom:16px;text-align:left">
226
- <li>Commercially redistribute outputs or present them as your own product&rsquo;s capability.</li>
227
- <li>Reverse-engineer, extract, or attempt to replicate the underlying model or processing pipeline.</li>
228
- <li>Use the application for unlawful, harmful, or safety-critical operational purposes.</li>
229
- <li>Misrepresent outputs as certified or regulatory-grade traffic data.</li>
230
- </ul>
231
- <p style="color:#a89f97;font-size:11px;text-align:left">This application is provided as-is for <strong style="color:#f0ece6">demonstration and evaluation purposes only</strong>.
232
- UrbanFlow provides an estimated accuracy of ±5–8% on dense mixed-traffic footage. For research use, we recommend processing the same video 2–3 times and taking the average count.</p>
233
- <p style="color:#a89f97;font-size:11px;text-align:left">Outputs are not intended for operational, regulatory, or safety-critical use. This is an early-stage research project, not a commercial product.</p>
234
- <p style="color:#555;font-size:10px;margin-top:16px;text-align:left">For any queries: <strong
235
- style="color:#c89a6c">support.urbanflow365@gmail.com</strong></p>
236
- </div>
237
- </div>
238
 
239
  <!-- Onboarding Walkthrough -->
240
  <div id="onboard-overlay" class="onboard-overlay" style="display:none">
@@ -270,12 +217,5 @@
270
  </div>
271
  </div>
272
 
273
- <script>
274
- if ('serviceWorker' in navigator) {
275
- window.addEventListener('load', () => {
276
- navigator.serviceWorker.register('./sw.js');
277
- });
278
- }
279
- </script>
280
  </body>
281
  </html>
 
23
  <script src="https://cdn.tailwindcss.com"></script>
24
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
25
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Montserrat:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
26
+ <link rel="stylesheet" href="css/shared.css">
27
  <link rel="stylesheet" href="css/initial.css">
28
+ <link rel="stylesheet" href="css/auth.css">
29
  </head>
30
 
31
  <body class="bg-black text-white min-h-screen w-full flex flex-col items-center selection:bg-white selection:text-black overflow-x-hidden">
32
 
33
+
34
  <header class="mt-16 flex flex-col items-center flex-shrink-0 w-full z-10">
35
  <img src="assets/uf_rf.png" alt="UrbanFlow Logo" class="h-44 md:h-52 w-auto object-contain mb-3">
36
  </header>
 
47
  </h1>
48
  <p class="font-bold mb-8 text-sm uppercase tracking-[0.2em] flex items-center justify-center sm:justify-start" style="color:#a89f97">
49
  <span class="core-badge px-3 py-1 rounded-full text-[10px] mr-3">Beta</span>
50
+ Field Intelligence Platform
51
  </p>
52
+
53
+ <div class="hero-desc mb-10 text-center sm:text-left" style="color:#a89f97">
54
+ <p class="hero-desc-lead mb-4 font-semibold text-white">
55
+ Transform video feeds into actionable intelligence.
56
+ </p>
57
+ <p class="hero-desc-body">
58
+ UrbanFlow converts raw footage from any CCTV, dashcam, or drone into high-precision structured data—delivering instant vehicle counts, classifications, and flow analytics without requiring any hardware installation. Simply upload a clip, define your spatial boundaries, and receive enterprise-grade analytical reports in minutes.
59
+ </p>
60
+ </div>
61
 
62
  <div class="mt-10 flex justify-center sm:justify-start">
63
+ <button onclick="openOnboarding()" class="transition-all active:scale-95 hover:text-[#c89a6c] group cursor-pointer font-bold text-white text-base tracking-[0.15em] uppercase" style="font-family: 'Montserrat', sans-serif;">
64
+ HOW TO USE?
 
 
 
65
  </button>
66
  </div>
67
  </div>
 
73
  <h2 class="text-3xl font-bold mb-2 text-center" style="color:#f0ece6">UrbanFlow</h2>
74
  <p class="text-[13px] font-medium mb-8 text-center" style="color:#a89f97">Proceed to analysis</p>
75
  <div class="flex justify-center w-full">
76
+ <div onclick="handleTrafficDynamicsClick()"
77
  class="group relative border-2 rounded-[2rem] p-8 cursor-pointer hover:-translate-y-1 transition-all duration-300 text-center max-w-sm w-full traffic-dynamics-card">
78
  <div class="absolute top-4 right-6 text-[9px] font-bold px-2.5 py-1 rounded-full uppercase tracking-wider"
79
  style="background:#c89a6c;color:#000">BETA</div>
 
123
  </div>
124
  <canvas id="drawing-canvas" class="absolute inset-0 w-full h-full"></canvas>
125
  </div>
126
+ <p class="text-[11px] text-center mb-6" style="color:#a89f97">
127
+ <em>Draw across the lane of travel, not along it.</em>
128
+ <!-- TODO: Add 3 correct angle visual graphics later -->
129
+ </p>
130
  <div class="flex flex-col items-center gap-3">
131
  <button id="btn-proceed" onclick="startRun()"
132
  class="w-fit px-16 py-3.5 rounded-full font-bold transition-all text-center text-sm shadow-lg hover:scale-105 active:scale-95"
133
+ style="background:#0a0a0a;border:1px solid var(--cocoa);color:var(--cocoa-l)">
134
  Continue &nbsp;&rarr;
135
  </button>
136
  <button onclick="resetCanvas()"
137
+ class="text-[11px] font-bold uppercase tracking-widest text-slate-300 hover:text-white transition px-4 py-2 mt-2 bg-neutral-900 rounded-full border border-neutral-700"
138
+ style="border-color:#333;">Reset </button>
139
  </div>
140
  </div>
141
 
 
144
 
145
  <footer class="w-full max-w-[90rem] mx-auto px-10 mt-auto z-10 text-[11px] font-bold uppercase tracking-[0.2em]" style="color:#777">
146
 
147
+ <div class="py-6 w-full">
148
+ <!-- Mobile: two links side-by-side, copyright below centered -->
149
+ <!-- Desktop: three columns left / center / right -->
150
+ <div class="flex flex-col md:flex-row md:justify-between md:items-center gap-2 md:gap-0">
151
+ <div class="flex justify-between md:block md:flex-none md:w-auto">
152
+ <button onclick="openAppModal('privacyModal')" class="hover:text-white transition md:mr-6">Privacy Policy</button>
153
+ <button onclick="openAppModal('termsModal')" class="hover:text-white transition">Terms &amp; Conditions</button>
154
+ </div>
155
+ <div class="text-center md:text-right">
156
+ &copy; 2026 UrbanFlow. All rights reserved.
157
+ </div>
 
 
 
 
 
 
 
 
 
 
158
  </div>
159
  </div>
160
 
161
  </footer>
162
 
163
+ <script src="js/templates.js"></script>
164
+ <script src="js/shared.js"></script>
165
+ <script src="js/auth.js"></script>
166
  <script src="js/initial.js"></script>
167
  <script>
168
+ // Auth-gated entry into Traffic Dynamics
169
+ function handleTrafficDynamicsClick() {
170
+ if (isAuthenticated()) {
171
+ showStep('upload');
172
+ } else {
173
+ promptGoogleSignIn(function() {
174
+ showStep('upload');
175
+ });
176
+ }
177
  }
178
+
179
+ document.addEventListener('DOMContentLoaded', function() {
180
+ injectLegalModals();
181
  });
182
  </script>
183
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
 
186
  <!-- Onboarding Walkthrough -->
187
  <div id="onboard-overlay" class="onboard-overlay" style="display:none">
 
217
  </div>
218
  </div>
219
 
 
 
 
 
 
 
 
220
  </body>
221
  </html>
frontend/js/auth.js ADDED
@@ -0,0 +1,375 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * UrbanFlow — auth.js
3
+ * Client-side Google Identity Services integration.
4
+ *
5
+ * Session is persisted in localStorage so it survives page reloads
6
+ * and HF Space container restarts. The backend verifies the JWT on
7
+ * every sign-in and returns the canonical user record.
8
+ */
9
+
10
+ const UF_AUTH_KEYS = {
11
+ email: 'uf_user_email',
12
+ name: 'uf_user_name',
13
+ picture: 'uf_user_picture',
14
+ username: 'uf_user_username',
15
+ };
16
+
17
+ // ---- Session helpers ----
18
+
19
+ function getAuthSession() {
20
+ const email = localStorage.getItem(UF_AUTH_KEYS.email);
21
+ if (!email) return null;
22
+ return {
23
+ email: email,
24
+ name: localStorage.getItem(UF_AUTH_KEYS.name) || '',
25
+ picture: localStorage.getItem(UF_AUTH_KEYS.picture) || '',
26
+ username: localStorage.getItem(UF_AUTH_KEYS.username) || '',
27
+ };
28
+ }
29
+
30
+ function isAuthenticated() {
31
+ return !!localStorage.getItem(UF_AUTH_KEYS.email);
32
+ }
33
+
34
+ function saveAuthSession(user) {
35
+ localStorage.setItem(UF_AUTH_KEYS.email, user.email || '');
36
+ localStorage.setItem(UF_AUTH_KEYS.name, user.name || '');
37
+ localStorage.setItem(UF_AUTH_KEYS.picture, user.picture || '');
38
+ localStorage.setItem(UF_AUTH_KEYS.username, user.username || '');
39
+ }
40
+
41
+ function clearAuthSession() {
42
+ Object.values(UF_AUTH_KEYS).forEach(k => localStorage.removeItem(k));
43
+ }
44
+
45
+ // ---- Google Identity Services ----
46
+
47
+ let _gsiInitialized = false;
48
+
49
+ /**
50
+ * Load GIS script and initialize. Returns a Promise that resolves
51
+ * once `google.accounts.id` is ready.
52
+ */
53
+ function initGoogleAuth() {
54
+ return new Promise((resolve, reject) => {
55
+ if (_gsiInitialized) { resolve(); return; }
56
+
57
+ fetch('api/auth/client-id')
58
+ .then(r => r.json())
59
+ .then(data => {
60
+ if (data.error) { reject(data.error); return; }
61
+
62
+ const script = document.createElement('script');
63
+ script.src = 'https://accounts.google.com/gsi/client';
64
+ script.async = true;
65
+ script.defer = true;
66
+ script.onload = () => {
67
+ google.accounts.id.initialize({
68
+ client_id: data.client_id,
69
+ callback: _handleCredentialResponse,
70
+ auto_select: false,
71
+ cancel_on_tap_outside: false,
72
+ });
73
+ _gsiInitialized = true;
74
+ resolve();
75
+ };
76
+ script.onerror = () => reject('Failed to load Google Identity Services');
77
+ document.head.appendChild(script);
78
+ })
79
+ .catch(reject);
80
+ });
81
+ }
82
+
83
+ /**
84
+ * Click the hidden real Google button — this is the only reliable way
85
+ * to open the account chooser popup from a user gesture.
86
+ */
87
+ function _triggerGoogleSignInPopup() {
88
+ const realBtn = document.querySelector('#gsi-hidden-btn [role="button"]');
89
+ if (realBtn) {
90
+ realBtn.click();
91
+ } else {
92
+ // Fallback: re-render and click once rendered
93
+ _renderHiddenGoogleBtn(function() {
94
+ const btn = document.querySelector('#gsi-hidden-btn [role="button"]');
95
+ if (btn) btn.click();
96
+ });
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Render Google's real button into a hidden offscreen container,
102
+ * then call `cb` once the iframe/button is ready.
103
+ */
104
+ function _renderHiddenGoogleBtn(cb) {
105
+ let container = document.getElementById('gsi-hidden-btn');
106
+ if (!container) {
107
+ container = document.createElement('div');
108
+ container.id = 'gsi-hidden-btn';
109
+ container.style.cssText = 'position:fixed;top:-9999px;left:-9999px;opacity:0;pointer-events:none;';
110
+ document.body.appendChild(container);
111
+ }
112
+ container.innerHTML = '';
113
+ google.accounts.id.renderButton(container, {
114
+ type: 'standard',
115
+ theme: 'filled_black',
116
+ size: 'large',
117
+ text: 'signin_with',
118
+ shape: 'pill',
119
+ width: 240,
120
+ });
121
+ // Give the iframe a tick to mount
122
+ setTimeout(function() {
123
+ container.style.pointerEvents = 'auto';
124
+ if (cb) cb();
125
+ }, 100);
126
+ }
127
+
128
+ // Internal: will be overridden by the page that triggers sign-in
129
+ let _onAuthSuccess = null;
130
+
131
+ function _handleCredentialResponse(response) {
132
+ fetch('api/auth/verify', {
133
+ method: 'POST',
134
+ headers: { 'Content-Type': 'application/json' },
135
+ body: JSON.stringify({ credential: response.credential }),
136
+ })
137
+ .then(r => {
138
+ if (!r.ok) throw new Error('Verification failed');
139
+ return r.json();
140
+ })
141
+ .then(user => {
142
+ saveAuthSession(user);
143
+
144
+ const handleSuccess = (u) => {
145
+ _hideAuthOverlay();
146
+ if (_onAuthSuccess) _onAuthSuccess(u);
147
+ else {
148
+ if (typeof populateProfileUI === 'function') populateProfileUI();
149
+ }
150
+ };
151
+
152
+ if (user.new_user) {
153
+ _showOnboardingForm(user, function(u) {
154
+ // After onboarding, check consent
155
+ _checkConsentThenProceed(u, handleSuccess);
156
+ });
157
+ } else {
158
+ // Existing user — check consent
159
+ _checkConsentThenProceed(user, handleSuccess);
160
+ }
161
+ })
162
+ .catch(err => {
163
+ console.error('[AUTH]', err);
164
+ const overlay = document.getElementById('auth-overlay');
165
+ const errEl = overlay ? overlay.querySelector('.auth-error') : null;
166
+ if (errEl) {
167
+ errEl.textContent = 'Sign-in failed. Please try again.';
168
+ errEl.classList.remove('hidden');
169
+ }
170
+ });
171
+ }
172
+
173
+ /**
174
+ * Show the auth overlay, render the custom button that delegates
175
+ * to the real Google button click for a reliable account chooser.
176
+ */
177
+ function promptGoogleSignIn(onSuccess) {
178
+ _onAuthSuccess = (user) => {
179
+ if (user.new_user) {
180
+ _showOnboardingForm(user, onSuccess);
181
+ } else {
182
+ _hideAuthOverlay();
183
+ onSuccess(user);
184
+ }
185
+ };
186
+
187
+ _showAuthOverlay();
188
+
189
+ initGoogleAuth().then(() => {
190
+ const btnContainer = document.getElementById('auth-google-btn');
191
+ if (!btnContainer) return;
192
+
193
+ // Render the hidden real Google button first so it's ready
194
+ _renderHiddenGoogleBtn(null);
195
+
196
+ // Show our styled button that delegates to it
197
+ btnContainer.innerHTML = TEMPLATES.authGoogleBtn;
198
+ });
199
+ }
200
+
201
+ // ---- Auth Overlay (injected into DOM) ----
202
+
203
+ function _showAuthOverlay() {
204
+ let overlay = document.getElementById('auth-overlay');
205
+ if (!overlay) {
206
+ overlay = document.createElement('div');
207
+ overlay.id = 'auth-overlay';
208
+ overlay.className = 'auth-overlay';
209
+ overlay.innerHTML = TEMPLATES.authOverlay;
210
+ document.body.appendChild(overlay);
211
+ overlay.addEventListener('click', function(e) {
212
+ if (e.target === overlay) _hideAuthOverlay();
213
+ });
214
+ }
215
+ overlay.style.display = 'flex';
216
+ document.body.style.overflow = 'hidden';
217
+ }
218
+
219
+ function _hideAuthOverlay() {
220
+ const overlay = document.getElementById('auth-overlay');
221
+ if (overlay) {
222
+ overlay.style.display = 'none';
223
+ document.body.style.overflow = '';
224
+ }
225
+ }
226
+
227
+ // ---- Onboarding Form (username) ----
228
+
229
+ function _showOnboardingForm(user, onSuccess) {
230
+ const overlay = document.getElementById('auth-overlay');
231
+ if (!overlay) return;
232
+
233
+ const card = overlay.querySelector('.auth-card');
234
+ card.innerHTML = getOnboardFormTemplate(user);
235
+ overlay._onboardUser = user;
236
+ overlay._onboardCallback = onSuccess;
237
+ }
238
+
239
+ function _submitOnboarding() {
240
+ const overlay = document.getElementById('auth-overlay');
241
+ const input = document.getElementById('auth-username-input');
242
+ const errEl = document.getElementById('auth-onboard-error');
243
+ const username = (input ? input.value : '').trim();
244
+
245
+ if (!username || username.length < 2) {
246
+ if (errEl) { errEl.textContent = 'Please enter at least 2 characters.'; errEl.classList.remove('hidden'); }
247
+ return;
248
+ }
249
+
250
+ const user = overlay._onboardUser;
251
+ const btn = document.getElementById('auth-onboard-submit');
252
+ if (btn) { btn.disabled = true; btn.textContent = 'Saving...'; }
253
+
254
+ fetch('api/auth/onboard', {
255
+ method: 'POST',
256
+ headers: { 'Content-Type': 'application/json' },
257
+ body: JSON.stringify({ email: user.email, username: username }),
258
+ })
259
+ .then(r => {
260
+ if (!r.ok) throw new Error('Onboarding failed');
261
+ return r.json();
262
+ })
263
+ .then(() => {
264
+ user.username = username;
265
+ saveAuthSession(user);
266
+ _hideAuthOverlay();
267
+ if (overlay._onboardCallback) overlay._onboardCallback(user);
268
+ })
269
+ .catch(() => {
270
+ if (errEl) { errEl.textContent = 'Something went wrong. Please try again.'; errEl.classList.remove('hidden'); }
271
+ if (btn) { btn.disabled = false; btn.textContent = 'Continue'; }
272
+ });
273
+ }
274
+
275
+ // ---- Logout ----
276
+
277
+ function showLogoutConfirm() {
278
+ let modal = document.getElementById('logout-confirm-modal');
279
+ if (!modal) {
280
+ modal = document.createElement('div');
281
+ modal.id = 'logout-confirm-modal';
282
+ modal.className = 'auth-overlay';
283
+ modal.onclick = (e) => { if (e.target === modal) hideLogoutConfirm(); };
284
+ modal.innerHTML = TEMPLATES.logoutModal;
285
+ document.body.appendChild(modal);
286
+ }
287
+ modal.style.display = 'flex';
288
+ document.body.style.overflow = 'hidden';
289
+ }
290
+
291
+ function hideLogoutConfirm() {
292
+ const modal = document.getElementById('logout-confirm-modal');
293
+ if (modal) { modal.style.display = 'none'; document.body.style.overflow = ''; }
294
+ }
295
+
296
+ 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 ----
312
+
313
+ let _consentCallback = null;
314
+ let _consentUser = null;
315
+
316
+ function _checkConsentThenProceed(user, callback) {
317
+ if (localStorage.getItem('uf_terms_accepted')) {
318
+ callback(user);
319
+ return;
320
+ }
321
+ // Show consent modal
322
+ _consentCallback = callback;
323
+ _consentUser = user;
324
+ _showConsentModal();
325
+ }
326
+
327
+ function _showConsentModal() {
328
+ let overlay = document.getElementById('consent-overlay');
329
+ if (!overlay) {
330
+ overlay = document.createElement('div');
331
+ overlay.id = 'consent-overlay';
332
+ overlay.className = 'auth-overlay';
333
+ overlay.innerHTML = TEMPLATES.consentModal;
334
+ document.body.appendChild(overlay);
335
+ // No click-to-close — user must agree
336
+ }
337
+ overlay.style.display = 'flex';
338
+ document.body.style.overflow = 'hidden';
339
+ }
340
+
341
+ function onConsentCheckboxChange() {
342
+ const cb = document.getElementById('consent-checkbox');
343
+ const btn = document.getElementById('consent-accept-btn');
344
+ const label = document.getElementById('consent-label');
345
+ if (!cb || !btn) return;
346
+ if (cb.checked) {
347
+ btn.disabled = false;
348
+ btn.style.background = '#0a0a0a';
349
+ btn.style.border = '1px solid var(--cocoa)';
350
+ btn.style.color = 'var(--cocoa-l)';
351
+ btn.style.cursor = 'pointer';
352
+ if (label) label.style.borderColor = 'var(--cocoa)';
353
+ } else {
354
+ btn.disabled = true;
355
+ btn.style.background = '#1a1a1a';
356
+ btn.style.border = '1px solid #222';
357
+ btn.style.color = '#555';
358
+ btn.style.cursor = 'not-allowed';
359
+ if (label) label.style.borderColor = '#222';
360
+ }
361
+ }
362
+
363
+ function acceptConsent() {
364
+ localStorage.setItem('uf_terms_accepted', 'true');
365
+ const overlay = document.getElementById('consent-overlay');
366
+ if (overlay) {
367
+ overlay.style.display = 'none';
368
+ document.body.style.overflow = '';
369
+ }
370
+ if (_consentCallback && _consentUser) {
371
+ _consentCallback(_consentUser);
372
+ }
373
+ _consentCallback = null;
374
+ _consentUser = null;
375
+ }
frontend/js/initial.js CHANGED
@@ -71,7 +71,7 @@ function uploadFile(file) {
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 = 100; // stop simulation at 100%, snap to 100% on load
75
  const tickMs = 200; // update every 200ms
76
  const totalTicks = estDurationMs / tickMs;
77
  const stepPerTick = targetPct / totalTicks;
@@ -87,6 +87,12 @@ function uploadFile(file) {
87
 
88
  const form = new FormData();
89
  form.append('file', file);
 
 
 
 
 
 
90
 
91
  const xhr = new XMLHttpRequest();
92
  currentXHR = xhr;
@@ -122,9 +128,9 @@ function uploadFile(file) {
122
  return;
123
  }
124
 
125
- // Snap to 100% on successful upload response
126
- bar.style.width = '100%';
127
- pct.innerText = '100%';
128
 
129
  const res = JSON.parse(xhr.responseText);
130
  videoId = res.video_id;
@@ -133,10 +139,19 @@ function uploadFile(file) {
133
  fetch('config/' + videoId)
134
  .then(r => r.json())
135
  .then(cfg => {
 
 
136
  runConfig = cfg;
137
  runConfig.conf = 0.12;
138
  runConfig.iou = 0.60;
139
  txt.innerText = 'Initialization Complete';
 
 
 
 
 
 
 
140
  if (fileInput) fileInput.value = '';
141
  setTimeout(() => showStep('draw'), 800);
142
  })
@@ -278,9 +293,20 @@ function startRun() {
278
  config: runConfig
279
  }));
280
  sessionStorage.setItem('uf_active_tab', 'settings');
281
- // Navigate directly to vehicles.html — avoids the initial.html bootstrap entirely,
282
- // which eliminates the white-flash / freeze glitch on the settings transition.
283
- window.location.href = 'vehicles.html';
 
 
 
 
 
 
 
 
 
 
 
284
  }
285
 
286
  // =============================================
 
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;
 
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) } })
95
+ }).catch(()=>{});
96
 
97
  const xhr = new XMLHttpRequest();
98
  currentXHR = xhr;
 
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;
 
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
  })
 
293
  config: runConfig
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
  // =============================================
frontend/js/shared.js ADDED
@@ -0,0 +1,266 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * UrbanFlow — shared.js
3
+ * Shared utilities, modal management, and legal content injection.
4
+ * Loaded on every page before page-specific scripts.
5
+ */
6
+
7
+ // =============================================
8
+ // Modal Management
9
+ // =============================================
10
+
11
+ function openAppModal(id) {
12
+ const el = document.getElementById('appModal-' + id);
13
+ if (el) { el.style.display = 'flex'; document.body.style.overflow = 'hidden'; }
14
+ }
15
+
16
+ function closeAppModal(id) {
17
+ const el = document.getElementById('appModal-' + id);
18
+ if (el) { el.style.display = 'none'; document.body.style.overflow = ''; }
19
+ }
20
+
21
+ // =============================================
22
+ // Legal Modals — Single Source of Truth
23
+ // =============================================
24
+
25
+ const LEGAL_CONTENT = {
26
+ privacy: {
27
+ title: 'Privacy Policy',
28
+ subtitle: 'Last Updated: 28th April 2026',
29
+ content: `
30
+ <p><strong>1. WHO WE ARE</strong><br>
31
+ UrbanFlow is a computer vision software that analyses traffic video footage to produce structured traffic data. It is hosted on Hugging Face Spaces as a demonstration and research product by Perception365.<br>
32
+ Contact: <strong style="color:#f0c674">support.urbanflow365@gmail.com</strong></p>
33
+
34
+ <p><strong>2. WHAT DATA WE COLLECT</strong><br>
35
+ <strong>a) Account Data:</strong> When you sign in with Google, we receive your name, email address, and profile picture. We store only what is necessary to identify your session.<br>
36
+ <strong>b) Uploaded Video Footage:</strong> Videos you upload are processed temporarily for inference only. We do not store your original footage. All uploaded files are deleted automatically once processing is complete and session cache is cleared each run.<br>
37
+ <strong>c) Analysis Outputs:</strong> Processed results are tied to your active session only. We do not sell or share this data with anyone.<br>
38
+ <strong>d) Usage Logs:</strong> We collect standard server logs (IP address, browser type, timestamps) for security monitoring and platform improvement only.</p>
39
+
40
+ <p><strong>3. HOW WE USE YOUR DATA</strong><br>
41
+ We use your data solely to:<br>
42
+ - Authenticate your identity and maintain your session<br>
43
+ - Process uploaded footage and return results to you<br>
44
+ - Detect and prevent misuse of the platform<br>
45
+ - Comply with legal obligations<br>
46
+ We do not use your data for advertising. We do not sell your data to any third party under any circumstances.</p>
47
+
48
+ <p><strong>4. DATA SHARING</strong><br>
49
+ We do not share your personal data with third parties except:<br>
50
+ - Google — for authentication (governed by Google's Privacy Policy)<br>
51
+ - Hugging Face — infrastructure hosting (see huggingface.co/privacy)<br>
52
+ - Legal authorities — only if required by law<br>
53
+ We have no control over Hugging Face's infrastructure-level data handling.</p>
54
+
55
+ <p><strong>5. DATA SECURITY</strong><br>
56
+ We apply reasonable technical measures to protect data in transit and at rest. However, no system is perfectly secure. We operate on shared cloud infrastructure and cannot guarantee absolute security. By using UrbanFlow, you acknowledge and accept this risk.<br>
57
+ You should not upload footage containing sensitive personal information, private property interiors, classified content, or any material you are not authorised to share.</p>
58
+
59
+ <p><strong>6. YOUR RIGHTS</strong><br>
60
+ Since UrbanFlow does not maintain permanent user accounts, signing out clears your session and associated data entirely. You may also contact us to ask what data is held or to report a concern.<br>
61
+ Contact: <strong style="color:#f0c674">support.urbanflow365@gmail.com</strong><br>
62
+ We will respond within 30 days.</p>
63
+
64
+ <p><strong>7. CHANGES TO THIS POLICY</strong><br>
65
+ We may update this policy as the platform evolves. The "Last Updated" date at the top reflects the most recent revision. Continued use after changes constitutes acceptance of the updated policy.</p>
66
+ `
67
+ },
68
+ terms: {
69
+ title: 'Terms & Conditions',
70
+ subtitle: 'Last Updated: 28th April 2026',
71
+ content: `
72
+ <p><strong>1. ACCEPTANCE</strong><br>
73
+ By accessing or using UrbanFlow, you confirm that you have read, understood, and agree to these Terms and Conditions in full. If you do not agree, do not use the platform.<br>
74
+ We are not responsible for any risks arising from future use of the platform beyond what is expressly stated here.</p>
75
+
76
+ <p><strong>2. NATURE OF THE PLATFORM</strong><br>
77
+ UrbanFlow is a beta product provided for research, evaluation, and non-critical use only. It is not intended for use in life-safety systems, emergency response, legal proceedings, or any context where errors in traffic data could cause harm.<br>
78
+ The platform may be modified, suspended, or discontinued at any time without prior notice.</p>
79
+
80
+ <p><strong>3. ELIGIBILITY</strong><br>
81
+ You must be at least 18 years old and hold a valid Google account to use UrbanFlow. By signing in, you confirm you meet these requirements.</p>
82
+
83
+ <p><strong>4. ACCEPTABLE USE</strong><br>
84
+ You agree to use UrbanFlow only for its intended purpose: analysing road or traffic video footage to obtain traffic flow data.<br>
85
+ You must NOT:<br>
86
+ a) Upload footage unrelated to traffic or road monitoring — including personal recordings, indoor footage, private events, or surveillance of individuals.<br>
87
+ b) Upload footage you do not have the legal right to use or process.<br>
88
+ c) Upload footage containing nudity, violence, illegal activity, or any content that violates applicable law.<br>
89
+ d) Attempt to reverse-engineer, scrape, or abuse the platform or its API.<br>
90
+ e) Use the platform to identify, track, or surveil specific individuals without their explicit consent.<br>
91
+ f) Upload classified, confidential, or government-restricted materials.<br>
92
+ g) Use bots or automated scripts to interact with the platform.<br>
93
+ Violation of these terms may result in immediate access termination and, where applicable, reporting to relevant authorities.</p>
94
+
95
+ <p><strong>5. YOUR RESPONSIBILITY FOR UPLOADED CONTENT</strong><br>
96
+ You are solely responsible for any footage you upload. By uploading, you confirm that:<br>
97
+ - You own the footage or hold explicit authorisation to process it<br>
98
+ - The footage is relevant to traffic or road monitoring<br>
99
+ - You are not violating any third party's rights, including privacy rights<br>
100
+ - The footage does not contain content prohibited under Section 4<br>
101
+ We do not review uploaded footage before processing and are not responsible for its content.</p>
102
+
103
+ <p><strong>6. LIMITATION OF LIABILITY</strong><br>
104
+ To the fullest extent permitted by applicable law:<br>
105
+ a) UrbanFlow is not liable for any direct, indirect, incidental, or consequential damages arising from your use of the platform.<br>
106
+ b) Analysis results are provided as-is. They should not be used as the sole basis for engineering, legal, or policy decisions without independent verification.<br>
107
+ c) In the event of a data breach, system failure, or security incident, our liability is limited to the maximum extent permitted by law. We will make reasonable efforts to notify affected users but are not liable for resulting harm.<br>
108
+ d) We are not responsible for platform availability or uptime, as it operates on Hugging Face's third-party infrastructure.</p>
109
+
110
+ <p><strong>7. DATA BREACH NOTICE</strong><br>
111
+ If a breach affecting user data occurs, we will notify affected users via their registered email address within a reasonable timeframe of becoming aware of the incident. Given that we collect only session-level identifiers and no permanent video data, the risk of significant harm from a breach is inherently limited.</p>
112
+
113
+ <p><strong>8. INTELLECTUAL PROPERTY</strong><br>
114
+ UrbanFlow, its models, interface, design, and associated technology are the intellectual property of Perception365. You may not copy, reproduce, or redistribute any part of the platform without written permission.<br>
115
+ You retain ownership of all footage you upload. By uploading, you grant us a limited, temporary licence to process that footage for analysis. This licence expires once processing is complete and the footage is deleted.</p>
116
+
117
+ <p><strong>9. THIRD-PARTY SERVICES</strong><br>
118
+ UrbanFlow uses:<br>
119
+ - Google Identity Services (authentication)<br>
120
+ - Hugging Face Spaces (hosting and compute)<br>
121
+ Use of these services is governed by their own terms and privacy policies. We are not responsible for the practices of these providers.</p>
122
+
123
+ <p><strong>10. TERMINATION</strong><br>
124
+ We reserve the right to suspend or terminate access to UrbanFlow at any time, with or without notice, for violations of these Terms or misuse of the platform.</p>
125
+
126
+ <p><strong>11. GOVERNING LAW</strong><br>
127
+ These Terms are governed by the laws of India. Any disputes shall be subject to the exclusive jurisdiction of the courts of Indore, Madhya Pradesh, India.</p>
128
+
129
+ <p><strong>12. CHANGES TO THESE TERMS</strong><br>
130
+ We may revise these Terms at any time. Continued use of the platform after changes are posted constitutes acceptance of the revised Terms.</p>
131
+
132
+ <p><strong>CONTACT</strong><br>
133
+ For any questions, concerns, or to report misuse:<br>
134
+ Email: <strong style="color:#f0c674">support.urbanflow365@gmail.com</strong></p>
135
+ `
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
+
155
+ // =============================================
156
+ // Keyboard Shortcuts Modal (vehicles.html only)
157
+ // =============================================
158
+
159
+ const SHORTCUTS = [
160
+ { label: 'About', key: '1' },
161
+ { label: 'Overview', key: '2' },
162
+ { label: 'Results', key: '3' },
163
+ { label: 'Settings', key: '4' },
164
+ { label: 'Guide', key: '5' },
165
+ { label: 'Feedback', key: '6' },
166
+ { label: 'Profile', key: '7' },
167
+ { label: 'Download Artifacts', key: 'D' },
168
+ ];
169
+
170
+ function injectShortcutsModal() {
171
+ const rows = SHORTCUTS.map(s =>
172
+ `<div class="shortcut-row">
173
+ <span class="shortcut-label">${s.label}</span>
174
+ <kbd class="kbd-key">${s.key}</kbd>
175
+ </div>`
176
+ ).join('');
177
+
178
+ const html = getShortcutsModalTemplate(rows);
179
+
180
+ const container = document.createElement('div');
181
+ container.innerHTML = html;
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
+ // =============================================
203
+
204
+ document.addEventListener('keydown', function(e) {
205
+ if (e.key === 'Escape') {
206
+ closeAppModal('privacyModal');
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
+ }
214
+ });
215
+
216
+ // =============================================
217
+ // Auto-inject legal modals on DOMContentLoaded
218
+ // =============================================
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
+ // =============================================
261
+
262
+ if ('serviceWorker' in navigator) {
263
+ window.addEventListener('load', function() {
264
+ navigator.serviceWorker.register('./sw.js');
265
+ });
266
+ }
frontend/js/templates.js ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * UrbanFlow — templates.js
3
+ * Stores large HTML string templates extracted from shared.js and auth.js
4
+ */
5
+
6
+ const TEMPLATES = {
7
+ authOverlay: `
8
+ <div class="auth-card">
9
+ <div class="auth-card-header">
10
+ <h2 class="auth-title">Sign In</h2>
11
+ <p class="auth-subtitle">Authenticate with your Google account to continue</p>
12
+ </div>
13
+ <div id="auth-google-btn" class="auth-google-btn"></div>
14
+ <p class="auth-error hidden"></p>
15
+ <p class="auth-footer">Your data is handled per our <button onclick="openAppModal('privacyModal')" class="auth-link">Privacy Policy</button></p>
16
+ </div>
17
+ `,
18
+
19
+ authGoogleBtn: `
20
+ <button class="gsi-custom-btn" onclick="_triggerGoogleSignInPopup()">
21
+ <svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
22
+ <path fill="#4285F4" d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844c-.209 1.125-.843 2.078-1.796 2.717v2.258h2.908c1.702-1.567 2.684-3.875 2.684-6.615z"/>
23
+ <path fill="#34A853" d="M9 18c2.43 0 4.467-.806 5.956-2.18l-2.908-2.259c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332A8.997 8.997 0 0 0 9 18z"/>
24
+ <path fill="#FBBC05" d="M3.964 10.71A5.41 5.41 0 0 1 3.682 9c0-.593.102-1.17.282-1.71V4.958H.957A8.996 8.996 0 0 0 0 9c0 1.452.348 2.827.957 4.042l3.007-2.332z"/>
25
+ <path fill="#EA4335" d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0A8.997 8.997 0 0 0 .957 4.958L3.964 6.29C4.672 4.163 6.656 3.58 9 3.58z"/>
26
+ </svg>
27
+ <span>Continue with Google</span>
28
+ </button>
29
+ `,
30
+
31
+ logoutModal: `
32
+ <div class="auth-card" style="max-width:340px">
33
+ <h2 class="auth-title" style="margin-bottom:8px">Sign Out</h2>
34
+ <p class="auth-subtitle" style="margin-bottom:24px">Are you sure you want to sign out of UrbanFlow?</p>
35
+ <div style="display:flex;gap:12px">
36
+ <button onclick="hideLogoutConfirm()" class="auth-cancel-btn" style="flex:1;text-align:center;padding:12px 0">Cancel</button>
37
+ <button onclick="executeLogout()" style="flex:1;padding:12px 0;background:rgba(220,38,38,0.15);color:#ef4444;border:1px solid rgba(220,38,38,0.4);border-radius:10px;font-family:'Montserrat',sans-serif;font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:1px;cursor:pointer;transition:background 0.2s" onmouseover="this.style.background='rgba(220,38,38,0.3)'" onmouseout="this.style.background='rgba(220,38,38,0.15)'">Yes</button>
38
+ </div>
39
+ </div>
40
+ `,
41
+
42
+ consentModal: `
43
+ <div class="auth-card" style="max-width:400px">
44
+ <div style="text-align:center;margin-bottom:20px">
45
+ <i class="fa-solid fa-shield-halved" style="font-size:28px;color:var(--cocoa-l);margin-bottom:12px;display:block"></i>
46
+ <h2 class="auth-title" style="margin-bottom:6px">Before you continue</h2>
47
+ <p class="auth-subtitle" style="margin-bottom:0">Please review and accept our policies to use UrbanFlow.</p>
48
+ </div>
49
+ <label id="consent-label" style="display:flex;align-items:flex-start;gap:12px;padding:16px;background:#111;border:1px solid #222;border-radius:12px;cursor:pointer;margin-bottom:20px;transition:border-color 0.2s">
50
+ <input type="checkbox" id="consent-checkbox" onchange="onConsentCheckboxChange()" style="margin-top:3px;accent-color:var(--cocoa-l);width:18px;height:18px;flex-shrink:0;cursor:pointer">
51
+ <span style="font-size:12px;color:#a89f97;line-height:1.6">
52
+ I have read and agree to the
53
+ <button onclick="event.preventDefault();openAppModal('privacyModal')" style="color:var(--cocoa-l);background:none;border:none;cursor:pointer;font-family:inherit;font-size:inherit;font-weight:700;text-decoration:underline;padding:0">Privacy Policy</button>
54
+ and
55
+ <button onclick="event.preventDefault();openAppModal('termsModal')" style="color:var(--cocoa-l);background:none;border:none;cursor:pointer;font-family:inherit;font-size:inherit;font-weight:700;text-decoration:underline;padding:0">Terms & Conditions</button>.
56
+ </span>
57
+ </label>
58
+ <button id="consent-accept-btn" onclick="acceptConsent()" disabled
59
+ style="width:100%;padding:14px 0;background:#1a1a1a;color:#555;border:1px solid #222;border-radius:12px;font-family:'Montserrat',sans-serif;font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:1.5px;cursor:not-allowed;transition:all 0.3s">
60
+ Agree & Continue
61
+ </button>
62
+ </div>
63
+ `
64
+ };
65
+
66
+ function getPrivacyModalTemplate(p) {
67
+ return `
68
+ <div id="appModal-privacyModal" class="modal-overlay" onclick="if(event.target===this)closeAppModal('privacyModal')">
69
+ <div class="modal-card" style="max-width: 600px; max-height: 85vh; overflow-y: auto;">
70
+ <button class="modal-close-btn" onclick="closeAppModal('privacyModal')">&times;</button>
71
+ <h2 class="modal-title">${p.title}</h2>
72
+ <p class="modal-subtitle mb-6" style="margin-bottom: 24px;">${p.subtitle}</p>
73
+ <div class="legal-document text-xs text-slate-300 space-y-4" style="line-height: 1.6; font-size: 12px; color: #cbd5e1; display: flex; flex-direction: column; gap: 16px; text-align: left;">
74
+ ${p.content}
75
+ </div>
76
+ </div>
77
+ </div>`;
78
+ }
79
+
80
+ function getTermsModalTemplate(t) {
81
+ return `
82
+ <div id="appModal-termsModal" class="modal-overlay" onclick="if(event.target===this)closeAppModal('termsModal')">
83
+ <div class="modal-card" style="max-width: 600px; max-height: 85vh; overflow-y: auto;">
84
+ <button class="modal-close-btn" onclick="closeAppModal('termsModal')">&times;</button>
85
+ <h2 class="modal-title">${t.title}</h2>
86
+ <p class="modal-subtitle mb-6" style="margin-bottom: 24px;">${t.subtitle}</p>
87
+ <div class="legal-document text-xs text-slate-300 space-y-4" style="line-height: 1.6; font-size: 12px; color: #cbd5e1; display: flex; flex-direction: column; gap: 16px; text-align: left;">
88
+ ${t.content}
89
+ </div>
90
+ </div>
91
+ </div>`;
92
+ }
93
+
94
+ function getShortcutsModalTemplate(rows) {
95
+ return `
96
+ <div id="appModal-shortcutsModal" class="modal-overlay" onclick="if(event.target===this)closeAppModal('shortcutsModal')">
97
+ <div class="modal-card modal-card-sm">
98
+ <button class="modal-close-btn" onclick="closeAppModal('shortcutsModal')">&times;</button>
99
+ <h2 class="modal-title" style="font-size:1rem">
100
+ <i class="fa-solid fa-keyboard text-accent" style="margin-right:6px"></i>Keyboard Shortcuts
101
+ </h2>
102
+ <p class="text-muted" style="font-size:10px;margin-bottom:16px">Navigate faster with these shortcuts</p>
103
+ <div style="display:flex;flex-direction:column;gap:8px">${rows}</div>
104
+ <p style="color:#333;font-size:9px;margin-top:14px;text-align:center">Press <strong style="color:#555">Esc</strong> to close</p>
105
+ </div>
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
123
+ </button>
124
+ </div>
125
+ `;
126
+ }
frontend/js/vehicles.js CHANGED
@@ -154,14 +154,17 @@ document.addEventListener('click', e => {
154
 
155
  // ---- Tab switching — updates both sidebar + mobile bottom nav ----
156
  function switchTab(tab) {
157
- const allTabs = ['about', 'overview', 'run-details', 'reports', 'settings', 'help', 'feedback'];
 
 
158
 
159
  allTabs.forEach(t => {
160
- // Content panels
161
  const el = document.getElementById('tab-' + t);
162
- if (el) el.classList.toggle('hidden', tab !== t);
 
 
 
163
 
164
- // Desktop sidebar nav items
165
  const nav = document.getElementById('nav-' + t);
166
  if (nav) {
167
  if (tab === t) {
@@ -173,17 +176,29 @@ function switchTab(tab) {
173
  }
174
  }
175
 
176
- // Mobile bottom nav items
177
  const mobNav = document.getElementById('mob-nav-' + t);
178
- if (mobNav) {
179
- mobNav.classList.toggle('active', tab === t);
180
- if (tab === 'reports' && t === 'reports') {
181
- mobNav.classList.remove('notify-glow');
182
- }
183
- }
184
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
  }
186
 
 
 
 
187
  // =========== Toast System ===========
188
  function showToast(message, type) {
189
  type = type || 'info';
@@ -196,12 +211,22 @@ function switchTab(tab) {
196
  }
197
 
198
  // =========== Keyboard Shortcuts ===========
199
- const TAB_KEYS = { '1': 'about', '2': 'overview', '3': 'run-details', '4': 'reports', '5': 'settings', '6': 'help', '7': 'feedback' };
200
  function downloadArtifacts() {
201
  const vid = document.body.dataset.lastVideoId;
202
  if (vid) {
 
 
 
 
 
 
 
 
 
 
203
  window.open(`bundle/${vid}`, '_blank');
204
- showToast('Download started', 'success');
205
  }
206
  }
207
 
@@ -266,6 +291,12 @@ function switchTab(tab) {
266
  details: text,
267
  timestamp: new Date().toISOString()
268
  };
 
 
 
 
 
 
269
  const res = await fetch('api/feedback', {
270
  method: 'POST',
271
  headers: { 'Content-Type': 'application/json' },
@@ -274,8 +305,23 @@ function switchTab(tab) {
274
  if (res.ok) {
275
  showToast('Thank you for your feedback!', 'success');
276
  document.getElementById('fb-text').value = '';
277
- document.getElementById('fb-usecase').selectedIndex = 0;
278
- document.getElementById('fb-type').selectedIndex = 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279
  document.querySelectorAll('#fb-priorities .fb-chip').forEach(c => c.classList.remove('active'));
280
 
281
  // Reset Emojis
@@ -600,7 +646,14 @@ function switchTab(tab) {
600
 
601
  // Original init() logic
602
  const raw = sessionStorage.getItem('funky_run');
603
- if (!raw) { window.location.replace('initial.html'); return; }
 
 
 
 
 
 
 
604
 
605
  _params = JSON.parse(raw);
606
 
@@ -615,6 +668,9 @@ function switchTab(tab) {
615
  // Sync current active tab from session if set
616
  const activeTab = sessionStorage.getItem('uf_active_tab') || 'settings';
617
  switchTab(activeTab);
 
 
 
618
  }
619
 
620
  // =========== Update functions ===========
@@ -731,8 +787,12 @@ function switchTab(tab) {
731
  function startNewAnalysis() {
732
  sessionStorage.clear();
733
  _params = null;
734
- // Navigate to initial page directly — skips SPA router
735
- window.location.replace('initial.html');
 
 
 
 
736
  }
737
  function updateBreakdown(classIn, classOut) {
738
  const container = document.getElementById('class-breakdown');
@@ -841,7 +901,6 @@ function switchTab(tab) {
841
  const stride = parseInt(document.getElementById('sv-stride').textContent);
842
  const reportFmt = document.getElementById('sv-report').value;
843
  const annotated = document.getElementById('sv-annotated').classList.contains('active');
844
- _alpha = parseFloat(document.getElementById('sv-smoothing').textContent) || 0.25;
845
 
846
  // Annotation Options
847
  const annotated_options = {
@@ -867,9 +926,21 @@ function switchTab(tab) {
867
  // Lock settings
868
  lockSettings();
869
 
 
 
 
 
 
 
 
870
  // Switch to overview
871
  switchTab('overview');
872
- document.getElementById('proc-label').innerText = 'Processing';
 
 
 
 
 
873
 
874
  // Reset Run Tab Results to Awaiting
875
 
@@ -879,11 +950,14 @@ function switchTab(tab) {
879
  <span class="text-xs font-semibold">Executing inference pipeline... results pending</span>
880
  </div>`;
881
 
882
- // Update Reports tab pending message
883
  const repIcon = document.getElementById('reports-pending-icon');
884
- if (repIcon) repIcon.className = 'fa-solid fa-circle-notch fa-spin text-[#c89a6c]';
 
 
 
885
  const repText = document.getElementById('reports-pending-text');
886
- if (repText) repText.innerText = 'Artifacts will be available after processing completes... Please wait';
887
 
888
 
889
  // Start WebSocket
@@ -914,6 +988,7 @@ function switchTab(tab) {
914
  };
915
 
916
  let processingDone = false;
 
917
 
918
  ws.onclose = () => {
919
  console.log('WS Closed');
@@ -926,7 +1001,7 @@ function switchTab(tab) {
926
  <span class="text-xs font-semibold mb-1">Processing connection was lost.</span>
927
  <span class="text-[10px] text-slate-500 mb-4">The server may have timed out or restarted. Please try again.</span>
928
  <button onclick="startNewAnalysis()" class="text-[10px] font-bold uppercase tracking-widest px-4 py-2 rounded-full" style="background:#111;border:1px solid #2a2a2a;color:#c89a6c">
929
- &larr; Start New Analysis
930
  </button>
931
  </div>`;
932
  }
@@ -939,6 +1014,11 @@ function switchTab(tab) {
939
  ws.onmessage = e => {
940
  const d = JSON.parse(e.data);
941
 
 
 
 
 
 
942
  // Hide empty state on first data
943
  const emptyState = document.getElementById('stats-empty-state');
944
  if (emptyState) emptyState.style.display = 'none';
@@ -952,7 +1032,7 @@ function switchTab(tab) {
952
  <span class="text-xs font-semibold mb-1">Inference pipeline failed.</span>
953
  <span class="text-[10px] text-slate-500 mb-4 text-center max-w-xs">${d.error}</span>
954
  <button onclick="startNewAnalysis()" class="text-[10px] font-bold uppercase tracking-widest px-4 py-2 rounded-full" style="background:#111;border:1px solid #2a2a2a;color:#c89a6c">
955
- &larr; Start New Analysis
956
  </button>
957
  </div>`;
958
  return;
@@ -960,9 +1040,19 @@ function switchTab(tab) {
960
 
961
  if (d.done) {
962
  processingDone = true;
 
 
 
 
 
 
 
 
 
963
  document.getElementById('proc-label').innerText = 'Complete';
964
  document.getElementById('proc-bar').style.width = '100%';
965
  document.getElementById('proc-pct').innerText = '100%';
 
966
  // Force frame counter to n/n
967
  const framesEl = document.getElementById('proc-frames');
968
  if (framesEl) {
@@ -975,11 +1065,16 @@ function switchTab(tab) {
975
 
976
 
977
  // GLOW NOTIFICATION: Let the user know artifacts are ready
978
- const reportsMob = document.getElementById('mob-nav-reports');
979
- if (reportsMob) {
980
- reportsMob.classList.add('notify-glow');
981
  }
982
 
 
 
 
 
 
983
 
984
  document.getElementById('run-results-content').innerHTML =
985
  detailRow('Inference Time', (d.processing_time || 0).toFixed(2) + ' sec') +
@@ -1011,10 +1106,10 @@ function switchTab(tab) {
1011
  jsonToggle.closest('.s-row').classList.add('disabled');
1012
  }
1013
 
1014
- // NOTIFY USER: Glow the artifacts (reports) icon in mobile nav
1015
- const reportsNav = document.getElementById('mob-nav-reports');
1016
- if (reportsNav) {
1017
- reportsNav.classList.add('notify-glow');
1018
  }
1019
  const csvToggle = document.getElementById('sv-export-csv');
1020
  if (csvToggle) {
@@ -1253,7 +1348,7 @@ function switchTab(tab) {
1253
  Want to try another video?
1254
  </p>
1255
  <p style="color:#a89f97;font-size:11px;margin:0">
1256
- Go to <b style="color:#f0ece6">&#9881; Settings</b> &amp; click 'Analyze again'
1257
  </p>
1258
  <div id="retry-bubble-arrow"></div>
1259
  `;
@@ -1265,14 +1360,14 @@ function switchTab(tab) {
1265
  portal.appendChild(bubble);
1266
 
1267
  if (isMobile) {
1268
- // 7 icons in bottom nav. Settings = 5th (index 4).
1269
- // Center of 5th icon = (4 + 0.5) / 7 = 64.28% of viewport width.
1270
  const navH = parseInt(
1271
  getComputedStyle(document.documentElement)
1272
  .getPropertyValue('--mob-nav-h') || '68', 10
1273
  );
1274
  const vpW = window.innerWidth;
1275
- const settingsCenterX = (4.5 / 7) * vpW;
1276
  const bubbleW = bubble.offsetWidth || 220;
1277
  const leftPx = Math.max(8, Math.min(settingsCenterX - bubbleW / 2, vpW - bubbleW - 8));
1278
 
@@ -1330,6 +1425,122 @@ function switchTab(tab) {
1330
  }, 6000);
1331
  }
1332
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1333
  document.addEventListener('DOMContentLoaded', () => {
1334
  // Phase 1: instant visual — show the shell immediately on first paint
1335
  const activeTab = sessionStorage.getItem('uf_active_tab') || 'settings';
 
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) {
170
  if (tab === t) {
 
176
  }
177
  }
178
 
 
179
  const mobNav = document.getElementById('mob-nav-' + t);
180
+ if (mobNav) mobNav.classList.toggle('active', tab === t);
 
 
 
 
 
181
  });
182
+
183
+ // Refresh Profile UI every switch
184
+ if (typeof populateProfileUI === 'function') populateProfileUI();
185
+ if (tab === 'profile') populateProfileTab();
186
+
187
+ // Stop glow notification when user views Results
188
+ if (tab === 'results') {
189
+ const mobResults = document.getElementById('mob-nav-results');
190
+ if (mobResults) mobResults.classList.remove('notify-glow');
191
+ const navResults = document.getElementById('nav-results');
192
+ if (navResults) navResults.classList.remove('notify-glow');
193
+ }
194
+
195
+ // Store active tab
196
+ sessionStorage.setItem('uf_active_tab', tab);
197
  }
198
 
199
+ // Ensure global access
200
+ window.switchTab = switchTab;
201
+
202
  // =========== Toast System ===========
203
  function showToast(message, type) {
204
  type = type || 'info';
 
211
  }
212
 
213
  // =========== Keyboard Shortcuts ===========
214
+ const TAB_KEYS = { '1': 'about', '2': 'overview', '3': 'results', '4': 'settings', '5': 'help', '6': 'feedback', '7': 'profile' };
215
  function downloadArtifacts() {
216
  const vid = document.body.dataset.lastVideoId;
217
  if (vid) {
218
+ const btn = document.getElementById('btn-download-bundle');
219
+ if (btn) {
220
+ const originalHTML = btn.innerHTML;
221
+ btn.innerHTML = `<i class="fa-solid fa-spinner fa-spin mr-2"></i> Zipping...`;
222
+ btn.disabled = true;
223
+ setTimeout(() => {
224
+ btn.innerHTML = originalHTML;
225
+ btn.disabled = false;
226
+ }, 4000);
227
+ }
228
  window.open(`bundle/${vid}`, '_blank');
229
+ showToast('Preparing download bundle...', 'info');
230
  }
231
  }
232
 
 
291
  details: text,
292
  timestamp: new Date().toISOString()
293
  };
294
+
295
+ // Attach authenticated user email if available
296
+ const session = (typeof getAuthSession === 'function') ? getAuthSession() : null;
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' },
 
305
  if (res.ok) {
306
  showToast('Thank you for your feedback!', 'success');
307
  document.getElementById('fb-text').value = '';
308
+
309
+ // Reset custom uf-select dropdowns
310
+ ['fb-usecase', 'fb-type'].forEach(function(id) {
311
+ var hidden = document.getElementById(id);
312
+ var label = document.getElementById(id + '-label');
313
+ var dropdown = document.getElementById(id + '-dropdown');
314
+ if (hidden) hidden.value = '';
315
+ if (label) {
316
+ label.textContent = id === 'fb-usecase' ? 'Select your use case' : 'General Feedback';
317
+ label.style.color = '#666';
318
+ }
319
+ if (dropdown) {
320
+ dropdown.querySelectorAll('.uf-select-option').forEach(function(opt) {
321
+ opt.classList.remove('uf-select-option-active');
322
+ });
323
+ }
324
+ });
325
  document.querySelectorAll('#fb-priorities .fb-chip').forEach(c => c.classList.remove('active'));
326
 
327
  // Reset Emojis
 
646
 
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
 
 
668
  // Sync current active tab from session if set
669
  const activeTab = sessionStorage.getItem('uf_active_tab') || 'settings';
670
  switchTab(activeTab);
671
+
672
+ // Populate auth profile UI
673
+ populateProfileUI();
674
  }
675
 
676
  // =========== Update functions ===========
 
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');
 
901
  const stride = parseInt(document.getElementById('sv-stride').textContent);
902
  const reportFmt = document.getElementById('sv-report').value;
903
  const annotated = document.getElementById('sv-annotated').classList.contains('active');
 
904
 
905
  // Annotation Options
906
  const annotated_options = {
 
926
  // Lock settings
927
  lockSettings();
928
 
929
+ // Freeze annotation chips during processing
930
+ const chipSelector = document.getElementById('chip-selector');
931
+ if (chipSelector) {
932
+ chipSelector.style.pointerEvents = 'none';
933
+ chipSelector.style.opacity = '0.5';
934
+ }
935
+
936
  // Switch to overview
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
 
 
950
  <span class="text-xs font-semibold">Executing inference pipeline... results pending</span>
951
  </div>`;
952
 
953
+ // Update Results tab pending message
954
  const repIcon = document.getElementById('reports-pending-icon');
955
+ if (repIcon) {
956
+ repIcon.className = 'fa-solid fa-satellite-dish animate-pulse text-5xl mb-2';
957
+ repIcon.style.color = '#c89a6c';
958
+ }
959
  const repText = document.getElementById('reports-pending-text');
960
+ if (repText) repText.innerText = 'Transmission in progress...';
961
 
962
 
963
  // Start WebSocket
 
988
  };
989
 
990
  let processingDone = false;
991
+ let firstMessageReceived = false;
992
 
993
  ws.onclose = () => {
994
  console.log('WS Closed');
 
1001
  <span class="text-xs font-semibold mb-1">Processing connection was lost.</span>
1002
  <span class="text-[10px] text-slate-500 mb-4">The server may have timed out or restarted. Please try again.</span>
1003
  <button onclick="startNewAnalysis()" class="text-[10px] font-bold uppercase tracking-widest px-4 py-2 rounded-full" style="background:#111;border:1px solid #2a2a2a;color:#c89a6c">
1004
+ <i class="fa-solid fa-house mr-1"></i> Home
1005
  </button>
1006
  </div>`;
1007
  }
 
1014
  ws.onmessage = e => {
1015
  const d = JSON.parse(e.data);
1016
 
1017
+ if (!firstMessageReceived) {
1018
+ firstMessageReceived = true;
1019
+ document.getElementById('proc-label').innerText = 'Processing';
1020
+ }
1021
+
1022
  // Hide empty state on first data
1023
  const emptyState = document.getElementById('stats-empty-state');
1024
  if (emptyState) emptyState.style.display = 'none';
 
1032
  <span class="text-xs font-semibold mb-1">Inference pipeline failed.</span>
1033
  <span class="text-[10px] text-slate-500 mb-4 text-center max-w-xs">${d.error}</span>
1034
  <button onclick="startNewAnalysis()" class="text-[10px] font-bold uppercase tracking-widest px-4 py-2 rounded-full" style="background:#111;border:1px solid #2a2a2a;color:#c89a6c">
1035
+ <i class="fa-solid fa-house mr-1"></i> Home
1036
  </button>
1037
  </div>`;
1038
  return;
 
1040
 
1041
  if (d.done) {
1042
  processingDone = true;
1043
+
1044
+ // Stats Tracking (Scoped by email)
1045
+ const session = (typeof getAuthSession === 'function') ? getAuthSession() : null;
1046
+ const emailKey = session ? `_${session.email}` : '';
1047
+
1048
+ let currentRuns = parseInt(localStorage.getItem(`uf_total_runs${emailKey}`) || '0');
1049
+ localStorage.setItem(`uf_total_runs${emailKey}`, currentRuns + 1);
1050
+ localStorage.setItem(`uf_last_active${emailKey}`, new Date().toLocaleString());
1051
+
1052
  document.getElementById('proc-label').innerText = 'Complete';
1053
  document.getElementById('proc-bar').style.width = '100%';
1054
  document.getElementById('proc-pct').innerText = '100%';
1055
+
1056
  // Force frame counter to n/n
1057
  const framesEl = document.getElementById('proc-frames');
1058
  if (framesEl) {
 
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');
1075
+ if (rPendingMsg) rPendingMsg.classList.add('hidden');
1076
+ const rContentWrap = document.getElementById('results-content-wrap');
1077
+ if (rContentWrap) rContentWrap.classList.remove('hidden');
1078
 
1079
  document.getElementById('run-results-content').innerHTML =
1080
  detailRow('Inference Time', (d.processing_time || 0).toFixed(2) + ' sec') +
 
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) {
 
1348
  Want to try another video?
1349
  </p>
1350
  <p style="color:#a89f97;font-size:11px;margin:0">
1351
+ Tap <b style="color:#f0ece6">&#9881; Settings</b> &amp; click '<b style="color:#f0ece6">Home</b>'
1352
  </p>
1353
  <div id="retry-bubble-arrow"></div>
1354
  `;
 
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));
1373
 
 
1425
  }, 6000);
1426
  }
1427
 
1428
+
1429
+ // =========== Auth & Profile UI ===========
1430
+
1431
+ function populateProfileUI() {
1432
+ const session = (typeof getAuthSession === 'function') ? getAuthSession() : null;
1433
+ if (!session) return;
1434
+
1435
+ // Update PFP in Desktop Sidebar
1436
+ const sidebarPfp = document.getElementById('sidebar-profile-pfp-wrap');
1437
+ if (sidebarPfp && session.picture) {
1438
+ sidebarPfp.innerHTML = `<img src="${session.picture}" alt="" class="w-full h-full object-cover rounded-full" referrerpolicy="no-referrer">`;
1439
+ }
1440
+
1441
+ // Update PFP in Mobile Bottom Nav
1442
+ const mobPfp = document.getElementById('mob-pfp-wrap');
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() {
1458
+ const session = (typeof getAuthSession === 'function') ? getAuthSession() : null;
1459
+ if (!session) return;
1460
+
1461
+ // Identity
1462
+ const pfpLarge = document.getElementById('profile-pfp-large');
1463
+ if (pfpLarge && session.picture) pfpLarge.src = session.picture;
1464
+
1465
+ const emailEl = document.getElementById('profile-email');
1466
+ if (emailEl) emailEl.innerText = session.email;
1467
+
1468
+ const nameInp = document.getElementById('profile-username-input');
1469
+ if (nameInp) nameInp.value = session.username || session.name || '';
1470
+
1471
+ // Stats (Scoped by email)
1472
+ const emailKey = `_${session.email}`;
1473
+ const totalRuns = localStorage.getItem(`uf_total_runs${emailKey}`) || '0';
1474
+ const lastActive = localStorage.getItem(`uf_last_active${emailKey}`) || 'Never';
1475
+
1476
+ if (document.getElementById('profile-total-runs'))
1477
+ document.getElementById('profile-total-runs').innerText = totalRuns;
1478
+ if (document.getElementById('profile-last-active'))
1479
+ document.getElementById('profile-last-active').innerText = lastActive;
1480
+ }
1481
+
1482
+ async function saveProfileUsername() {
1483
+ const session = (typeof getAuthSession === 'function') ? getAuthSession() : null;
1484
+ if (!session) return;
1485
+
1486
+ const input = document.getElementById('profile-username-input');
1487
+ const newName = input.value.trim();
1488
+ if (!newName) return showToast('Name cannot be empty', 'error');
1489
+
1490
+ const btn = document.getElementById('btn-save-username');
1491
+ btn.disabled = true;
1492
+ btn.innerText = 'Saving...';
1493
+
1494
+ try {
1495
+ // Save locally first (always works)
1496
+ session.username = newName;
1497
+ if (typeof saveAuthSession === 'function') saveAuthSession(session);
1498
+
1499
+ // Try backend as best-effort
1500
+ fetch('api/auth/onboard', {
1501
+ method: 'POST',
1502
+ headers: { 'Content-Type': 'application/json' },
1503
+ body: JSON.stringify({ email: session.email, username: newName })
1504
+ }).catch(() => {});
1505
+
1506
+ showToast('Profile updated successfully', 'success');
1507
+ populateProfileUI();
1508
+ } catch (err) {
1509
+ showToast('Failed to update profile', 'error');
1510
+ } finally {
1511
+ btn.disabled = false;
1512
+ btn.innerText = 'Save';
1513
+ }
1514
+ }
1515
+
1516
+ function toggleLegalMenu(e) {
1517
+ e.stopPropagation();
1518
+ const menus = ['legal-menu', 'legal-menu-profile'];
1519
+ menus.forEach(m => {
1520
+ const el = document.getElementById(m);
1521
+ if (el) el.classList.toggle('hidden');
1522
+ });
1523
+
1524
+ // Close on click outside
1525
+ const closer = () => {
1526
+ menus.forEach(m => {
1527
+ const el = document.getElementById(m);
1528
+ if (el) el.classList.add('hidden');
1529
+ });
1530
+ window.removeEventListener('click', closer);
1531
+ };
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';
frontend/sw.js CHANGED
@@ -2,6 +2,8 @@ const CACHE_NAME = 'urbanflow-v4';
2
  const ASSETS = [
3
  './css/initial.css',
4
  './css/vehicles.css',
 
 
5
  './assets/shuriken.png',
6
  './assets/shurkien_b.png',
7
  './assets/uf_rf.png'
 
2
  const ASSETS = [
3
  './css/initial.css',
4
  './css/vehicles.css',
5
+ './css/shared.css',
6
+ './css/auth.css',
7
  './assets/shuriken.png',
8
  './assets/shurkien_b.png',
9
  './assets/uf_rf.png'
frontend/vehicles.html CHANGED
@@ -6,13 +6,13 @@
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/rf.png">
10
  <link rel="manifest" href="manifest.json">
11
  <meta name="theme-color" content="#000000">
12
  <meta name="apple-mobile-web-app-capable" content="yes">
13
  <meta name="apple-mobile-web-app-status-bar-style" content="black">
14
  <meta name="apple-mobile-web-app-title" content="UrbanFlow">
15
- <link rel="apple-touch-icon" href="assets/icon-192.png">
16
  <script src="https://cdn.tailwindcss.com"></script>
17
  <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
18
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
@@ -20,7 +20,30 @@
20
  href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Montserrat:wght@400;500;600;700;800;900&display=swap"
21
  rel="stylesheet">
22
  <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
 
23
  <link rel="stylesheet" href="css/vehicles.css">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  </head>
25
 
26
  <body class="bg-black text-white h-screen w-screen flex" style="overflow:hidden">
@@ -31,16 +54,18 @@
31
 
32
  <!-- Mobile Legal Overflow -->
33
  <div class="absolute right-4 top-1/2 -translate-y-1/2 flex items-center">
34
- <button onclick="toggleLegalMenu(event)" class="text-[#a89f97] hover:text-white p-2 transition-colors" aria-label="Legal Menu">
35
- <i class="fa-solid fa-ellipsis-vertical text-lg"></i>
36
  </button>
37
  <div id="legal-menu" class="hidden absolute right-0 top-full mt-2 w-48 bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg shadow-2xl z-50 overflow-hidden">
38
  <button onclick="openAppModal('privacyModal'); 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">
39
  Privacy Policy
40
  </button>
41
- <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] transition-all">
42
  Terms & Conditions
43
  </button>
 
 
44
  </div>
45
  </div>
46
  </div>
@@ -57,15 +82,11 @@
57
  </a>
58
  <a onclick="switchTab('overview')" id="nav-overview"
59
  class="flex items-center px-4 py-2.5 rounded-lg transition cursor-pointer nav-item-inactive">
60
- <i class="fa-solid fa-desktop w-6"></i> <span class="font-medium">Stats</span>
61
- </a>
62
- <a onclick="switchTab('run-details')" id="nav-run-details"
63
- class="flex items-center px-4 py-2.5 rounded-lg transition cursor-pointer nav-item-inactive">
64
- <i class="fa-solid fa-microchip w-6"></i> <span class="font-medium">Run</span>
65
  </a>
66
- <a onclick="switchTab('reports')" id="nav-reports"
67
  class="flex items-center px-4 py-2.5 rounded-lg transition cursor-pointer nav-item-inactive">
68
- <i class="fa-solid fa-file-lines w-6"></i> <span class="font-medium">Artifacts</span>
69
  </a>
70
  <a onclick="switchTab('settings')" id="nav-settings"
71
  class="flex items-center px-4 py-2.5 rounded-lg transition cursor-pointer nav-item-inactive">
@@ -79,6 +100,11 @@
79
  class="flex items-center px-4 py-2.5 rounded-lg transition cursor-pointer nav-item-inactive">
80
  <i class="fa-solid fa-comment-dots w-6"></i> <span class="font-medium">Feedback</span>
81
  </a>
 
 
 
 
 
82
  </nav>
83
  <div class="mt-auto border-t p-4 flex flex-col items-center gap-2 bg-black flex-shrink-0"
84
  style="border-color:#2a2a2a">
@@ -90,29 +116,24 @@
90
  class="text-[10px] font-bold uppercase tracking-widest transition w-full text-center py-1 rounded"
91
  style="color:#a89f97" onmouseover="this.style.color='#c89a6c'"
92
  onmouseout="this.style.color='#a89f97'">Terms &amp; Conditions</button>
93
- <p class="text-[11px] font-medium mt-1 mb-1" style="color:#555">&#169; 2026 UrbanFlow</p>
94
  </div>
95
  </aside>
96
 
97
  <!-- Mobile Navigation (hidden on desktop) -->
98
  <div
99
- class="mobile-nav hidden fixed top-0 left-0 right-0 z-30 bg-black border-b border-slate-800 px-2 py-1.5 items-center justify-between">
100
  <img src="assets/uf_rf.png" alt="UF" class="h-8">
101
- <div class="flex gap-1">
102
- <button onclick="switchTab('about')" class="text-[9px] px-2 py-1 rounded" style="color:#a89f97"><i
103
- class="fa-solid fa-circle-info"></i></button>
104
- <button onclick="switchTab('overview')" class="text-[9px] px-2 py-1 rounded" style="color:#a89f97"><i
105
- class="fa-solid fa-desktop"></i></button>
106
- <button onclick="switchTab('run-details')" class="text-[9px] px-2 py-1 rounded" style="color:#a89f97"><i
107
- class="fa-solid fa-microchip"></i></button>
108
- <button onclick="switchTab('reports')" class="text-[9px] px-2 py-1 rounded" style="color:#a89f97"><i
109
- class="fa-solid fa-file-lines"></i></button>
110
- <button onclick="switchTab('settings')" class="text-[9px] px-2 py-1 rounded" style="color:#a89f97"><i
111
- class="fa-solid fa-sliders"></i></button>
112
- <button onclick="switchTab('help')" class="text-[9px] px-2 py-1 rounded" style="color:#a89f97"><i
113
- class="fa-solid fa-circle-question"></i></button>
114
- <button onclick="switchTab('feedback')" class="text-[9px] px-2 py-1 rounded" style="color:#a89f97"><i
115
- class="fa-solid fa-comment-dots"></i></button>
116
  </div>
117
  </div>
118
 
@@ -121,6 +142,41 @@
121
 
122
  <main class="flex-1 flex flex-col h-full min-w-0 p-4 gap-4">
123
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
  <!-- TAB: About -->
125
  <div id="tab-about" class="hidden flex-1 min-h-0 overflow-y-auto">
126
  <div class="bg-black border rounded-xl p-12 shadow-2xl space-y-8 flex flex-col justify-center"
@@ -158,7 +214,7 @@
158
  </p>
159
  </div>
160
 
161
- <div class="grid grid-cols-2 gap-12 pt-8 text-left border-t max-w-5xl mx-auto"
162
  style="border-color:#1a1a1a">
163
  <div class="space-y-4">
164
  <h4 class="font-bold text-[13px] uppercase tracking-wider" style="color:#f0ece6">What You Will Get Here</h4>
@@ -173,7 +229,7 @@
173
  </li>
174
  <li class="flex items-start gap-3"><i class="fa-solid fa-circle text-[5px] mt-1.5"
175
  style="color:#c89a6c"></i>
176
- <span>Congestion Analytics,Peak Detection, and temporal Trends</span>
177
  </li>
178
  <li class="flex items-start gap-3"><i class="fa-solid fa-circle text-[5px] mt-1.5"
179
  style="color:#c89a6c"></i>
@@ -187,13 +243,30 @@
187
  </h4>
188
  <p class="text-xs leading-relaxed" style="color:#a89f97">
189
  UrbanFlow delivers an estimated accuracy of ±5–8% on dense mixed-traffic footage. Results
190
- may vary slightly across runs due to frame-by-frame inference variability
191
  </p>
192
  <p class="text-xs leading-relaxed" style="color:#a89f97">
193
  For research or planning use, process the same footage 2–3 times
194
  and average the results for improved reliability.
195
  </p>
196
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
  </div>
198
 
199
  <div class="text-center pt-6 border-t" style="border-color:#1a1a1a">
@@ -212,42 +285,6 @@
212
  </div>
213
 
214
 
215
- <!-- Progress Bar (shared) -->
216
- <div id="progress-bar-wrapper"
217
- class="w-full bg-neutral-950 rounded-xl px-6 py-4 border border-neutral-800 shadow-sm flex items-center justify-between flex-shrink-0">
218
- <div class="flex items-center space-x-4 flex-1 mr-6">
219
- <span class="text-[11px] font-black text-white uppercase tracking-wider whitespace-nowrap"
220
- id="proc-label">Waiting</span>
221
- <div class="flex-1 h-2 bg-[#111111] rounded-full overflow-hidden relative border border-[#1a1a1a]">
222
- <div id="proc-bar" class="h-full bg-[#444444] rounded-full transition-all duration-500 ease-out"
223
- style="width: 0%"></div>
224
- </div>
225
- </div>
226
- <div class="flex items-center space-x-6 text-xs font-bold text-white whitespace-nowrap">
227
- <span id="proc-frames">Awaiting Input</span>
228
- <span id="proc-pct">Idle</span>
229
- <div class="uf-select-wrap" id="live-palette-wrap">
230
- <div class="uf-select-trigger" id="live-palette-trigger" onclick="ufSelectToggle('live-palette')">
231
- <span class="uf-select-label" id="live-palette-label">Default</span>
232
- <i class="fa-solid fa-chevron-down uf-select-arrow" id="live-palette-arrow"></i>
233
- </div>
234
- <div class="uf-select-dropdown hidden" id="live-palette-dropdown">
235
- <div class="uf-select-option" data-value="default" onclick="ufSelectPick('live-palette','default','Default')">Default</div>
236
- <div class="uf-select-option" data-value="vibrant" onclick="ufSelectPick('live-palette','vibrant','Vibrant')">Vibrant</div>
237
- <div class="uf-select-option" data-value="corporate" onclick="ufSelectPick('live-palette','corporate','Corporate')">Corporate</div>
238
- <div class="uf-select-option" data-value="neon" onclick="ufSelectPick('live-palette','neon','Neon Night')">Neon Night</div>
239
- <div class="uf-select-option" data-value="earth" onclick="ufSelectPick('live-palette','earth','Earth Tones')">Earth Tones</div>
240
- <div class="uf-select-option" data-value="ocean" onclick="ufSelectPick('live-palette','ocean','Ocean Breeze')">Ocean Breeze</div>
241
- <div class="uf-select-option" data-value="sunset" onclick="ufSelectPick('live-palette','sunset','Sunset Glow')">Sunset Glow</div>
242
- <div class="uf-select-option" data-value="midnight" onclick="ufSelectPick('live-palette','midnight','Midnight Deep')">Midnight Deep</div>
243
- <div class="uf-select-option" data-value="gold" onclick="ufSelectPick('live-palette','gold','Monochrome Gold')">Monochrome Gold</div>
244
- </div>
245
- <input type="hidden" id="live-palette-select" value="default">
246
- </div>
247
- </div>
248
- </div>
249
-
250
- <!-- TAB: Overview -->
251
  <div id="tab-overview" class="hidden grid grid-cols-12 gap-4 flex-1 min-h-0 overflow-hidden"
252
  style="position:relative">
253
 
@@ -339,88 +376,17 @@
339
  <div class="flex-1 w-full relative min-h-[220px]">
340
  <canvas id="flowChart"></canvas>
341
  </div>
342
- </div>
343
-
344
- </div>
345
-
346
- </div>
347
-
348
- <!-- TAB: Run -->
349
- <div id="tab-run-details" class="hidden flex-1 min-h-0 overflow-y-auto">
350
- <div class="space-y-6 w-full max-w-[1400px] mx-auto">
351
-
352
- <!-- HERO: Process Analytics -->
353
- <div id="run-results-card" class="bg-neutral-950 rounded-xl border border-neutral-800 shadow-sm overflow-hidden flex flex-col"
354
- style="border-color:#2a2a2a">
355
- <div class="px-6 py-4 border-b flex flex-col lg:flex-row justify-between items-center gap-4"
356
- style="border-color:#1a1a1a;background:#050505">
357
- <div class="text-center lg:text-left">
358
- <h3 class="font-bold text-sm" style="color:#f0ece6">Process Analytics</h3>
359
- <p class="text-[10px] mt-0.5 uppercase tracking-widest font-medium"
360
- style="color:#a89f97">
361
- Execution Telemetry</p>
362
- </div>
363
- <div class="flex items-center gap-3">
364
- </div>
365
- </div>
366
- <div class="p-8">
367
- <div id="run-results-content" class="grid grid-cols-3 gap-12">
368
- <div class="flex flex-col items-center justify-center p-12 rounded-2xl col-span-3 text-center w-full"
369
- style="color:#555; min-height: 180px;">
370
- <i class="fa-solid fa-chart-line text-4xl mb-4" style="color:#3a3a3a;"></i>
371
- <span class="text-[11px] font-bold uppercase tracking-[0.2em] block w-full text-center">Initiate a run to view performance insights</span>
372
- </div>
373
- </div>
374
- </div>
375
- </div>
376
-
377
- <!-- Technical Context Row -->
378
- <div class="grid grid-cols-2 gap-6">
379
- <div class="bg-neutral-950 rounded-xl border border-neutral-800 shadow-sm flex flex-col overflow-hidden"
380
- style="border-color:#2a2a2a">
381
- <div class="px-6 py-4 border-b" style="border-color:#1a1a1a;background:#050505">
382
- <h3 class="font-bold text-sm" style="color:#f0ece6">Stream Source Profile</h3>
383
- </div>
384
- <div class="p-6 space-y-4" id="panel-video"></div>
385
- </div>
386
- <div class="bg-neutral-950 rounded-xl border border-neutral-800 shadow-sm flex flex-col overflow-hidden"
387
- style="border-color:#2a2a2a">
388
- <div class="px-6 py-4 border-b" style="border-color:#1a1a1a;background:#050505">
389
- <h3 class="font-bold text-sm" style="color:#f0ece6">System Resource Utilization</h3>
390
- </div>
391
- <div class="p-6 space-y-4" id="panel-perf"></div>
392
- </div>
393
- <div class="bg-neutral-950 rounded-xl border border-neutral-800 shadow-sm flex flex-col overflow-hidden"
394
- style="border-color:#2a2a2a">
395
- <div class="px-6 py-4 border-b" style="border-color:#1a1a1a;background:#050505">
396
- <h3 class="font-bold text-sm" style="color:#f0ece6">Model Architecture &amp; Logic</h3>
397
- </div>
398
- <div class="p-6 space-y-4" id="panel-model"></div>
399
- </div>
400
- <div class="bg-neutral-950 rounded-xl border border-neutral-800 shadow-sm flex flex-col overflow-hidden"
401
- style="border-color:#2a2a2a">
402
- <div class="px-6 py-4 border-b" style="border-color:#1a1a1a;background:#050505">
403
- <h3 class="font-bold text-sm" style="color:#f0ece6">Inference Parameters</h3>
404
- </div>
405
- <div class="p-6 space-y-4" id="panel-infer"></div>
406
- </div>
407
- </div>
408
-
409
-
410
-
411
  </div>
412
  </div>
413
 
414
-
415
-
416
- <!-- TAB: Reports -->
417
- <div id="tab-reports" class="hidden flex-1 min-h-0 overflow-y-auto">
418
  <div id="reports-pending-message"
419
  class="mb-4 text-center p-8 flex flex-col items-center justify-center gap-4"
420
  style="min-height: 60vh;">
421
  <i class="fa-solid fa-hourglass-half text-5xl mb-2" style="color:#3a3230;"></i>
422
  <div style="color:#c89a6c; font-size:13px; font-weight:700; letter-spacing:0.18em; text-transform:uppercase;">
423
- Processing Pending
424
  </div>
425
  <span id="reports-pending-text"
426
  class="text-xs font-medium tracking-wide uppercase leading-relaxed text-center"
@@ -429,31 +395,91 @@
429
  </span>
430
  </div>
431
 
432
- <!-- Post-Processing Insights (Populated after completion) -->
433
- <div id="post-process-cards" class="hidden mb-4">
434
- <!-- PCU Summary -->
435
- <div class="bg-black rounded-xl p-6 border border-slate-800 shadow-sm flex flex-col min-h-[140px]">
436
- <div class="flex justify-between items-center mb-4 relative">
437
- <h3 class="font-bold text-white text-sm flex items-center">PCU Analysis
438
- <span class="info-wrap"><span class="info-btn" style="background:#222;color:#888"><i
439
- class="fa-solid fa-info"></i></span>
440
- <span class="info-tip">Passenger Car Units (IRC:106-1990). Converts heterogeneous Indian
441
- traffic into standardized units for road capacity analysis.</span></span>
442
- </h3>
443
- </div>
444
- <div class="flex-1 flex items-center justify-center" id="pcu-stats-card">
445
- <div class="text-center text-slate-600 text-xs"><i></i><br>Available after processing</div>
446
- </div>
447
  </div>
448
- </div>
449
 
450
- <div id="reports-grid" class="hidden grid grid-cols-2 xl:grid-cols-3 gap-4"></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
451
 
452
- <!-- Mobile Download Button (visible at the very bottom of Artifacts tab after processing) -->
453
- <div id="mobile-download-wrap" class="hidden lg:hidden flex justify-center mt-6 mb-4">
454
- <button onclick="downloadArtifacts()" class="w-fit px-16 py-4 font-bold text-sm rounded-full transition flex items-center justify-center gap-2 shadow-lg active:scale-95" style="background:var(--cocoa-l);color:#000">
455
- <i class="fa-solid fa-download"></i> Download All
456
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
457
  </div>
458
  </div>
459
 
@@ -461,7 +487,7 @@
461
  <div id="tab-settings" class="hidden flex-1 min-h-0 overflow-y-auto">
462
  <div class="grid grid-cols-2 gap-6 w-full">
463
  <!-- Processing Parameters -->
464
- <div class="col-span-1 bg-black rounded-xl border shadow-sm overflow-hidden flex flex-col"
465
  style="border-color:#2a2a2a">
466
  <div class="px-6 py-4 border-b" style="border-color:#1a1a1a;background:#050505">
467
  <h3 class="font-bold text-white text-sm flex items-center">Inference Configuration Profile
@@ -475,7 +501,7 @@
475
  <p class="text-[10px] mt-0.1 uppercase tracking-widest font-medium" style="color:#a89f97">
476
  Auto-configured pipeline parameters</p>
477
  </div>
478
- <div class="px-6 py-2 flex-1" id="settings-params">
479
  <div class="s-row" data-param="imgsz">
480
  <div>
481
  <div class="text-xs font-semibold text-slate-300 flex items-center">Image Size
@@ -541,24 +567,6 @@
541
  onclick="stepParam('stride',1)">&#8250;</button></div>
542
  </div>
543
 
544
- <div class="s-row" data-param="smoothing">
545
- <div>
546
- <div class="text-xs font-semibold text-slate-300 flex items-center gap-1">
547
- <span>Congestion Smoothing</span>
548
- <span class="info-wrap">
549
- <span class="info-btn" style="background:#1a1a1a;color:#888"><i
550
- class="fa-solid fa-info"></i></span>
551
- <span class="info-tip">Reduces jitter/noise in the line chart. Low values
552
- (0.05-0.2) create very smooth trends; high values (0.8+) show raw spiky
553
- data.</span>
554
- </span>
555
- </div>
556
- <div class="text-[10px] text-slate-500">EMA Alpha factor for the rolling average</div>
557
- </div>
558
- <div class="s-stepper"><button onclick="stepParam('smoothing',-0.05)">&#8249;</button><span
559
- class="s-val" id="sv-smoothing">0.25</span><button
560
- onclick="stepParam('smoothing',0.05)">&#8250;</button></div>
561
- </div>
562
  </div>
563
  </div>
564
 
@@ -661,14 +669,6 @@
661
  <div class="toggle-thumb"></div>
662
  </div>
663
  </div>
664
- <div class="s-row hidden">
665
- <div>
666
- <div class="text-xs font-semibold text-slate-300">Interface Mode</div>
667
- <div class="text-[10px] text-slate-500">Locked to Professional Dark</div>
668
- </div>
669
- <div class="text-xs font-bold text-white px-3 py-1 bg-slate-800 rounded-full">Dark Mode Only
670
- </div>
671
- </div>
672
  </div>
673
  </div>
674
 
@@ -681,12 +681,12 @@
681
  </button>
682
  </div>
683
 
684
- <!-- New Analysis 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:var(--cocoa-l);color:#000">
689
- <span>Analyze again <i class="fa-solid fa-rotate-left ml-2 text-[10px]"></i></span>
690
  </button>
691
  </div>
692
  </div>
@@ -755,8 +755,8 @@
755
  </button>
756
  <div id="h3-ans" class="hidden px-6 pb-6 text-xs leading-relaxed"
757
  style="color:#777">
758
- To analyze a new video, go to 'Settings' and click 'Analyze again'
759
- This resets current session state and return you
760
  to the Home screen.
761
  </div>
762
  </div>
@@ -1130,174 +1130,80 @@
1130
  </div>
1131
  </div>
1132
 
1133
- </main>
1134
-
1135
- <script src="js/vehicles.js"></script>
1136
- <!-- Privacy Modal -->
1137
- <div id="appModal-privacyModal" onclick="if(event.target===this)closeAppModal('privacyModal')"
1138
- style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.85);z-index:9999;align-items:center;justify-content:center;padding:24px">
1139
- <div
1140
- style="background:#0a0a0a;border:1px solid #2a2a2a;border-radius:14px;max-width:480px;width:100%;padding:32px;position:relative;max-height:80vh;overflow-y:auto">
1141
- <button onclick="closeAppModal('privacyModal')"
1142
- style="position:absolute;top:16px;right:18px;background:none;border:none;color:#a89f97;font-size:18px;cursor:pointer">&times;</button>
1143
- <h2 style="color:#f0ece6;font-size:1.1rem;font-weight:700;margin-bottom:8px">Privacy Policy</h2>
1144
- <p style="color:#a89f97;font-size:11px;margin-bottom:20px">We keep this simple and honest.</p>
1145
- <ul style="color:#a89f97;font-size:11px;line-height:1.9;padding-left:16px;list-style:disc">
1146
- <li>This is a <strong style="color:#f0ece6">public demo</strong> hosted on Hugging Face Spaces. It is
1147
- not yet a complete production service.</li>
1148
- <li>Footage you submit is processed in real time and <strong style="color:#f0ece6">discarded
1149
- immediately</strong> after the session ends. Nothing is stored on our servers.</li>
1150
- <li>We do not use your footage to train models, sell it, or share it with any third party.</li>
1151
- <li>Reports and annotated videos are generated temporarily and delivered to your device. We do not
1152
- retain copies.</li>
1153
- <li>We do not use advertising cookies, behavioral tracking, or analytics scripts on this platform.</li>
1154
- <li>Your use of this demo may inform product requirements. No personally identifiable data is collected
1155
- in that process.</li>
1156
- <li>For any queries: <strong style="color:#c89a6c">support.urbanflow365@gmail.com</strong></li>
1157
- </ul>
1158
- <p style="color:#555;font-size:10px;margin-top:20px">&mdash; Team UrbanFlow</p>
1159
- </div>
1160
- </div>
1161
-
1162
- <!-- Terms Modal -->
1163
- <div id="appModal-termsModal" onclick="if(event.target===this)closeAppModal('termsModal')"
1164
- style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.85);z-index:9999;align-items:center;justify-content:center;padding:24px">
1165
- <div
1166
- style="background:#0a0a0a;border:1px solid #2a2a2a;border-radius:14px;max-width:480px;width:100%;padding:32px;position:relative;max-height:80vh;overflow-y:auto">
1167
- <button onclick="closeAppModal('termsModal')"
1168
- style="position:absolute;top:16px;right:18px;background:none;border:none;color:#a89f97;font-size:18px;cursor:pointer">&times;</button>
1169
- <h2 style="color:#f0ece6;font-size:1.1rem;font-weight:700;margin-bottom:8px">Terms &amp; Conditions</h2>
1170
- <p style="color:#a89f97;font-size:11px;margin-bottom:20px">By using this application, you agree to the
1171
- following terms.</p>
1172
- <p style="color:#c89a6c;font-size:11px;font-weight:700;margin-bottom:6px">You can:</p>
1173
- <ul
1174
- style="color:#a89f97;font-size:11px;line-height:1.9;padding-left:16px;list-style:disc;margin-bottom:16px">
1175
- <li>Use this demo to evaluate UrbanFlow&rsquo;s traffic detection and analytics capabilities.</li>
1176
- <li>Export reports, annotated video outputs, and data artifacts to your own device.</li>
1177
- <li>Share feedback, feature requests, or questions with us at <strong
1178
- style="color:#c89a6c">support.urbanflow365@gmail.com</strong>.</li>
1179
- <li>Reference this demo in research or internal evaluation, with proper attribution.</li>
1180
- </ul>
1181
- <p style="color:#c89a6c;font-size:11px;font-weight:700;margin-bottom:6px">You cannot:</p>
1182
- <ul
1183
- style="color:#a89f97;font-size:11px;line-height:1.9;padding-left:16px;list-style:disc;margin-bottom:16px">
1184
- <li>Commercially redistribute outputs or present them as your own product&rsquo;s capability.</li>
1185
- <li>Reverse-engineer, extract, or attempt to replicate the underlying model or processing pipeline.</li>
1186
- <li>Use the platform for unlawful, harmful, or safety-critical operational purposes.</li>
1187
- <li>Misrepresent demo outputs as certified or regulatory-grade traffic data.</li>
1188
- </ul>
1189
- <p style="color:#a89f97;font-size:11px">This platform is provided as-is for <strong
1190
- style="color:#f0ece6">demonstration and evaluation purposes only</strong>.
1191
- Outputs are not intended for operational, regulatory, or safety-critical use. This is an early-stage
1192
- research project, not a commercial product.</p>
1193
- <p style="color:#555;font-size:10px;margin-top:16px">Questions: <strong
1194
- style="color:#c89a6c">support.urbanflow365@gmail.com</strong></p>
1195
- </div>
1196
- </div>
1197
-
1198
- <!-- Keyboard Shortcuts Modal -->
1199
- <div id="appModal-shortcutsModal" onclick="if(event.target===this)closeAppModal('shortcutsModal')"
1200
- style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.85);z-index:9999;align-items:center;justify-content:center;padding:24px">
1201
- <div
1202
- style="background:#0a0a0a;border:1px solid #2a2a2a;border-radius:14px;max-width:380px;width:100%;padding:28px;position:relative">
1203
- <button onclick="closeAppModal('shortcutsModal')"
1204
- style="position:absolute;top:14px;right:16px;background:none;border:none;color:#a89f97;font-size:18px;cursor:pointer">&times;</button>
1205
- <h2 style="color:#f0ece6;font-size:1rem;font-weight:700;margin-bottom:4px">
1206
- <i class="fa-solid fa-keyboard" style="color:#c89a6c;margin-right:6px"></i>Keyboard Shortcuts
1207
- </h2>
1208
- <p style="color:#555;font-size:10px;margin-bottom:16px">Navigate faster with these shortcuts</p>
1209
- <div style="display:flex;flex-direction:column;gap:8px">
1210
- <div
1211
- style="display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid #1a1a1a">
1212
- <span style="color:#a89f97;font-size:11px;font-weight:500">About</span>
1213
- <kbd
1214
- style="background:#1a1a1a;color:#c89a6c;font-size:11px;font-weight:700;padding:3px 10px;border-radius:6px;border:1px solid #2a2a2a;font-family:'JetBrains Mono',monospace">1</kbd>
1215
- </div>
1216
- <div
1217
- style="display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid #1a1a1a">
1218
- <span style="color:#a89f97;font-size:11px;font-weight:500">Stats</span>
1219
- <kbd
1220
- style="background:#1a1a1a;color:#c89a6c;font-size:11px;font-weight:700;padding:3px 10px;border-radius:6px;border:1px solid #2a2a2a;font-family:'JetBrains Mono',monospace">2</kbd>
1221
- </div>
1222
- <div
1223
- style="display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid #1a1a1a">
1224
- <span style="color:#a89f97;font-size:11px;font-weight:500">Run</span>
1225
- <kbd
1226
- style="background:#1a1a1a;color:#c89a6c;font-size:11px;font-weight:700;padding:3px 10px;border-radius:6px;border:1px solid #2a2a2a;font-family:'JetBrains Mono',monospace">3</kbd>
1227
- </div>
1228
- <div
1229
- style="display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid #1a1a1a">
1230
- <span style="color:#a89f97;font-size:11px;font-weight:500">Artifacts</span>
1231
- <kbd
1232
- style="background:#1a1a1a;color:#c89a6c;font-size:11px;font-weight:700;padding:3px 10px;border-radius:6px;border:1px solid #2a2a2a;font-family:'JetBrains Mono',monospace">4</kbd>
1233
- </div>
1234
- <div
1235
- style="display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid #1a1a1a">
1236
- <span style="color:#a89f97;font-size:11px;font-weight:500">Settings</span>
1237
- <kbd
1238
- style="background:#1a1a1a;color:#c89a6c;font-size:11px;font-weight:700;padding:3px 10px;border-radius:6px;border:1px solid #2a2a2a;font-family:'JetBrains Mono',monospace">5</kbd>
1239
  </div>
1240
- <div
1241
- style="display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid #1a1a1a">
1242
- <span style="color:#a89f97;font-size:11px;font-weight:500">Quick Help</span>
1243
- <kbd
1244
- style="background:#1a1a1a;color:#c89a6c;font-size:11px;font-weight:700;padding:3px 10px;border-radius:6px;border:1px solid #2a2a2a;font-family:'JetBrains Mono',monospace">6</kbd>
 
 
 
 
 
 
1245
  </div>
1246
- <div
1247
- style="display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid #1a1a1a">
1248
- <span style="color:#a89f97;font-size:11px;font-weight:500">Feedback</span>
1249
- <kbd
1250
- style="background:#1a1a1a;color:#c89a6c;font-size:11px;font-weight:700;padding:3px 10px;border-radius:6px;border:1px solid #2a2a2a;font-family:'JetBrains Mono',monospace">7</kbd>
 
 
 
1251
  </div>
1252
- <div style="display:flex;justify-content:space-between;align-items:center;padding:6px 0">
1253
- <span style="color:#a89f97;font-size:11px;font-weight:500">Download Artifacts</span>
1254
- <kbd
1255
- style="background:#1a1a1a;color:#c89a6c;font-size:11px;font-weight:700;padding:3px 10px;border-radius:6px;border:1px solid #2a2a2a;font-family:'JetBrains Mono',monospace">D</kbd>
 
 
 
 
1256
  </div>
1257
  </div>
1258
- <p style="color:#333;font-size:9px;margin-top:14px;text-align:center">Press <strong
1259
- style="color:#555">Esc</strong> to close</p>
1260
  </div>
1261
- </div>
 
 
 
 
 
 
1262
 
1263
  <script>
1264
 
1265
- function openAppModal(id) {
1266
- const el = document.getElementById('appModal-' + id);
1267
- if (el) { el.style.display = 'flex'; document.body.style.overflow = 'hidden'; }
1268
- }
1269
- function closeAppModal(id) {
1270
- const el = document.getElementById('appModal-' + id);
1271
- if (el) { el.style.display = 'none'; document.body.style.overflow = ''; }
1272
- }
1273
-
1274
- // Legal Menu Toggle
1275
- function toggleLegalMenu(e) {
1276
- if (e) e.stopPropagation();
1277
- const menu = document.getElementById('legal-menu');
1278
- if (menu) menu.classList.toggle('hidden');
1279
- }
1280
 
1281
- document.addEventListener('click', function(e) {
1282
- const menu = document.getElementById('legal-menu');
1283
- if (menu && !menu.classList.contains('hidden')) {
1284
- if (!e.target.closest('.mobile-top-bar')) {
1285
- menu.classList.add('hidden');
1286
- }
1287
- }
1288
- });
1289
 
1290
- document.addEventListener('keydown', function (e) {
1291
- if (e.key === 'Escape') {
1292
- closeAppModal('privacyModal');
1293
- closeAppModal('termsModal');
1294
- closeAppModal('shortcutsModal');
1295
- const menu = document.getElementById('legal-menu');
1296
- if (menu) menu.classList.add('hidden');
1297
- }
1298
- });
1299
 
1300
- // Auto-show keyboard shortcuts on every page visit (Desktop only)
 
 
 
1301
  if (window.matchMedia('(hover: hover) and (pointer: fine)').matches) {
1302
  setTimeout(function () { openAppModal('shortcutsModal'); }, 800);
1303
  }
@@ -1309,10 +1215,7 @@
1309
  <button class="mob-nav-item" id="mob-nav-overview" onclick="switchTab('overview')">
1310
  <i class="fa-solid fa-desktop"></i>
1311
  </button>
1312
- <button class="mob-nav-item" id="mob-nav-run-details" onclick="switchTab('run-details')">
1313
- <i class="fa-solid fa-microchip"></i>
1314
- </button>
1315
- <button class="mob-nav-item" id="mob-nav-reports" onclick="switchTab('reports')">
1316
  <i class="fa-solid fa-file-lines"></i>
1317
  </button>
1318
  <button class="mob-nav-item" id="mob-nav-settings" onclick="switchTab('settings')">
@@ -1324,7 +1227,12 @@
1324
  <button class="mob-nav-item" id="mob-nav-feedback" onclick="switchTab('feedback')">
1325
  <i class="fa-solid fa-comment-dots"></i>
1326
  </button>
1327
- </nav>
 
 
 
 
 
1328
  <script>
1329
  if ('serviceWorker' in navigator) {
1330
  window.addEventListener('load', () => {
 
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">
11
  <meta name="theme-color" content="#000000">
12
  <meta name="apple-mobile-web-app-capable" content="yes">
13
  <meta name="apple-mobile-web-app-status-bar-style" content="black">
14
  <meta name="apple-mobile-web-app-title" content="UrbanFlow">
15
+ <link rel="apple-touch-icon" href="assets/shurkien_b.png">
16
  <script src="https://cdn.tailwindcss.com"></script>
17
  <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
18
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
 
20
  href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Montserrat:wght@400;500;600;700;800;900&display=swap"
21
  rel="stylesheet">
22
  <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
23
+ <link rel="stylesheet" href="css/shared.css">
24
  <link rel="stylesheet" href="css/vehicles.css">
25
+ <link rel="stylesheet" href="css/auth.css">
26
+ <!-- SPA Guard: no active run OR page refresh → go back to landing -->
27
+ <script>
28
+ (function() {
29
+ // SPA: rewrite URL to root so /vehicles never shows in the address bar
30
+ if (window.location.pathname !== '/') {
31
+ history.replaceState(null, '', '/');
32
+ }
33
+ var hasRun = sessionStorage.getItem('funky_run');
34
+ var consumed = sessionStorage.getItem('funky_run_consumed');
35
+ if (!hasRun || consumed) {
36
+ sessionStorage.removeItem('funky_run');
37
+ sessionStorage.removeItem('funky_run_consumed');
38
+ sessionStorage.removeItem('uf_active_tab');
39
+ window.location.replace('/');
40
+ } else {
41
+ // Mark as consumed immediately — any refresh from here will redirect
42
+ sessionStorage.setItem('funky_run_consumed', '1');
43
+ }
44
+ })();
45
+ </script>
46
+
47
  </head>
48
 
49
  <body class="bg-black text-white h-screen w-screen flex" style="overflow:hidden">
 
54
 
55
  <!-- Mobile Legal Overflow -->
56
  <div class="absolute right-4 top-1/2 -translate-y-1/2 flex items-center">
57
+ <button id="mobile-menu-trigger" onclick="toggleLegalMenu(event)" class="text-[#a89f97] hover:text-white p-2 transition-colors" aria-label="Menu">
58
+ <i id="mobile-menu-icon" class="fa-solid fa-ellipsis-vertical text-lg"></i>
59
  </button>
60
  <div id="legal-menu" class="hidden absolute right-0 top-full mt-2 w-48 bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg shadow-2xl z-50 overflow-hidden">
61
  <button onclick="openAppModal('privacyModal'); 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">
62
  Privacy Policy
63
  </button>
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>
 
82
  </a>
83
  <a onclick="switchTab('overview')" id="nav-overview"
84
  class="flex items-center px-4 py-2.5 rounded-lg transition cursor-pointer nav-item-inactive">
85
+ <i class="fa-solid fa-desktop w-6"></i> <span class="font-medium">Overview</span>
 
 
 
 
86
  </a>
87
+ <a onclick="switchTab('results')" id="nav-results"
88
  class="flex items-center px-4 py-2.5 rounded-lg transition cursor-pointer nav-item-inactive">
89
+ <i class="fa-solid fa-file-lines w-6"></i> <span class="font-medium">Results</span>
90
  </a>
91
  <a onclick="switchTab('settings')" id="nav-settings"
92
  class="flex items-center px-4 py-2.5 rounded-lg transition cursor-pointer nav-item-inactive">
 
100
  class="flex items-center px-4 py-2.5 rounded-lg transition cursor-pointer nav-item-inactive">
101
  <i class="fa-solid fa-comment-dots w-6"></i> <span class="font-medium">Feedback</span>
102
  </a>
103
+ <a onclick="switchTab('profile')" id="nav-profile"
104
+ class="flex items-center px-4 py-2.5 rounded-lg transition cursor-pointer nav-item-inactive">
105
+ <div id="sidebar-profile-pfp-wrap" class="w-6 h-6 inline-flex items-center justify-center flex-shrink-0"><i class="fa-solid fa-circle-user"></i></div>
106
+ <span class="font-medium ml-3">Profile</span>
107
+ </a>
108
  </nav>
109
  <div class="mt-auto border-t p-4 flex flex-col items-center gap-2 bg-black flex-shrink-0"
110
  style="border-color:#2a2a2a">
 
116
  class="text-[10px] font-bold uppercase tracking-widest transition w-full text-center py-1 rounded"
117
  style="color:#a89f97" onmouseover="this.style.color='#c89a6c'"
118
  onmouseout="this.style.color='#a89f97'">Terms &amp; Conditions</button>
119
+ <p class="text-[11px] font-medium mt-2 mb-1 text-center" style="color:#555">&#169; 2026 UrbanFlow<br><span class="text-[9px] text-[#444] block mt-1">All rights reserved.</span></p>
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
 
 
142
 
143
  <main class="flex-1 flex flex-col h-full min-w-0 p-4 gap-4">
144
 
145
+ <!-- Progress Bar (shared) -->
146
+ <div id="progress-bar-wrapper"
147
+ class="w-full bg-neutral-950 rounded-xl px-6 py-4 border border-neutral-800 shadow-sm flex items-center justify-between flex-shrink-0">
148
+ <div class="flex items-center space-x-4 flex-1 mr-6">
149
+ <span class="text-[11px] font-black text-white uppercase tracking-wider whitespace-nowrap"
150
+ id="proc-label">Waiting</span>
151
+ <div class="flex-1 h-2 bg-[#111111] rounded-full overflow-hidden relative border border-[#1a1a1a]">
152
+ <div id="proc-bar" class="h-full bg-[#444444] rounded-full transition-all duration-500 ease-out"
153
+ style="width: 0%"></div>
154
+ </div>
155
+ </div>
156
+ <div class="flex items-center space-x-6 text-xs font-bold text-white whitespace-nowrap">
157
+ <span id="proc-frames">Awaiting Input</span>
158
+ <span id="proc-pct">Idle</span>
159
+ <div class="uf-select-wrap" id="live-palette-wrap">
160
+ <div class="uf-select-trigger" id="live-palette-trigger" onclick="ufSelectToggle('live-palette')">
161
+ <span class="uf-select-label" id="live-palette-label">Default</span>
162
+ <i class="fa-solid fa-chevron-down uf-select-arrow" id="live-palette-arrow"></i>
163
+ </div>
164
+ <div class="uf-select-dropdown hidden" id="live-palette-dropdown">
165
+ <div class="uf-select-option" data-value="default" onclick="ufSelectPick('live-palette','default','Default')">Default</div>
166
+ <div class="uf-select-option" data-value="vibrant" onclick="ufSelectPick('live-palette','vibrant','Vibrant')">Vibrant</div>
167
+ <div class="uf-select-option" data-value="corporate" onclick="ufSelectPick('live-palette','corporate','Corporate')">Corporate</div>
168
+ <div class="uf-select-option" data-value="neon" onclick="ufSelectPick('live-palette','neon','Neon Night')">Neon Night</div>
169
+ <div class="uf-select-option" data-value="earth" onclick="ufSelectPick('live-palette','earth','Earth Tones')">Earth Tones</div>
170
+ <div class="uf-select-option" data-value="ocean" onclick="ufSelectPick('live-palette','ocean','Ocean Breeze')">Ocean Breeze</div>
171
+ <div class="uf-select-option" data-value="sunset" onclick="ufSelectPick('live-palette','sunset','Sunset Glow')">Sunset Glow</div>
172
+ <div class="uf-select-option" data-value="midnight" onclick="ufSelectPick('live-palette','midnight','Midnight Deep')">Midnight Deep</div>
173
+ <div class="uf-select-option" data-value="gold" onclick="ufSelectPick('live-palette','gold','Monochrome Gold')">Monochrome Gold</div>
174
+ </div>
175
+ <input type="hidden" id="live-palette-select" value="default">
176
+ </div>
177
+ </div>
178
+ </div>
179
+
180
  <!-- TAB: About -->
181
  <div id="tab-about" class="hidden flex-1 min-h-0 overflow-y-auto">
182
  <div class="bg-black border rounded-xl p-12 shadow-2xl space-y-8 flex flex-col justify-center"
 
214
  </p>
215
  </div>
216
 
217
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-12 pt-8 text-left border-t max-w-6xl mx-auto"
218
  style="border-color:#1a1a1a">
219
  <div class="space-y-4">
220
  <h4 class="font-bold text-[13px] uppercase tracking-wider" style="color:#f0ece6">What You Will Get Here</h4>
 
229
  </li>
230
  <li class="flex items-start gap-3"><i class="fa-solid fa-circle text-[5px] mt-1.5"
231
  style="color:#c89a6c"></i>
232
+ <span>Congestion Analytics, Peak Detection, and temporal Trends</span>
233
  </li>
234
  <li class="flex items-start gap-3"><i class="fa-solid fa-circle text-[5px] mt-1.5"
235
  style="color:#c89a6c"></i>
 
243
  </h4>
244
  <p class="text-xs leading-relaxed" style="color:#a89f97">
245
  UrbanFlow delivers an estimated accuracy of ±5–8% on dense mixed-traffic footage. Results
246
+ may vary slightly across runs due to frame-by-frame inference variability.
247
  </p>
248
  <p class="text-xs leading-relaxed" style="color:#a89f97">
249
  For research or planning use, process the same footage 2–3 times
250
  and average the results for improved reliability.
251
  </p>
252
  </div>
253
+ <div class="space-y-4">
254
+ <h4 class="font-bold text-[13px] uppercase tracking-wider" style="color:#f0ece6">What's Next (Roadmap)</h4>
255
+ <ul class="text-xs space-y-3 pl-1">
256
+ <li class="flex items-start gap-3"><i class="fa-solid fa-circle text-[5px] mt-1.5"
257
+ style="color:#c89a6c"></i>
258
+ <span><strong>RTSP Streaming Integration:</strong> Live CCTV stream processing directly from your network.</span>
259
+ </li>
260
+ <li class="flex items-start gap-3"><i class="fa-solid fa-circle text-[5px] mt-1.5"
261
+ style="color:#c89a6c"></i>
262
+ <span><strong>ANPR / ALPR:</strong> Automated Number Plate Recognition for localized enforcement.</span>
263
+ </li>
264
+ <li class="flex items-start gap-3"><i class="fa-solid fa-circle text-[5px] mt-1.5"
265
+ style="color:#c89a6c"></i>
266
+ <span><strong>Helmet Detection:</strong> Real-time compliance tracking for two-wheelers.</span>
267
+ </li>
268
+ </ul>
269
+ </div>
270
  </div>
271
 
272
  <div class="text-center pt-6 border-t" style="border-color:#1a1a1a">
 
285
  </div>
286
 
287
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
288
  <div id="tab-overview" class="hidden grid grid-cols-12 gap-4 flex-1 min-h-0 overflow-hidden"
289
  style="position:relative">
290
 
 
376
  <div class="flex-1 w-full relative min-h-[220px]">
377
  <canvas id="flowChart"></canvas>
378
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
379
  </div>
380
  </div>
381
 
382
+ <!-- TAB: Results (Merged Artifacts + Run) -->
383
+ <div id="tab-results" class="hidden flex-1 min-h-0 overflow-y-auto">
 
 
384
  <div id="reports-pending-message"
385
  class="mb-4 text-center p-8 flex flex-col items-center justify-center gap-4"
386
  style="min-height: 60vh;">
387
  <i class="fa-solid fa-hourglass-half text-5xl mb-2" style="color:#3a3230;"></i>
388
  <div style="color:#c89a6c; font-size:13px; font-weight:700; letter-spacing:0.18em; text-transform:uppercase;">
389
+ Results Pending
390
  </div>
391
  <span id="reports-pending-text"
392
  class="text-xs font-medium tracking-wide uppercase leading-relaxed text-center"
 
395
  </span>
396
  </div>
397
 
398
+ <div id="results-content-wrap" class="hidden space-y-8 w-full pb-6">
399
+
400
+ <!-- Download Button -->
401
+ <div class="flex justify-center lg:hidden">
402
+ <button onclick="downloadArtifacts()" id="btn-download-bundle" class="w-fit px-8 py-3 font-bold text-xs rounded-full transition flex items-center justify-center gap-2 shadow-lg active:scale-95 hover:scale-105"
403
+ style="background:#0a0a0a;border:1px solid var(--cocoa);color:var(--cocoa-l)">
404
+ <i class="fa-solid fa-download"></i> Download All Artifacts
405
+ </button>
 
 
 
 
 
 
 
406
  </div>
 
407
 
408
+ <!-- Section: Performance Insights (formerly Run tab) -->
409
+ <div class="space-y-6">
410
+ <!-- PCU + Report Grid (unified) -->
411
+ <div id="post-process-cards" class="mb-4">
412
+ <div class="bg-black rounded-xl p-6 border border-neutral-800 shadow-sm flex flex-col min-h-[140px]">
413
+ <div class="flex justify-between items-center mb-4 relative">
414
+ <h3 class="font-bold text-white text-sm flex items-center">PCU Analysis
415
+ <span class="info-wrap"><span class="info-btn" style="background:#222;color:#888"><i
416
+ class="fa-solid fa-info"></i></span>
417
+ <span class="info-tip">Passenger Car Units (IRC:106-1990). Converts heterogeneous Indian
418
+ traffic into standardized units for road capacity analysis.</span></span>
419
+ </h3>
420
+ </div>
421
+ <div class="flex-1 flex items-center justify-center" id="pcu-stats-card">
422
+ <div class="text-center text-slate-600 text-xs">Available after processing</div>
423
+ </div>
424
+ </div>
425
+ </div>
426
+
427
+ <!-- Report Grid -->
428
+ <div id="reports-grid" class="grid grid-cols-1 lg:grid-cols-2 gap-4"></div>
429
 
430
+ <!-- Collapsible Technical Telemetry (formerly Run tab) -->
431
+ <div class="mt-12 pt-12 border-t border-neutral-900">
432
+ <details class="group">
433
+ <summary class="flex items-center justify-between cursor-pointer list-none text-slate-500 hover:text-slate-300 transition">
434
+ <div class="flex items-center gap-3">
435
+ <i class="fa-solid fa-microchip text-xs"></i>
436
+ <span class="text-sm font-bold uppercase tracking-widest">Technical Runtime Telemetry</span>
437
+ </div>
438
+ <i class="fa-solid fa-chevron-down text-[10px] group-open:rotate-180 transition-transform"></i>
439
+ </summary>
440
+
441
+ <div class="mt-8 space-y-8 animate-in fade-in slide-in-from-top-4 duration-500">
442
+ <!-- Process Analytics -->
443
+ <div id="run-results-card" class="bg-neutral-950 rounded-xl border border-neutral-800 shadow-sm overflow-hidden flex flex-col">
444
+ <div class="px-6 py-4 border-b" style="border-color:#1a1a1a;background:#050505">
445
+ <h3 class="font-bold text-sm text-white">Execution Telemetry</h3>
446
+ </div>
447
+ <div class="p-8">
448
+ <div id="run-results-content" class="grid grid-cols-3 gap-8"></div>
449
+ </div>
450
+ </div>
451
+
452
+ <!-- Technical Context Row -->
453
+ <div class="grid grid-cols-2 gap-6">
454
+ <div class="bg-neutral-950 rounded-xl border border-neutral-800 shadow-sm flex flex-col overflow-hidden">
455
+ <div class="px-6 py-4 border-b" style="border-color:#1a1a1a;background:#050505">
456
+ <h3 class="font-bold text-sm text-white">Stream Source Profile</h3>
457
+ </div>
458
+ <div class="p-6 space-y-4 text-xs text-slate-400" id="panel-video"></div>
459
+ </div>
460
+ <div class="bg-neutral-950 rounded-xl border border-neutral-800 shadow-sm flex flex-col overflow-hidden">
461
+ <div class="px-6 py-4 border-b" style="border-color:#1a1a1a;background:#050505">
462
+ <h3 class="font-bold text-sm text-white">System Resource Utilization</h3>
463
+ </div>
464
+ <div class="p-6 space-y-4 text-xs text-slate-400" id="panel-perf"></div>
465
+ </div>
466
+ <div class="bg-neutral-950 rounded-xl border border-neutral-800 shadow-sm flex flex-col overflow-hidden">
467
+ <div class="px-6 py-4 border-b" style="border-color:#1a1a1a;background:#050505">
468
+ <h3 class="font-bold text-sm text-white">Model Architecture &amp; Logic</h3>
469
+ </div>
470
+ <div class="p-6 space-y-4 text-xs text-slate-400" id="panel-model"></div>
471
+ </div>
472
+ <div class="bg-neutral-950 rounded-xl border border-neutral-800 shadow-sm flex flex-col overflow-hidden">
473
+ <div class="px-6 py-4 border-b" style="border-color:#1a1a1a;background:#050505">
474
+ <h3 class="font-bold text-sm text-white">Inference Parameters</h3>
475
+ </div>
476
+ <div class="p-6 space-y-4 text-xs text-slate-400" id="panel-infer"></div>
477
+ </div>
478
+ </div>
479
+ </div>
480
+ </details>
481
+ </div>
482
+ </div>
483
  </div>
484
  </div>
485
 
 
487
  <div id="tab-settings" class="hidden flex-1 min-h-0 overflow-y-auto">
488
  <div class="grid grid-cols-2 gap-6 w-full">
489
  <!-- Processing Parameters -->
490
+ <div class="col-span-1 bg-black rounded-xl border shadow-sm overflow-hidden flex flex-col self-start"
491
  style="border-color:#2a2a2a">
492
  <div class="px-6 py-4 border-b" style="border-color:#1a1a1a;background:#050505">
493
  <h3 class="font-bold text-white text-sm flex items-center">Inference Configuration Profile
 
501
  <p class="text-[10px] mt-0.1 uppercase tracking-widest font-medium" style="color:#a89f97">
502
  Auto-configured pipeline parameters</p>
503
  </div>
504
+ <div class="px-6 py-2" id="settings-params">
505
  <div class="s-row" data-param="imgsz">
506
  <div>
507
  <div class="text-xs font-semibold text-slate-300 flex items-center">Image Size
 
567
  onclick="stepParam('stride',1)">&#8250;</button></div>
568
  </div>
569
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
570
  </div>
571
  </div>
572
 
 
669
  <div class="toggle-thumb"></div>
670
  </div>
671
  </div>
 
 
 
 
 
 
 
 
672
  </div>
673
  </div>
674
 
 
681
  </button>
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)">
689
+ <span><i class="fa-solid fa-house mr-2 text-sm"></i> Home</span>
690
  </button>
691
  </div>
692
  </div>
 
755
  </button>
756
  <div id="h3-ans" class="hidden px-6 pb-6 text-xs leading-relaxed"
757
  style="color:#777">
758
+ To analyze a new video, go to 'Settings' and click 'Home'.
759
+ This resets your current session and returns you
760
  to the Home screen.
761
  </div>
762
  </div>
 
1130
  </div>
1131
  </div>
1132
 
1133
+ <!-- TAB: Profile -->
1134
+ <div id="tab-profile" class="hidden flex-1 min-h-0 overflow-y-auto">
1135
+ <!-- Single unified card — full width -->
1136
+ <div class="bg-neutral-950 rounded-2xl border border-neutral-900 overflow-hidden">
1137
+ <!-- Identity Section -->
1138
+ <div class="p-6 lg:p-8 flex flex-col items-center lg:flex-row lg:items-start gap-6 border-b border-neutral-900">
1139
+ <div class="relative group flex-shrink-0">
1140
+ <img id="profile-pfp-large" src="" alt="" class="w-20 h-20 lg:w-24 lg:h-24 rounded-full border-2 border-neutral-800 shadow-2xl object-cover">
1141
+ <div class="absolute inset-0 rounded-full bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center text-[10px] font-bold text-white uppercase text-center p-2">Managed by Google</div>
1142
+ </div>
1143
+ <div class="flex-1 w-full text-center lg:text-left space-y-4">
1144
+ <div>
1145
+ <label class="text-[10px] font-bold uppercase tracking-widest text-slate-500 block mb-1">Email Address</label>
1146
+ <div id="profile-email" class="text-sm font-medium text-slate-300"></div>
1147
+ </div>
1148
+ <div>
1149
+ <label class="text-[10px] font-bold uppercase tracking-widest text-slate-500 block mb-1">Display Name</label>
1150
+ <div class="flex items-center gap-3 justify-center lg:justify-start">
1151
+ <input type="text" id="profile-username-input" class="bg-neutral-900 border border-neutral-800 rounded-lg px-4 py-2 text-sm text-white focus:outline-none focus:border-neutral-600 transition w-full max-w-[240px]">
1152
+ <button onclick="saveProfileUsername()" id="btn-save-username" class="px-4 py-2 bg-neutral-800 hover:bg-neutral-700 text-[10px] font-bold uppercase tracking-widest rounded-lg text-slate-300 transition">Save</button>
1153
+ </div>
1154
+ <p class="text-[9px] text-slate-600 mt-1">This name will be used on exported reports.</p>
1155
+ </div>
1156
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1157
  </div>
1158
+
1159
+ <!-- Stats Row -->
1160
+ <div class="grid grid-cols-2 gap-0 border-b border-neutral-900">
1161
+ <div class="p-5 lg:p-6 border-r border-neutral-900">
1162
+ <div class="text-[9px] font-bold uppercase tracking-widest text-slate-600 mb-1">Total Runs</div>
1163
+ <div id="profile-total-runs" class="text-2xl font-bold text-white">0</div>
1164
+ </div>
1165
+ <div class="p-5 lg:p-6">
1166
+ <div class="text-[9px] font-bold uppercase tracking-widest text-slate-600 mb-1">Last Active</div>
1167
+ <div id="profile-last-active" class="text-sm font-bold text-slate-300">Never</div>
1168
+ </div>
1169
  </div>
1170
+
1171
+ <!-- Terms Acceptance Indicator -->
1172
+ <div class="p-5 lg:p-6 flex items-center gap-3 border-b border-neutral-900">
1173
+ <i class="fa-solid fa-circle-check text-emerald-500"></i>
1174
+ <div>
1175
+ <div class="text-xs font-bold text-slate-300">Terms & Privacy Accepted</div>
1176
+ <div class="text-[9px] text-slate-600">You have agreed to the Privacy Policy and Terms & Conditions.</div>
1177
+ </div>
1178
  </div>
1179
+
1180
+ <!-- Sign Out -->
1181
+ <div class="p-5 lg:p-6 flex items-center justify-between">
1182
+ <div>
1183
+ <h3 class="text-xs font-bold uppercase tracking-widest text-red-400">Account Access</h3>
1184
+ <p class="text-[10px] text-slate-500 mt-1">Sign out clears your local session.</p>
1185
+ </div>
1186
+ <button onclick="showLogoutConfirm()" class="px-6 py-2.5 bg-red-900/20 hover:bg-red-900/30 text-red-400 border border-red-900/30 text-[10px] font-bold uppercase tracking-widest rounded-xl transition">Sign Out</button>
1187
  </div>
1188
  </div>
 
 
1189
  </div>
1190
+
1191
+ </main>
1192
+
1193
+ <script src="js/templates.js"></script>
1194
+ <script src="js/shared.js"></script>
1195
+ <script src="js/auth.js"></script>
1196
+ <script src="js/vehicles.js"></script>
1197
 
1198
  <script>
1199
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
  }
 
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')">
 
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', () => {
requirements.txt CHANGED
@@ -10,6 +10,7 @@ websockets==12.0
10
  openvino>=2024.0.0
11
  lap>=0.5.12
12
  resend
 
13
  torch==2.1.0+cpu
14
  torchvision==0.16.0+cpu
15
  --extra-index-url https://download.pytorch.org/whl/cpu
 
10
  openvino>=2024.0.0
11
  lap>=0.5.12
12
  resend
13
+ google-auth>=2.20.0
14
  torch==2.1.0+cpu
15
  torchvision==0.16.0+cpu
16
  --extra-index-url https://download.pytorch.org/whl/cpu