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