From e8495c02ec089c865f173be2300ef392309d7cd8 Mon Sep 17 00:00:00 2001 From: ajouatom Date: Thu, 25 Jun 2026 10:00:36 +0900 Subject: [PATCH 01/12] Retry carrot tmux log uploads --- selfdrive/carrot/carrot_man.py | 234 ++++++++++++++++++++++++--------- 1 file changed, 170 insertions(+), 64 deletions(-) diff --git a/selfdrive/carrot/carrot_man.py b/selfdrive/carrot/carrot_man.py index b244ea1f55e..1498463b494 100644 --- a/selfdrive/carrot/carrot_man.py +++ b/selfdrive/carrot/carrot_man.py @@ -52,7 +52,9 @@ NAVI_IMAGE_BASE64_MAX_CHARS = 6 * 1024 * 1024 NAVI_ROUTE_MAX_POINTS = 4096 NAVI_ROUTE_SUMMARY_MAX_SCAN = 20000 -AUTO_ONROAD_DIAGNOSTICS = os.environ.get("CARROT_AUTO_ONROAD_DIAGNOSTICS", "0").strip().lower() in ("1", "true", "yes", "on") +AUTO_ONROAD_DIAGNOSTICS = os.environ.get("CARROT_AUTO_ONROAD_DIAGNOSTICS", "1").strip().lower() in ("1", "true", "yes", "on") +AUTO_ONROAD_TMUX_DELAY_SECONDS = float(os.environ.get("CARROT_AUTO_ONROAD_TMUX_DELAY_SECONDS", "60")) +CARROT_EXCEPTION_UPLOAD_RETRY_SECONDS = 60.0 def limit_route_points(points, max_points=NAVI_ROUTE_MAX_POINTS): @@ -73,6 +75,40 @@ def limit_route_points(points, max_points=NAVI_ROUTE_MAX_POINTS): previous_index = source_index return limited +_carrot_exception_tmux_send_lock = threading.Lock() +_carrot_exception_tmux_send_queued = False + + +def reset_carrot_exception_tmux_send_queue() -> None: + global _carrot_exception_tmux_send_queued + + with _carrot_exception_tmux_send_lock: + _carrot_exception_tmux_send_queued = False + + +def queue_carrot_exception_tmux_send(context: str = "") -> None: + global _carrot_exception_tmux_send_queued + + with _carrot_exception_tmux_send_lock: + if _carrot_exception_tmux_send_queued: + return + + try: + params = Params() + current = params.get("CarrotException") + if current in (None, "", b""): + put_nonblocking = getattr(params, "put_nonblocking", None) + if callable(put_nonblocking): + put_nonblocking("CarrotException", "tmux_send") + else: + params.put("CarrotException", "tmux_send") + _carrot_exception_tmux_send_queued = True + print(f"[carrot_man] CarrotException tmux_send queued: {context or 'exception'}") + elif current == "tmux_send": + _carrot_exception_tmux_send_queued = True + except Exception as e: + print(f"[carrot_man] failed to queue CarrotException tmux_send: {e}") + ################ CarrotNavi ## 국가법령정보센터: 도로설계기준 #V_CURVE_LOOKUP_BP = [0., 1./800., 1./670., 1./560., 1./440., 1./360., 1./265., 1./190., 1./135., 1./85., 1./55., 1./30., 1./15.] @@ -362,12 +398,14 @@ def broadcast_version_info(self): self.connection = None print(f"##### broadcast_error...: {e}") traceback.print_exc() + queue_carrot_exception_tmux_send("broadcast_version_info") rk.keep_time() frame += 1 except Exception as e: print(f"broadcast_version_info error...: {e}") traceback.print_exc() + queue_carrot_exception_tmux_send("broadcast_version_info") time.sleep(1) @@ -641,6 +679,7 @@ def kisa_app_thread(self): #print(json_obj) except Exception as e: traceback.print_exc() + queue_carrot_exception_tmux_send("kisa_app_thread") print(f"kisa_app_thread: json error...: {e}") print(data) @@ -667,58 +706,64 @@ def kisa_app_thread(self): def make_tmux_data(self): try: - subprocess.run("rm /data/media/tmux.log; tmux capture-pane -pq -S-1000 > /data/media/tmux.log", shell=True, capture_output=True, text=False) + subprocess.run("rm -f /data/media/tmux.log; tmux capture-pane -pq -S-1000 > /data/media/tmux.log", shell=True, capture_output=True, text=False, check=True) subprocess.run("/data/openpilot/selfdrive/apilot.py", shell=True, capture_output=True, text=False) + return True except Exception as e: print(f"TMUX creation error: {e}") - return + return False def send_tmux(self, ftp_password, tmux_why, send_settings=False): ftp_server = "shind0.synology.me" ftp_port = 8021 ftp_username = "carrotpilot" - ftp = FTP() - ftp.connect(ftp_server, ftp_port) - ftp.login(ftp_username, ftp_password) - car_selected = Params().get("CarName") - if car_selected is None: - car_selected = "none" - else: - car_selected = car_selected - - git_branch = Params().get("GitBranch").replace("/", "__") + ftp = FTP(timeout=10) try: - ftp.mkd(git_branch) - except Exception as e: - print(f"Directory creation failed: {e}") - ftp.cwd(git_branch) + ftp.connect(ftp_server, ftp_port, timeout=10) + ftp.login(ftp_username, ftp_password) + car_selected = Params().get("CarName") or "none" - directory = car_selected + " " + Params().get("DongleId") - current_time = datetime.now().strftime("%Y%m%d-%H%M%S") - filename = tmux_why + "-" + current_time + "-" + git_branch + ".txt" + git_branch = (Params().get("GitBranch") or "unknown").replace("/", "__") + try: + ftp.mkd(git_branch) + except Exception as e: + print(f"Directory creation failed: {e}") + ftp.cwd(git_branch) - try: - ftp.mkd(directory) - except Exception as e: - print(f"Directory creation failed: {e}") - ftp.cwd(directory) + directory = car_selected + " " + (Params().get("DongleId") or "unknown") + current_time = datetime.now().strftime("%Y%m%d-%H%M%S") + filename = tmux_why + "-" + current_time + "-" + git_branch + ".txt" + + try: + ftp.mkd(directory) + except Exception as e: + print(f"Directory creation failed: {e}") + ftp.cwd(directory) - try: with open("/data/media/tmux.log", "rb") as file: ftp.storbinary(f'STOR {filename}', file) - except Exception as e: - print(f"ftp sending error...: {e}") - if send_settings: - self.save_toggle_values() + if send_settings: + self.save_toggle_values() + try: + #with open("/data/backup_params.json", "rb") as file: + with open("/data/toggle_values.json", "rb") as file: + ftp.storbinary(f'STOR toggles-{current_time}.json', file) + except Exception as e: + print(f"ftp params sending error...: {e}") + return True + except Exception as e: + print(f"ftp tmux sending error...: {e}") + traceback.print_exc() + return False + finally: try: - #with open("/data/backup_params.json", "rb") as file: - with open("/data/toggle_values.json", "rb") as file: - ftp.storbinary(f'STOR toggles-{current_time}.json', file) - except Exception as e: - print(f"ftp params sending error...: {e}") - - ftp.quit() + ftp.quit() + except Exception: + try: + ftp.close() + except Exception: + pass def send_tmux_http(self, tmux_why, send_settings=False): def get_private_ip_by_iface(name="wlan0"): @@ -752,18 +797,21 @@ def _pstr(key): "local_ip" : get_private_ip_by_iface("wlan0"), } - files = [ - ("files[0]", ("tmux.log", open("/data/media/tmux.log", "rb"), "text/plain")), - ] - - if send_settings: - #self.save_toggle_values() - files.append(("files[1]",("toggle_values.json",open("/data/toggle_values.json", "rb"),"application/json"))) - params = {} headers = {} + files = [] try: + files.append(("files[0]", ("tmux.log", open("/data/media/tmux.log", "rb"), "text/plain"))) + + if send_settings: + #self.save_toggle_values() + self.save_toggle_values() + try: + files.append(("files[1]",("toggle_values.json",open("/data/toggle_values.json", "rb"),"application/json"))) + except Exception as e: + print(f"http params file open error...: {e}") + response = requests.post( url, params=params, @@ -774,6 +822,10 @@ def _pstr(key): ) print(response.status_code, response.text) return response + except Exception as e: + print(f"http tmux sending error...: {e}") + traceback.print_exc() + return None finally: for _, fileinfo in files: fileobj = fileinfo[1] @@ -864,10 +916,16 @@ def setup_socket(): socket, poller = setup_socket() isOnroadCount = 0 is_tmux_sent = False + onroad_start_at = None + onroad_tmux_captured = False + onroad_tmux_next_attempt_at = 0.0 + pending_tmux_reason = None + pending_tmux_next_attempt_at = 0.0 print("#########carrot_cmd_zmq: thread started...") while True: try: + now = time.monotonic() socks = dict(poller.poll(100)) if socket in socks and socks[socket] == zmq.POLLIN: @@ -878,27 +936,69 @@ def setup_socket(): json_obj = None if json_obj is None: - isOnroadCount = isOnroadCount + 1 if self.params.get_bool("IsOnroad") else 0 - if isOnroadCount == 0: + is_onroad = self.params.get_bool("IsOnroad") + if is_onroad: + if onroad_start_at is None: + onroad_start_at = now + isOnroadCount = 1 + is_tmux_sent = False + onroad_tmux_captured = False + onroad_tmux_next_attempt_at = 0.0 + if AUTO_ONROAD_DIAGNOSTICS: + self.show_panda_debug = True + else: + isOnroadCount += 1 + else: + isOnroadCount = 0 + onroad_start_at = None is_tmux_sent = False - if AUTO_ONROAD_DIAGNOSTICS and isOnroadCount == 1: - self.show_panda_debug = True + onroad_tmux_captured = False + onroad_tmux_next_attempt_at = 0.0 network_type = self.sm['deviceState'].networkType # if not force_wifi else NetworkType.wifi networkConnected = False if network_type == NetworkType.none else True - if AUTO_ONROAD_DIAGNOSTICS and isOnroadCount == 500: - self.make_tmux_data() - if AUTO_ONROAD_DIAGNOSTICS and isOnroadCount > 500 and not is_tmux_sent and networkConnected: - self.send_tmux("Ekdrmsvkdlffjt7710", "onroad", send_settings = True) - self.send_tmux_http("onroad", send_settings = True) - is_tmux_sent = True + if AUTO_ONROAD_DIAGNOSTICS and onroad_start_at is not None and not is_tmux_sent: + onroad_elapsed = now - onroad_start_at + if not onroad_tmux_captured and onroad_elapsed >= AUTO_ONROAD_TMUX_DELAY_SECONDS and now >= onroad_tmux_next_attempt_at: + if self.make_tmux_data(): + onroad_tmux_captured = True + onroad_tmux_next_attempt_at = 0.0 + print(f"[carrot_man] onroad tmux captured after {onroad_elapsed:.1f}s; waiting for network upload") + else: + onroad_tmux_next_attempt_at = now + CARROT_EXCEPTION_UPLOAD_RETRY_SECONDS + + if onroad_tmux_captured and networkConnected and now >= onroad_tmux_next_attempt_at: + ftp_ok = self.send_tmux("Ekdrmsvkdlffjt7710", "onroad", send_settings = True) + http_response = self.send_tmux_http("onroad", send_settings = True) + http_ok = http_response is not None and getattr(http_response, "ok", False) + if ftp_ok or http_ok: + print(f"[carrot_man] onroad tmux upload complete: ftp_ok={ftp_ok}, http_ok={http_ok}") + is_tmux_sent = True + else: + onroad_tmux_next_attempt_at = now + CARROT_EXCEPTION_UPLOAD_RETRY_SECONDS carrot_exception = self.params.get("CarrotException") - if carrot_exception in ["exception", "log", "tmux_send"] and networkConnected: - self.params.put("CarrotException", "") - self.make_tmux_data() - self.send_tmux("Ekdrmsvkdlffjt7710", carrot_exception) - self.send_tmux_http(carrot_exception, send_settings = False) + if carrot_exception in ["exception", "log", "tmux_send"] and pending_tmux_reason is None and now >= pending_tmux_next_attempt_at: + if self.make_tmux_data(): + pending_tmux_reason = carrot_exception + pending_tmux_next_attempt_at = 0.0 + print(f"[carrot_man] tmux captured for {carrot_exception}; waiting for network upload") + else: + pending_tmux_next_attempt_at = now + CARROT_EXCEPTION_UPLOAD_RETRY_SECONDS + reset_carrot_exception_tmux_send_queue() + + if pending_tmux_reason is not None and networkConnected and now >= pending_tmux_next_attempt_at: + ftp_ok = self.send_tmux("Ekdrmsvkdlffjt7710", pending_tmux_reason) + http_response = self.send_tmux_http(pending_tmux_reason, send_settings = False) + http_ok = http_response is not None and getattr(http_response, "ok", False) + if ftp_ok or http_ok: + print(f"[carrot_man] tmux upload complete for {pending_tmux_reason}: ftp_ok={ftp_ok}, http_ok={http_ok}") + self.params.put("CarrotException", "") + pending_tmux_reason = None + pending_tmux_next_attempt_at = 0.0 + reset_carrot_exception_tmux_send_queue() + else: + pending_tmux_next_attempt_at = now + CARROT_EXCEPTION_UPLOAD_RETRY_SECONDS elif 'echo_cmd' in json_obj: try: result = subprocess.run(json_obj['echo_cmd'], shell=True, capture_output=True, text=False) @@ -916,10 +1016,12 @@ def setup_socket(): #print(echo) socket.send(echo.encode()) elif 'tmux_send' in json_obj: - self.make_tmux_data() - self.send_tmux(json_obj['tmux_send'], "tmux_send") - self.send_tmux_http("tmux_send") - echo = json.dumps({"tmux_send": json_obj['tmux_send'], "result": "success"}) + tmux_created = self.make_tmux_data() + ftp_ok = self.send_tmux(json_obj['tmux_send'], "tmux_send") if tmux_created else False + http_response = self.send_tmux_http("tmux_send") if tmux_created else None + http_ok = http_response is not None and getattr(http_response, "ok", False) + result = "success" if ftp_ok or http_ok else "failed" + echo = json.dumps({"tmux_send": json_obj['tmux_send'], "result": result, "ftp_ok": ftp_ok, "http_ok": http_ok}) socket.send(echo.encode()) except Exception as e: print(f"carrot_cmd_zmq error: {e}") @@ -1810,6 +1912,7 @@ def _safe_dispatch_handler(self, label: str, handler: Any, *args: Any): except Exception as e: print(f"navi {label} handler error: {e}") traceback.print_exc() + queue_carrot_exception_tmux_send(f"navi {label} handler") return None def carrot_navi_http_thread(self): @@ -1819,6 +1922,7 @@ def carrot_navi_http_thread(self): except Exception as e: print(f"navi http server error: {e}") traceback.print_exc() + queue_carrot_exception_tmux_send("navi http server") time.sleep(2) def carrot_navi_tcp_server(self, port: int = 7712): @@ -1911,6 +2015,7 @@ async def carrot_http_post(self, request: web.Request): except Exception as e: print(f"[HTTP] dispatch error: {e}") traceback.print_exc() + queue_carrot_exception_tmux_send("navi http dispatch") return web.json_response({ "ok": False, "error": str(e), @@ -1975,6 +2080,7 @@ def main(): except Exception as e: print(f"carrot_man error...: {e}") traceback.print_exc() + queue_carrot_exception_tmux_send("carrot_man_thread") time.sleep(10) From 8207a0f3d0fdad1427f68816a431337aa4a6ace2 Mon Sep 17 00:00:00 2001 From: jominki354 Date: Thu, 25 Jun 2026 09:37:10 +0900 Subject: [PATCH 02/12] web f f f f f f f f f f f f f f f --- selfdrive/carrot/server/features/params.py | 2 +- selfdrive/carrot/server/services/params.py | 109 ++++++---- selfdrive/carrot/web/css/components.css | 193 ++++++++++-------- selfdrive/carrot/web/css/pages/drive.css | 27 +++ selfdrive/carrot/web/css/pages/logs.css | 15 +- .../carrot/web/css/pages/settings/base.css | 2 +- .../carrot/web/css/pages/settings/device.css | 187 ++++++++++++++--- .../carrot/web/css/pages/settings/panels.css | 55 +++-- selfdrive/carrot/web/css/pages/terminal.css | 2 +- selfdrive/carrot/web/css/tokens.css | 51 ++++- selfdrive/carrot/web/index.html | 38 ++-- selfdrive/carrot/web/js/pages/setting.js | 161 ++++++++++++++- .../carrot/web/js/pages/setting_device.js | 2 +- .../carrot/web/js/realtime/app_realtime.js | 45 ++++ selfdrive/carrot/web/js/shared/i18n.js | 2 + selfdrive/carrot/web/js/shared/ui/dialog.js | 38 +++- selfdrive/carrot/web/js/translations/en.js | 8 + selfdrive/carrot/web/js/translations/ko.js | 8 + selfdrive/carrot/web/js/translations/zh.js | 8 + 19 files changed, 736 insertions(+), 217 deletions(-) diff --git a/selfdrive/carrot/server/features/params.py b/selfdrive/carrot/server/features/params.py index a8893ad1328..0873c42a4ac 100644 --- a/selfdrive/carrot/server/features/params.py +++ b/selfdrive/carrot/server/features/params.py @@ -87,7 +87,7 @@ async def api_param_set(request: web.Request) -> web.Response: pass try: - set_param_value(name, value) + set_param_value(name, value, p) return web.json_response({"ok": True, "name": name, "value": value, "has_params": HAS_PARAMS}) except Exception as e: return web.json_response({"ok": False, "error": str(e)}, status=500) diff --git a/selfdrive/carrot/server/services/params.py b/selfdrive/carrot/server/services/params.py index cadcb57c3d9..e05b5f8348f 100644 --- a/selfdrive/carrot/server/services/params.py +++ b/selfdrive/carrot/server/services/params.py @@ -180,55 +180,88 @@ def get_param_values(names: list[str], defaults: Optional[Dict[str, Any]] = None return values -def put_typed(params: "Params", key: str, value: Any) -> None: - try: - t = params.get_type(key) - - # BOOL - if t == ParamKeyType.BOOL: - v = value in ("1", "true", "True", "on", "yes") if isinstance(value, str) else bool(value) - params.put_bool(key, v) - return - - # INT - if t == ParamKeyType.INT: - params.put_int(key, int(float(value))) - return - - # FLOAT - if t == ParamKeyType.FLOAT: - params.put_float(key, float(value)) - return +def _coerce_bool(value: Any) -> bool: + if isinstance(value, str): + return value.strip().lower() in ("1", "true", "on", "yes") + return bool(value) + + +def _put_inferred(params: "Params", key: str, value: Any, p: Optional[Dict[str, Any]]) -> None: + """Write using a type inferred from the setting definition. + + Used when the runtime type system (get_type / ParamKeyType) is unavailable, + so a missing ParamKeyType can no longer silently drop the write (the previous + code promised this 'inference' fallback in a comment but never did it).""" + kind = infer_type_from_setting(p) + if kind == "bool": + v = _coerce_bool(value) + if hasattr(params, "put_bool"): + params.put_bool(key, v) + else: + params.put(key, "1" if v else "0") + elif kind == "int": + iv = int(float(value)) + if hasattr(params, "put_int"): + params.put_int(key, iv) + else: + params.put(key, str(iv)) + elif kind == "float": + fv = float(value) + if hasattr(params, "put_float"): + params.put_float(key, fv) + else: + params.put(key, repr(fv)) + elif kind == "json": + obj = json.loads(value) if isinstance(value, str) else value + params.put(key, obj) + else: + params.put(key, str(value)) - # TIME (string ISO) - if t == ParamKeyType.TIME: - params.put(key, str(value)) - return - # STRING - if t == ParamKeyType.STRING: - params.put(key, str(value)) - return +def put_typed(params: "Params", key: str, value: Any, p: Optional[Dict[str, Any]] = None) -> None: + """Persist value with the param's declared type. - # JSON - if t == ParamKeyType.JSON: - obj = json.loads(value) if isinstance(value, str) else value - params.put(key, obj) + Prefers the runtime type (get_type / ParamKeyType); when that is unavailable + (older fork, ParamKeyType is None, or an unknown key) it falls back to a type + inferred from the setting definition. A genuine write failure is RAISED so the + caller (e.g. /api/param_set) reports it — previously every error here was + swallowed, so a failed save still returned ok and the UI showed a false + success ("toggle not saved").""" + t = None + if ParamKeyType is not None: + try: + t = params.get_type(key) + except Exception: + t = None - # BYTES 등은 일단 스킵 - raise RuntimeError(f"Unsupported ParamKeyType for {key}: {t}") + if t is None or ParamKeyType is None: + _put_inferred(params, key, value, p) + return - except Exception: - # fall through to inference - pass + if t == ParamKeyType.BOOL: + params.put_bool(key, _coerce_bool(value)) + elif t == ParamKeyType.INT: + params.put_int(key, int(float(value))) + elif t == ParamKeyType.FLOAT: + params.put_float(key, float(value)) + elif t == ParamKeyType.TIME: + params.put(key, str(value)) + elif t == ParamKeyType.STRING: + params.put(key, str(value)) + elif t == ParamKeyType.JSON: + obj = json.loads(value) if isinstance(value, str) else value + params.put(key, obj) + else: + # BYTES or anything unmapped → best-effort string write. + params.put(key, str(value)) -def set_param_value(name: str, value: Any) -> None: +def set_param_value(name: str, value: Any, p: Optional[Dict[str, Any]] = None) -> None: if not HAS_PARAMS: _mem_store[name] = str(value) return params = Params() - put_typed(params, name, value) + put_typed(params, name, value, p) # ----------------------- diff --git a/selfdrive/carrot/web/css/components.css b/selfdrive/carrot/web/css/components.css index 4794fb9f7a8..4a4cf5fb919 100644 --- a/selfdrive/carrot/web/css/components.css +++ b/selfdrive/carrot/web/css/components.css @@ -22,19 +22,22 @@ body[data-page="terminal"] .app-toast-host { } .app-toast { - width: min(100%, 420px); - padding: 12px 14px; - border: 1px solid color-mix(in srgb, var(--md-stroke-soft) 88%, transparent); - border-radius: var(--r-md); - background: color-mix(in srgb, var(--md-surface-cont) 94%, #000); - color: var(--md-on-surface); - font-size: var(--fs-body-sm); + display: flex; + align-items: center; + gap: 12px; + width: min(100%, 440px); + padding: 14px 18px; + border: 1px solid var(--toast-border); + border-radius: var(--toast-radius); + background: var(--toast-bg); + color: var(--toast-fg); + box-shadow: var(--toast-shadow); + font-size: 15px; font-weight: 600; line-height: 1.45; - white-space: pre-wrap; opacity: 0; - transform: translateY(10px); - transition: opacity 0.18s ease, transform 0.18s ease; + transform: translateY(16px); + transition: opacity 0.2s ease, transform 0.26s cubic-bezier(0.2, 0, 0, 1); } .app-toast.is-visible { @@ -42,22 +45,30 @@ body[data-page="terminal"] .app-toast-host { transform: translateY(0); } -.app-toast.is-error { - border-color: color-mix(in srgb, var(--md-error) 42%, var(--md-stroke-soft)); - color: color-mix(in srgb, var(--md-error) 72%, var(--md-on-surface)); +/* Round tone icon — the only part that changes color between tones. */ +.app-toast__icon { + flex: none; + width: var(--toast-icon-size); + height: var(--toast-icon-size); + border-radius: 50%; + display: grid; + place-items: center; + background: var(--toast-icon-default); + color: var(--toast-icon-fg); + font: 900 14px/1 system-ui, sans-serif; } -.app-toast.is-success { - border-color: color-mix(in srgb, var(--md-primary) 38%, var(--md-stroke-soft)); - color: var(--md-primary); +.app-toast__msg { + flex: 1; + min-width: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; } -.app-toast.is-hint { - border-color: color-mix(in srgb, var(--md-stroke-soft) 44%, transparent); - background: color-mix(in srgb, var(--md-surface-cont) 88%, #000); - color: var(--md-on-surface-var); - font-weight: 500; -} +.app-toast.is-info .app-toast__icon { background: var(--toast-icon-info); } +.app-toast.is-success .app-toast__icon { background: var(--toast-icon-success); } +.app-toast.is-warn .app-toast__icon { background: var(--toast-icon-warn); } +.app-toast.is-error .app-toast__icon { background: var(--toast-icon-error); } .app-dialog { position: fixed; @@ -87,7 +98,7 @@ body[data-page="terminal"] .app-toast-host { inset: 0; border: 0; padding: 0; - background: color-mix(in srgb, #000 62%, transparent); + background: var(--popup-backdrop); opacity: 0; backdrop-filter: blur(2px) saturate(106%); -webkit-backdrop-filter: blur(2px) saturate(106%); @@ -102,10 +113,10 @@ body[data-page="terminal"] .app-toast-host { display: flex; flex-direction: column; padding: var(--sp-lg); - border: 1px solid color-mix(in srgb, var(--md-stroke-soft) 92%, transparent); - border-radius: var(--dialog-sheet-radius); - background: color-mix(in srgb, var(--md-surface-cont) 96%, #000); - box-shadow: var(--shadow-4); + border: 1px solid var(--popup-border-color); + border-radius: var(--popup-radius); + background: var(--popup-bg); + box-shadow: var(--popup-shadow); opacity: 0; transform: translateY(14px) scale(0.985); transition: @@ -197,61 +208,48 @@ body[data-page="terminal"] .app-toast-host { font-weight: 800; } -/* 박스 안 박스 금지 — 다이얼로그 시트 자체가 박스이므로 리스트엔 테두리/배경/라운드를 - 두지 않는다. 상·하 hairline 으로만 구역을 나누고 항목 사이는 구분선만. */ +/* Unified popup item look: compact boxed rows matching the branch picker. + Value grids remain a separate compact control for short numeric choices. */ .app-dialog__choices--list { - gap: 0; + gap: var(--sp-sm); padding: 0; border: 0; - border-top: 1px solid color-mix(in srgb, var(--md-stroke-soft) 30%, transparent); - border-bottom: 1px solid color-mix(in srgb, var(--md-stroke-soft) 30%, transparent); background: transparent; } -/* list 항목은 .app-dialog__choices--list 하위로 묶어 우선순위를 .btn(파일 뒤쪽 - 정의, 알약 라운드+1px 보더)보다 높인다. 그래야 사각·풀폭 행이 확실히 유지돼 - 선택/포커스 시 둥근 박스가 컨테이너 보더와 겹치지 않는다. */ .app-dialog__choices--list .app-dialog__choiceBtn--action { justify-content: space-between; text-align: left; white-space: normal; line-height: 1.25; min-height: 48px; - padding: 0 14px; - border: 0; - border-bottom: 1px solid color-mix(in srgb, var(--md-stroke-soft) 40%, transparent); - border-radius: 0; - background: transparent; + padding: var(--sp-md) var(--sp-lg); + border: 1px solid var(--popup-item-border); + border-radius: var(--popup-item-radius); + background: var(--popup-item-bg); box-shadow: none; } -.app-dialog__choices--list .app-dialog__choiceBtn--action:last-child { - border-bottom: 0; -} - -/* 즉시 동작(Play/Upload/Delete…)에 navigation chevron(›)은 오해를 부른다 → 없음 */ -.app-dialog__choices--list .app-dialog__choiceBtn--action::after { - content: ""; - display: none; -} - -/* 호버/포커스: 둥근 포커스링·보더 대신 풀폭 배경만 (선 겹침 방지) */ .app-dialog__choices--list .app-dialog__choiceBtn--action:hover, .app-dialog__choices--list .app-dialog__choiceBtn--action:focus-visible { - background: color-mix(in srgb, var(--md-on-surface) 8%, transparent); - border-bottom-color: color-mix(in srgb, var(--md-stroke-soft) 40%, transparent); - border-radius: 0; + border-color: color-mix(in srgb, var(--md-primary) 38%, var(--md-outline-var)); + background: color-mix(in srgb, var(--md-surface-cont-h) 88%, var(--md-primary)); outline: none; - box-shadow: none; +} + +.app-dialog__choices--list .app-dialog__choiceBtn--action:active { + transform: translateY(1px); } .app-dialog__choices--list .app-dialog__choiceBtn--action.is-current { - color: var(--md-on-surface); - background: color-mix(in srgb, var(--md-primary) 14%, transparent); - border-bottom-color: color-mix(in srgb, var(--md-stroke-soft) 40%, transparent); + border-color: var(--popup-item-active-border); + background: var(--popup-item-active-bg); + color: var(--md-primary); + font-weight: 850; } -/* 선택된 항목만 체크(✓) — 자체완결 */ +/* Selected indicator — checkmark (branch picker uses a "현재" badge; the check + is the dialog's equivalent affordance). */ .app-dialog__choices--list .app-dialog__choiceBtn--action.is-current::after { content: ""; display: block; @@ -269,17 +267,32 @@ body[data-page="terminal"] .app-toast-host { gap: 6px; } -.app-dialog__choiceBtn--value { +.app-dialog__choices--value-grid .app-dialog__choiceBtn--value { min-height: 42px; padding: 0 6px; justify-content: center; - border-radius: var(--dialog-choice-grid-radius); - background: var(--md-surface-cont-h); + border: 1px solid var(--popup-item-border); + border-radius: var(--popup-item-radius); + background: var(--popup-item-bg); text-align: center; white-space: nowrap; font-variant-numeric: tabular-nums; } +.app-dialog__choices--value-grid .app-dialog__choiceBtn--value:hover, +.app-dialog__choices--value-grid .app-dialog__choiceBtn--value:focus-visible { + border-color: color-mix(in srgb, var(--md-primary) 38%, var(--md-outline-var)); + background: color-mix(in srgb, var(--popup-item-bg) 90%, var(--md-primary)); + outline: none; +} + +.app-dialog__choices--value-grid .app-dialog__choiceBtn--value.is-current { + border-color: var(--popup-item-active-border); + background: var(--popup-item-active-bg); + color: var(--md-primary); + font-weight: 850; +} + .app-dialog__inputWrap { margin-top: var(--sp-md); } @@ -441,7 +454,7 @@ body[data-page="terminal"] .app-toast-host { .app-branch-picker__groupCount { flex: 0 0 auto; padding: 3px 8px; - border-radius: var(--r-pill); + border-radius: var(--r-round); background: color-mix(in srgb, var(--md-surface-cont-h) 80%, transparent); color: var(--md-on-surface-var); font-size: 11px; @@ -465,6 +478,9 @@ body[data-page="terminal"] .app-toast-host { text-align: left; font-family: var(--font-mono); font-size: var(--fs-body-sm); + border-color: var(--popup-item-border); + border-radius: var(--popup-item-radius); + background: var(--popup-item-bg); } .app-branch-picker__label { @@ -485,7 +501,7 @@ body[data-page="terminal"] .app-toast-host { .app-branch-picker__badge { flex: 0 0 auto; padding: 4px 8px; - border-radius: var(--r-pill); + border-radius: var(--r-round); border: 1px solid color-mix(in srgb, var(--md-primary) 42%, transparent); background: var(--md-primary-state-soft); color: var(--md-primary); @@ -495,8 +511,8 @@ body[data-page="terminal"] .app-toast-host { } .app-branch-picker__item.is-current { - border-color: color-mix(in srgb, var(--md-primary) 56%, var(--md-stroke-soft)); - background: var(--md-primary-state); + border-color: var(--popup-item-active-border); + background: var(--popup-item-active-bg); color: var(--md-primary); font-weight: 800; } @@ -523,13 +539,15 @@ body[data-page="terminal"] .app-toast-host { margin-bottom: var(--sp-lg); } -/* ── Buttons: Material 3 ─────────────────────────────────── */ +/* ── Buttons: boxed controls by default ──────────────────── + Generic button primitives stay boxed. Fully rounded controls must opt in + through a dedicated semantic class. */ .btn { padding: 10px 20px; border: 1px solid color-mix(in srgb, var(--md-outline-var) 46%, transparent); background: var(--md-surface-cont-h); color: var(--md-on-surface); - border-radius: var(--r-pill); + border-radius: var(--control-radius); cursor: pointer; font-size: var(--fs-label-lg); font-weight: 750; @@ -574,7 +592,7 @@ body[data-page="terminal"] .app-toast-host { min-width: 30px; height: 24px; padding: 0 8px; - border-radius: var(--r-pill); + border-radius: var(--r-round); background: var(--md-error); color: var(--md-on-error); font-size: 12px; @@ -613,7 +631,7 @@ body[data-page="terminal"] .app-toast-host { .smallBtn { padding: var(--sp-sm) var(--sp-md); - border-radius: var(--r-pill); + border-radius: var(--control-radius); border: 1px solid color-mix(in srgb, var(--md-outline-var) 46%, transparent); background: var(--md-surface-cont-h); color: var(--md-on-surface); @@ -714,12 +732,14 @@ body[data-page="terminal"] .app-toast-host { .ui-dropdown-menu__panel { min-width: 156px; - padding: 6px; - border: 1px solid color-mix(in srgb, var(--md-outline-var) 48%, transparent); - border-radius: 8px; - background: var(--md-surface-cont); + display: grid; + gap: 6px; + padding: var(--sp-sm); + border: 1px solid var(--popup-border-color); + border-radius: var(--popup-radius); + background: var(--popup-bg); color: var(--md-on-surface); - box-shadow: 0 16px 34px rgba(0, 0, 0, 0.32); + box-shadow: var(--popup-shadow); } .ui-dropdown-menu__panel[hidden] { @@ -730,9 +750,9 @@ body[data-page="terminal"] .app-toast-host { width: 100%; min-height: 40px; padding: 0 12px; - border: 0; - border-radius: 6px; - background: transparent; + border: 1px solid var(--popup-item-border); + border-radius: var(--popup-item-radius); + background: var(--popup-item-bg); color: var(--md-on-surface); display: flex; align-items: center; @@ -748,7 +768,8 @@ body[data-page="terminal"] .app-toast-host { .ui-dropdown-menu__item:hover, .ui-dropdown-menu__item:focus-visible { - background: color-mix(in srgb, var(--md-primary) 10%, transparent); + border-color: color-mix(in srgb, var(--md-primary) 38%, var(--md-outline-var)); + background: color-mix(in srgb, var(--popup-item-bg) 90%, var(--md-primary)); color: var(--md-on-surface); outline: none; } @@ -1139,12 +1160,12 @@ body[data-page="terminal"] .app-toast-host { transform: translateY(1px); } -/* ── Pill / Values ────────────────────────────────────────── */ -.pill { +/* ── Value surface ────────────────────────────────────────── */ +.value-surface { background: var(--md-surface-cont); border: 1px solid var(--md-stroke-soft); padding: var(--sp-sm) var(--sp-md); - border-radius: var(--r-sm); + border-radius: var(--control-radius); font-size: var(--fs-body-sm); color: var(--md-on-surface-var); } @@ -1219,7 +1240,7 @@ pre { ════════════════════════════════════════════════════════════════ */ /* ── Chip ────────────────────────────────────────────────────── - A pill-shaped status/info tag. Two variants: + A compact status/info tag. Two variants: .chip neutral, surface tone (default) .chip.chip--accent primary-tinted (e.g. "selected", counts) @@ -1238,7 +1259,7 @@ pre { min-height: 22px; padding: 2px 10px; border: 1px solid color-mix(in srgb, var(--md-outline-var) 42%, transparent); - border-radius: var(--r-pill); + border-radius: var(--r-round); background: color-mix(in srgb, var(--md-surface-cont-h) 80%, var(--md-surface-cont)); color: var(--md-on-surface-var); font-size: var(--fs-label-sm); @@ -1311,7 +1332,7 @@ pre { Variants: - .icon-btn--circle pill / circular (use with a 30–40 px size) + .icon-btn--circle circular (use with a 30–40 px size) .icon-btn--ghost transparent base, hover-only background .icon-btn--sm 28×28 (compact lists, dense toolbars) .icon-btn--lg 44×44 (FAB-like, touch-priority) @@ -1360,7 +1381,7 @@ pre { } .icon-btn--circle { - border-radius: var(--r-pill); + border-radius: var(--r-round); } .icon-btn--ghost { diff --git a/selfdrive/carrot/web/css/pages/drive.css b/selfdrive/carrot/web/css/pages/drive.css index 88948e51c8d..215654e184d 100644 --- a/selfdrive/carrot/web/css/pages/drive.css +++ b/selfdrive/carrot/web/css/pages/drive.css @@ -467,6 +467,33 @@ body[data-page="carrot"] #driveHudCard { white-space: nowrap; } +.carrot-stage__loadingActions { + width: 100%; + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 0.72fr); + gap: 8px; + margin-top: 2px; +} + +.carrot-stage__loadingAction { + min-width: 0; + min-height: 38px; + margin: 0; + padding: 8px 12px; + background: color-mix(in srgb, var(--md-surface-cont-h) 84%, transparent); +} + +.carrot-stage__loadingAction--primary { + border-color: color-mix(in srgb, var(--md-primary) 58%, var(--md-outline-var)); + background: color-mix(in srgb, var(--md-primary) 18%, var(--md-surface-cont-h)); + color: var(--md-primary); +} + +.carrot-stage__loadingAction:disabled { + opacity: 0.5; + cursor: wait; +} + @keyframes carrot-stage-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } diff --git a/selfdrive/carrot/web/css/pages/logs.css b/selfdrive/carrot/web/css/pages/logs.css index b3e9df50565..d03d0a6e056 100644 --- a/selfdrive/carrot/web/css/pages/logs.css +++ b/selfdrive/carrot/web/css/pages/logs.css @@ -566,8 +566,8 @@ } /* Group (route) overflow menu — range select / sort. - Styled as a pill (like the other selection-row buttons) so it fits both - portrait (row) and landscape (column). Pushed to the right end in portrait. */ + Uses the shared boxed control shape in both portrait and landscape. + Pushed to the right end in portrait. */ .dashcam-group-menu-btn { margin-left: auto; flex: 0 0 auto; @@ -746,7 +746,7 @@ display: grid; place-items: center; padding: 18px; - background: rgba(0, 0, 0, .46); + background: var(--popup-backdrop); opacity: 0; transition: opacity .16s ease; } @@ -758,10 +758,10 @@ .dashcam-upload-progress__sheet { width: min(92vw, 430px); padding: 18px; - border-radius: 8px; - border: 1px solid color-mix(in srgb, var(--md-outline-var) 55%, transparent); - background: var(--md-surface-cont); - box-shadow: var(--shadow-2); + border-radius: var(--popup-radius); + border: 1px solid var(--popup-border-color); + background: var(--popup-bg); + box-shadow: var(--popup-shadow); } .dashcam-upload-progress__title { @@ -810,6 +810,7 @@ .dashcam-upload-progress__cancel { min-height: 38px; padding-inline: 16px; + border-radius: var(--dialog-control-radius); border-color: color-mix(in srgb, var(--md-error) 42%, var(--md-outline-var)); color: color-mix(in srgb, var(--md-error) 82%, var(--md-on-surface)); background: transparent; diff --git a/selfdrive/carrot/web/css/pages/settings/base.css b/selfdrive/carrot/web/css/pages/settings/base.css index 76cba158ad5..0beb2502c60 100644 --- a/selfdrive/carrot/web/css/pages/settings/base.css +++ b/selfdrive/carrot/web/css/pages/settings/base.css @@ -234,7 +234,7 @@ gap: 10px; padding: 0 16px; border: 1px solid color-mix(in srgb, var(--md-outline-var) 84%, transparent); - border-radius: 999px; + border-radius: var(--control-radius); background: color-mix(in srgb, var(--md-surface-cont) 92%, #000); color: var(--md-on-surface); font-size: var(--fs-body-sm); diff --git a/selfdrive/carrot/web/css/pages/settings/device.css b/selfdrive/carrot/web/css/pages/settings/device.css index 0a1a7c2d2f9..08b634bcfb7 100644 --- a/selfdrive/carrot/web/css/pages/settings/device.css +++ b/selfdrive/carrot/web/css/pages/settings/device.css @@ -34,9 +34,9 @@ .setting-search-result { width: 100%; - padding: 15px 0; + padding: 12px 14px; border: 0; - border-bottom: 1px solid color-mix(in srgb, var(--md-stroke-soft) 62%, transparent); + border-bottom: 1px solid color-mix(in srgb, var(--md-stroke-soft) 32%, transparent); border-radius: 0; background: transparent; color: inherit; @@ -44,13 +44,26 @@ cursor: pointer; } +.setting-search-section__body > .setting-search-result:last-child { + border-bottom: 0; +} + +.setting-search-result:hover, +.setting-search-result:focus-visible { + background: color-mix(in srgb, var(--md-surface-cont) 88%, var(--md-primary)); + outline: none; +} + .setting-search-result:active { - background: color-mix(in srgb, var(--md-primary) 6%, transparent); + transform: translateY(1px); } .setting-search-result--empty { cursor: default; color: var(--md-on-surface-var); + border: 1px solid var(--popup-item-border); + border-radius: var(--popup-item-radius); + background: var(--popup-item-bg); } .setting-search-result__group { @@ -107,10 +120,10 @@ .setting-search-panel { --setting-search-form-width: clamp(360px, 34vw, 540px); --setting-search-results-width: min(64vw, 820px); - --setting-search-results-max-height: min(72dvh, 680px); + --setting-search-results-max-height: 680px; gap: 14px; padding-top: calc(22px + env(safe-area-inset-top, 0px)); - padding-bottom: calc(var(--nav-bar-height-desktop) + 18px + env(safe-area-inset-bottom, 0px)); + padding-bottom: calc(var(--app-nav-bottom-gap, var(--nav-bar-height-desktop)) + 18px + env(safe-area-inset-bottom, 0px)); } } @@ -192,9 +205,9 @@ .setting-search-panel { --setting-search-form-width: calc(100vw - 28px); --setting-search-results-width: calc(100vw - 28px); - --setting-search-results-max-height: min(68dvh, 560px); + --setting-search-results-max-height: 560px; gap: 12px; - padding: calc(14px + env(safe-area-inset-top, 0px)) 12px calc(var(--nav-bar-height) + 14px + env(safe-area-inset-bottom, 0px)); + padding: calc(14px + env(safe-area-inset-top, 0px)) 12px calc(var(--app-nav-bottom-gap, var(--nav-bar-height)) + 14px + env(safe-area-inset-bottom, 0px)); } .setting-search-form { @@ -274,6 +287,18 @@ will-change: transform; } + /* 최상위 그룹 화면은 탭과 첫 버튼을 선명하게 유지한다. + 스크롤 가장자리 페이드는 하위 설정 화면에만 적용한다. */ + .page.page--setting > #settingScreenHost > #settingScreenGroups { + -webkit-mask-image: none; + mask-image: none; + } + + .page.page--setting > #settingScreenHost > #settingScreenItems { + -webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 14px, #000 calc(100% - 24px), transparent 100%); + mask-image: linear-gradient(to bottom, transparent 0, #000 14px, #000 calc(100% - 24px), transparent 100%); + } + .page--setting #settingSubnavWrap { display: none !important; } @@ -285,7 +310,7 @@ @media (max-height: 760px) { .setting-search-panel { - --setting-search-results-max-height: min(64dvh, 520px); + --setting-search-results-max-height: 620px; gap: 10px; padding-top: calc(12px + env(safe-area-inset-top, 0px)); } @@ -339,7 +364,15 @@ box-sizing: border-box; scrollbar-gutter: stable; transition: none; - /* One UI 스타일 스크롤 가장자리 페이드 (상 14px / 하 24px) */ + } + + .page.page--setting > #settingScreenHost > #settingScreenGroups { + -webkit-mask-image: none; + mask-image: none; + } + + /* 가로모드도 하위 설정 화면에만 스크롤 가장자리 페이드를 유지한다. */ + .page.page--setting > #settingScreenHost > #settingScreenItems { -webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 14px, #000 calc(100% - 24px), transparent 100%); mask-image: linear-gradient(to bottom, transparent 0, #000 14px, #000 calc(100% - 24px), transparent 100%); } @@ -459,11 +492,19 @@ display: inline-flex; opacity: 1 !important; visibility: visible !important; - transform: none !important; margin-right: 0; pointer-events: auto; } + /* Reset the flow-positioning transform, but exempt the click bounce: CSS + animations are overridden by !important author rules, so the blanket + `transform: none !important` previously killed the FAB bounce in landscape + only (portrait has no such reset). Lifting it while .is-bouncing lets the + setting-fab-bounce keyframes play; it returns on animationend. */ + .page.page--setting > .page-fab-layer--setting .fab:not(.is-bouncing) { + transform: none !important; + } + /* Flow the menu inside the page-anchored absolute layer instead of leaving it viewport-fixed. A position:fixed element re-anchors to the page's transform during page transitions (the page briefly gets will-change/transform), so @@ -525,9 +566,9 @@ .setting-search-panel { --setting-search-form-width: min(76vw, 540px); --setting-search-results-width: min(82vw, 760px); - --setting-search-results-max-height: min(68dvh, 520px); + --setting-search-results-max-height: 680px; gap: 10px; - padding: calc(12px + env(safe-area-inset-top, 0px)) 12px calc(var(--nav-bar-height) + 12px + env(safe-area-inset-bottom, 0px)); + padding: calc(12px + env(safe-area-inset-top, 0px)) 12px calc(var(--app-nav-bottom-gap, 0px) + 12px + env(safe-area-inset-bottom, 0px)); } } @@ -546,6 +587,45 @@ } +/* Robust two-column layout for every width / orientation / language: the text + column shrinks (min-width:0) while the control column keeps its intrinsic + width, so right-side controls can never be pushed off-screen by long titles + (e.g. ko/zh). The media queries below still refine the gap and the + single-column wrap; this base rule fills the gaps they don't cover + (notably portrait wider than 640px). */ +.page--setting #settingScreenItems .settingTop { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: var(--sp-md); + align-items: start; +} + +.page--setting #settingScreenItems .settingTop > :first-child { + min-width: 0; +} + +/* Long descriptions / range legends with no break opportunities (e.g. English + "0:stock,1,2:REC,..." — unlike CJK, Latin runs without spaces don't break) + must wrap instead of forcing the row wider than the viewport, which would + push the right-side control off-screen. CJK wraps per-character on its own, + which is why this only surfaced in English. The landscape split layout + already applies this; promote it to every layout. */ +.page--setting #settingScreenItems .title, +.page--setting #settingScreenItems .name, +.page--setting #settingScreenItems .muted, +.page--setting #settingScreenItems .descr { + max-width: 100%; + overflow-wrap: anywhere; + word-break: break-word; +} + +/* Safety net: never let a single item bleed past its box and drag the control + column with it (matches the known-good split layout). The title/name marquees + keep their own inner overflow-x scroller, so this does not clip them. */ +.page--setting #settingScreenItems .setting { + overflow: hidden; +} + .page--setting #settingScreenItems .ctrl { --setting-control-side: 42px; --setting-control-value: 88px; @@ -862,6 +942,48 @@ color: var(--md-on-surface); } +/* Footer actions row — holds the optional unit-cycle (배율) and the + reset-to-default (기본값) button, right-aligned under the item. */ +.page--setting #settingScreenItems .setting--has-actions { + padding-bottom: var(--sp-md); +} + +.page--setting #settingScreenItems .setting-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + margin-top: 8px; +} + +.page--setting #settingScreenItems .setting-actions .setting-unit-cycle { + margin: 0; +} + +.page--setting #settingScreenItems .setting-default-reset { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 54px; + min-height: 32px; + padding: 0 12px; + border: 1px solid color-mix(in srgb, var(--md-outline-var) 54%, transparent); + border-radius: var(--r-md); + background: color-mix(in srgb, var(--md-surface-cont-hh) 72%, var(--md-surface-cont-h)); + color: var(--md-on-surface); + font-size: var(--fs-label-sm); + font-weight: 900; + line-height: 1; + letter-spacing: 0; + box-shadow: none; +} + +.page--setting #settingScreenItems .setting-default-reset:active { + border-color: color-mix(in srgb, var(--md-outline-var) 68%, transparent); + background: color-mix(in srgb, var(--md-surface-cont-hh) 88%, var(--md-on-surface)); + color: var(--md-on-surface); +} + .page--setting #settingScreenItems .setting-copy { min-width: 0; max-width: 100%; @@ -1033,13 +1155,13 @@ } .page--setting #settingScreenItems .setting-marquee.is-overflowing .setting-marquee__content { - animation: setting-marquee-pan 7s ease-in-out 0.8s infinite alternate; + animation: setting-marquee-pan 6s linear 0.5s infinite alternate; } -.page--setting #settingScreenItems .setting-marquee:hover .setting-marquee__content, -.page--setting #settingScreenItems .setting-marquee:focus .setting-marquee__content, -.page--setting #settingScreenItems .setting-marquee:active .setting-marquee__content { - animation-play-state: paused; +.page--setting #settingScreenItems .setting-marquee.is-manual .setting-marquee__content { + animation: none; + transform: none; + will-change: auto; } .page--setting #settingScreenItems .title.setting-marquee, @@ -1061,8 +1183,13 @@ @media (max-width: 640px) and (orientation: portrait) { .page--setting #settingScreenItems > .row-between.mb-sm { - min-height: 48px; + /* min-height was 48px while the row content (42px back-icon + title) is + shorter, and align-items:end pushed the ~6px slack ABOVE the title — + the thin band the user saw. Collapse to content height so the title + starts at the top edge, like the groups screen. */ + min-height: 0; margin-bottom: 10px; + padding-top: 0; padding-bottom: 8px; } @@ -1079,11 +1206,10 @@ padding-right: var(--sp-lg); } - .page--setting #settingScreenItems #items::after { - content: ""; - display: block; - height: calc(var(--fab-size, 62px) + 12px); - } + /* No extra FAB spacer here: the scroll container's padding-bottom + (nav-bar-height + 24px) already clears the floating button, so the old + `#items::after` (fab-size + 12px) doubled the bottom gap on the submenu + only. Removing it matches the groups screen's spacing. */ .page--setting #settingScreenItems .settingTop { display: grid; @@ -1151,11 +1277,11 @@ } @keyframes setting-marquee-pan { - from { + 0%, 6% { transform: translateX(0); } - to { + 94%, 100% { transform: translateX(calc(var(--setting-marquee-distance, 0px) * -1)); } } @@ -1657,7 +1783,7 @@ align-items: center; justify-content: center; padding: var(--sp-md); - background: rgba(0, 0, 0, 0.86); + background: var(--popup-backdrop); } .training-guide-modal__surface { @@ -1667,9 +1793,10 @@ width: min(100%, 960px); height: min(100%, 540px); overflow: hidden; - border: 1px solid color-mix(in srgb, var(--md-outline-var) 48%, transparent); - border-radius: var(--r-md); + border: 1px solid var(--popup-border-color); + border-radius: var(--popup-radius); background: #000; + box-shadow: var(--popup-shadow); } .training-guide-modal__image { @@ -1688,7 +1815,7 @@ width: 42px; height: 42px; border: 1px solid color-mix(in srgb, var(--md-outline-var) 56%, transparent); - border-radius: 999px; + border-radius: var(--dialog-control-radius); background: color-mix(in srgb, var(--md-surface-cont) 82%, #000); color: var(--md-on-surface); font-size: 28px; @@ -1717,7 +1844,7 @@ grid-template-rows: auto minmax(0, 1fr); width: min(100%, 760px); height: min(100%, 620px); - background: var(--md-surface); + background: var(--popup-bg); } .device-info-modal__header { diff --git a/selfdrive/carrot/web/css/pages/settings/panels.css b/selfdrive/carrot/web/css/pages/settings/panels.css index e88e5c781e0..0f56c990b59 100644 --- a/selfdrive/carrot/web/css/pages/settings/panels.css +++ b/selfdrive/carrot/web/css/pages/settings/panels.css @@ -2,16 +2,23 @@ .setting-search-panel { --setting-search-form-width: clamp(320px, 42vw, 520px); --setting-search-results-width: min(72vw, 760px); - --setting-search-results-max-height: min(72dvh, 680px); + --setting-search-results-max-height: 680px; position: fixed; - inset: 0; + top: var(--app-vv-top, 0px); + right: 0; + bottom: auto; + left: 0; + width: 100vw; + height: var(--app-vv-height, 100dvh); + min-height: 0; + box-sizing: border-box; z-index: 171; display: flex; flex-direction: column; align-items: center; justify-content: flex-start; gap: 14px; - padding: calc(18px + env(safe-area-inset-top, 0px)) var(--sp-lg) calc(var(--nav-bar-height) + 18px + env(safe-area-inset-bottom, 0px)); + padding: calc(18px + env(safe-area-inset-top, 0px)) var(--sp-lg) calc(var(--app-nav-bottom-gap, var(--nav-bar-height)) + 18px + env(safe-area-inset-bottom, 0px)); pointer-events: none; } @@ -28,7 +35,7 @@ min-height: clamp(56px, 7vh, 62px); padding-left: 20px; border: 1px solid color-mix(in srgb, var(--md-primary) 26%, var(--md-stroke-soft)); - border-radius: 999px; + border-radius: var(--control-radius); background: color-mix(in srgb, var(--md-surface-cont) 76%, transparent); backdrop-filter: blur(10px) saturate(112%); -webkit-backdrop-filter: blur(10px) saturate(112%); @@ -63,7 +70,7 @@ height: 100%; border: 0; border-left: 1px solid color-mix(in srgb, var(--md-stroke-soft) 42%, transparent); - border-radius: 0 999px 999px 0; + border-radius: 0 var(--control-radius) var(--control-radius) 0; background: transparent; color: var(--md-primary); display: inline-flex; @@ -86,11 +93,14 @@ flex-direction: column; gap: 0; width: min(var(--setting-search-results-width), calc(100vw - 40px)); - padding: 0 4px; + min-height: 0; + padding: 0 4px 12px; max-height: var(--setting-search-results-max-height); - flex: 0 1 var(--setting-search-results-max-height); + flex: 1 1 auto; overflow: auto; overscroll-behavior: contain; + scroll-padding-block: 8px 18px; + scrollbar-gutter: stable; pointer-events: auto; position: relative; z-index: 1; @@ -102,31 +112,34 @@ .setting-search-section { display: grid; - gap: 0; - padding: 0 0 2px; + gap: var(--sp-sm); + padding: 0 0 var(--sp-lg); } .setting-search-section__title { - position: sticky; - top: 0; - z-index: 2; display: flex; align-items: center; justify-content: space-between; gap: 10px; - padding: 10px 4px 8px; - border-bottom: 1px solid color-mix(in srgb, var(--md-outline-var) 34%, transparent); - background: color-mix(in srgb, var(--md-surface) 96%, #000); - color: var(--md-on-surface-var); - font-size: 12px; + padding: 0 4px; + color: var(--md-primary); + font-size: var(--fs-title-sm); font-weight: 900; - box-shadow: 0 1px 0 color-mix(in srgb, var(--md-outline-var) 18%, transparent); + letter-spacing: 0; } .setting-search-section__title strong { - color: var(--md-primary); - font-size: 12px; - font-weight: 900; + color: var(--md-on-surface-var); + font-size: var(--fs-label-md); + font-weight: 800; +} + +.setting-search-section__body { + overflow: hidden; + background: var(--md-surface-cont); + border: 1px solid color-mix(in srgb, var(--md-outline-var) 22%, transparent); + border-radius: var(--r-xl); + box-shadow: var(--shadow-1); } .page--setting #groupList, diff --git a/selfdrive/carrot/web/css/pages/terminal.css b/selfdrive/carrot/web/css/pages/terminal.css index 3399f12b1e4..781f1a653a1 100644 --- a/selfdrive/carrot/web/css/pages/terminal.css +++ b/selfdrive/carrot/web/css/pages/terminal.css @@ -151,7 +151,7 @@ margin: 8px var(--terminal-inline) var(--terminal-form-bottom); padding-left: 14px; border: 1px solid color-mix(in srgb, var(--md-stroke-soft) 56%, transparent); - border-radius: var(--r-pill); + border-radius: var(--control-radius); background: color-mix(in srgb, var(--md-surface-cont) 92%, #000); overflow: hidden; position: relative; diff --git a/selfdrive/carrot/web/css/tokens.css b/selfdrive/carrot/web/css/tokens.css index 6d0d8092089..87ed08460b9 100644 --- a/selfdrive/carrot/web/css/tokens.css +++ b/selfdrive/carrot/web/css/tokens.css @@ -82,24 +82,46 @@ --r-lg: 16px; --r-xl: 28px; --r-2xl: 32px; - --r-pill: 999px; + --r-round: 999px; + + /* Default interactive controls are boxed. + --r-round is reserved for explicitly semantic shapes only: + FABs, switches, badges, status dots, and circular icon controls. */ + --control-radius: var(--r-sm); /* Popup UX primitives - Compact sheets for alert / confirm / prompt. - Bottom sheets on narrow portrait screens. - Value grids for short numeric or code choices. - Action lists for commands and destructive choices. - - Pill radius is reserved for FABs, switches, and small chips. */ + - Fully rounded shapes are reserved for semantic controls. */ --dialog-sheet-radius: 16px; --dialog-sheet-radius-mobile: 18px; - --dialog-control-radius: 8px; - --dialog-choice-radius: 8px; - --dialog-choice-grid-radius: 8px; + --dialog-control-radius: var(--control-radius); + --dialog-choice-radius: var(--control-radius); --dialog-sheet-width: 420px; --dialog-sheet-width-wide: 520px; --dialog-sheet-max-height: calc(100dvh - var(--sp-lg) * 2); --dialog-sheet-mobile-max-height: min(88dvh, 720px); + /* ── Popup surface ──────────────────────────────────────────── + One surface for every floating popup (dialog sheet, dropdown + menu, popover) so they share background / border / shadow / + radius / backdrop. Values equal the existing dialog sheet, so + dialogs are unchanged — only non-dialog popups (e.g. the + dropdown menu) are pulled onto this surface. Size stays per + component (width/height are NOT tokenized here). */ + --popup-bg: color-mix(in srgb, var(--md-surface-cont) 96%, #000); + --popup-border-color: color-mix(in srgb, var(--md-stroke-soft) 92%, transparent); + --popup-radius: var(--dialog-sheet-radius); + --popup-shadow: var(--shadow-4); + --popup-backdrop: color-mix(in srgb, #000 62%, transparent); + --popup-item-radius: var(--control-radius); + --popup-item-border: color-mix(in srgb, var(--md-outline-var) 46%, transparent); + --popup-item-bg: var(--md-surface-cont); + --popup-item-active-border: color-mix(in srgb, var(--md-primary) 56%, var(--md-stroke-soft)); + --popup-item-active-bg: var(--md-primary-state); + /* ── Elevation ──────────────────────────────────────────────── Material 3 dark elevation. Pick the smallest level that reads as "raised". Larger = more separation from background. @@ -204,6 +226,25 @@ --z-toast: 200; --z-overlay: 220; + /* ── Toast / Snackbar ────────────────────────────────────────── + One surface design for every app toast (showAppToast → .app-toast). + Values match the carrot-web-push reference exactly (style.css + .toast / .toast-ic). The round icon carries the tone color; the + message text stays white so toasts differ only by glyph/color. + Default tone == reference "info" (blue i). */ + --toast-bg: #1b222c; /* reference --surface-higher */ + --toast-fg: #fff; + --toast-border: rgba(120, 129, 145, 0.22); + --toast-radius: 16px; + --toast-shadow: 0 16px 42px rgba(0, 0, 0, 0.55); + --toast-icon-size: 24px; + --toast-icon-fg: #0c0f14; + --toast-icon-default: #6ea8ff; /* same as info */ + --toast-icon-info: #6ea8ff; + --toast-icon-success: #3fef7d; + --toast-icon-warn: #ffb06d; /* reference --orange */ + --toast-icon-error: #ff6b6b; /* reference --error */ + /* ── Focus Ring ──────────────────────────────────────────────── One ring style for the whole app, via :focus-visible. Defined here so individual components don't reinvent outline width and diff --git a/selfdrive/carrot/web/index.html b/selfdrive/carrot/web/index.html index e30cb95c610..154d15080a7 100644 --- a/selfdrive/carrot/web/index.html +++ b/selfdrive/carrot/web/index.html @@ -70,21 +70,21 @@ background: color-mix(in srgb, var(--md-primary) 14%, transparent) !important; } - + - - - - - - + + + + + + - + @@ -265,7 +265,7 @@

Tools

@@ -680,29 +684,29 @@

Home

- - - + + + - + - + - + - + @@ -716,7 +720,7 @@

Home

- + diff --git a/selfdrive/carrot/web/js/pages/setting.js b/selfdrive/carrot/web/js/pages/setting.js index 7514467dbb9..3c5d75f20ed 100644 --- a/selfdrive/carrot/web/js/pages/setting.js +++ b/selfdrive/carrot/web/js/pages/setting.js @@ -1780,10 +1780,20 @@ function syncSettingMarqueeOverflow(root = document) { const elWidth = el.clientWidth || 0; if (elWidth <= 0) return; const overflow = content.scrollWidth > el.clientWidth + 2; - const distance = Math.max(0, content.scrollWidth - el.clientWidth + 18); + const distance = Math.max(0, content.scrollWidth - el.clientWidth); const nextDistance = `${distance}px`; const prevDistance = el.style.getPropertyValue("--setting-marquee-distance"); const wasOverflowing = el.classList.contains("is-overflowing"); + if (el._settingMarqueeResetTimer) { + clearTimeout(el._settingMarqueeResetTimer); + el._settingMarqueeResetTimer = null; + } + if (el._settingMarqueeRestoreTimer) { + clearTimeout(el._settingMarqueeRestoreTimer); + el._settingMarqueeRestoreTimer = null; + } + el._settingMarqueeResetting = false; + el.classList.remove("is-manual"); el.style.setProperty("--setting-marquee-distance", nextDistance); el.scrollLeft = 0; if (!overflow) { @@ -1924,8 +1934,10 @@ function renderSettingSearchResults(query = "") { ${escapeHtml(section.title)} ${section.entries.length} +
`; settingSearchResults.appendChild(sectionEl); + const sectionBody = sectionEl.querySelector(".setting-search-section__body"); section.entries.forEach((entry) => { const button = document.createElement("button"); @@ -1956,7 +1968,7 @@ function renderSettingSearchResults(query = "") { showAppToast(e.message || "Search jump failed", { tone: "error" }); } }; - sectionEl.appendChild(button); + sectionBody.appendChild(button); }); }); } @@ -2866,7 +2878,7 @@ async function renderItems(group, options = {}) { const val = document.createElement("button"); val.type = "button"; - val.className = compactNumeric ? "pill val setting-value-compact" : "pill val"; + val.className = compactNumeric ? "value-surface val setting-value-compact" : "value-surface val"; val.setAttribute("aria-label", compactNumeric ? getUIText("setting_value_detail", "Open detail") : getUIText("setting_value_edit", "Edit value")); @@ -2978,10 +2990,49 @@ async function renderItems(group, options = {}) { el.appendChild(top); el.appendChild(d); + + // Footer actions row: optional unit-cycle (배율) plus a reset-to-default + // (기본값) button on every item. Pressing 기본값 confirms then restores + // the param to its declared default. commitSettingValue / normalizeSettingValue + // are hoisted function declarations below, so referencing them here is fine. + const actions = document.createElement("div"); + actions.className = "setting-actions"; if (unitBtn) { el.classList.add("setting--has-unit-cycle"); - el.appendChild(unitBtn); - } + actions.appendChild(unitBtn); + } + const defaultBtn = document.createElement("button"); + defaultBtn.type = "button"; + defaultBtn.className = "setting-default-reset"; + defaultBtn.textContent = getUIText("setting_reset_default", "Default"); + defaultBtn.setAttribute("aria-label", getUIText("setting_reset_default_aria", "Reset to default")); + defaultBtn.onclick = async (event) => { + event.stopPropagation(); + const normalizedDefault = normalizeSettingValue(p.default); + const target = normalizedDefault === null ? p.default : normalizedDefault; + const current = val.dataset.committedValue ?? val.dataset.rawValue; + if (String(target) === String(current)) { + showAppToast(getUIText("setting_already_default", "Already at default")); + return; + } + const ok = await appConfirm( + getUIText("setting_reset_default_confirm", "Reset to default ({value})?", { + value: formatSettingDisplayValue(p, target), + }), + { + title: getUIText("setting_reset_default_title", "Reset to default"), + confirmLabel: getUIText("ok", "OK"), + cancelLabel: getUIText("cancel", "Cancel"), + }, + ); + if (!ok) return; + await commitSettingValue(target); + showAppToast(getUIText("setting_reset_default_done", "Restored to default")); + }; + actions.appendChild(defaultBtn); + el.classList.add("setting--has-actions"); + el.appendChild(actions); + (currentProfileSectionBody || currentCategoryCardBody || itemsBox).appendChild(el); const cur = (name in values) ? values[name] : p.default; @@ -3319,6 +3370,106 @@ function bindSettingFavoriteLongPress() { bindSettingFavoriteLongPress(); +// Let long titles / param names be panned left-right by the user. Automatic +// movement uses transform, while manual movement uses scrollLeft; never allow +// both coordinate systems to remain active at the same time. +function bindSettingMarqueeDrag() { + ["items", "deviceItems"].forEach((id) => { + const box = document.getElementById(id); + if (!box || box.dataset.marqueeDragBound === "1") return; + box.dataset.marqueeDragBound = "1"; + + let drag = null; + + function cancelManualReset(el) { + if (!el) return; + if (el._settingMarqueeResetTimer) { + clearTimeout(el._settingMarqueeResetTimer); + el._settingMarqueeResetTimer = null; + } + if (el._settingMarqueeRestoreTimer) { + clearTimeout(el._settingMarqueeRestoreTimer); + el._settingMarqueeRestoreTimer = null; + } + } + + function beginManualScroll(el) { + cancelManualReset(el); + el._settingMarqueeResetting = false; + el.classList.add("is-manual"); + } + + function scheduleManualReset(el) { + if (!el) return; + cancelManualReset(el); + el._settingMarqueeResetTimer = window.setTimeout(() => { + el._settingMarqueeResetTimer = null; + el._settingMarqueeResetting = true; + el.scrollTo({ left: 0, behavior: "smooth" }); + el._settingMarqueeRestoreTimer = window.setTimeout(() => { + el._settingMarqueeRestoreTimer = null; + el.scrollLeft = 0; + el.classList.remove("is-manual"); + el._settingMarqueeResetting = false; + }, 320); + }, 1200); + } + + function endDrag(event) { + if (!drag || (event && event.pointerId !== drag.pointerId)) return; + const el = drag.el; + try { el.releasePointerCapture(drag.pointerId); } catch (_) {} + el.classList.remove("is-dragging"); + drag = null; + scheduleManualReset(el); + } + + box.addEventListener("pointerdown", (event) => { + if (event.button !== undefined && event.button !== 0) return; + const marquee = event.target.closest(".setting-marquee"); + if (!marquee || !box.contains(marquee) || !marquee.classList.contains("is-overflowing")) return; + beginManualScroll(marquee); + drag = { + el: marquee, + pointerId: event.pointerId, + startX: event.clientX, + startScroll: marquee.scrollLeft, + moved: false, + }; + marquee.classList.add("is-dragging"); + }); + + box.addEventListener("pointermove", (event) => { + if (!drag || event.pointerId !== drag.pointerId) return; + // Touch pans the overflow container natively — don't double-apply scroll. + if (event.pointerType === "touch") return; + const dx = event.clientX - drag.startX; + if (!drag.moved) { + if (Math.abs(dx) <= 4) return; + drag.moved = true; + try { drag.el.setPointerCapture(drag.pointerId); } catch (_) {} + } + drag.el.scrollLeft = drag.startScroll - dx; + if (event.cancelable) event.preventDefault(); + }); + + box.addEventListener("scroll", (event) => { + const marquee = event.target; + if (!(marquee instanceof Element) || !marquee.classList.contains("setting-marquee")) return; + if (!marquee.classList.contains("is-manual")) return; + if (marquee._settingMarqueeResetting) return; + cancelManualReset(marquee); + if (!drag || drag.el !== marquee) scheduleManualReset(marquee); + }, true); + + box.addEventListener("pointerup", endDrag); + box.addEventListener("pointercancel", endDrag); + box.addEventListener("lostpointercapture", endDrag); + }); +} + +bindSettingMarqueeDrag(); + async function syncSettingViewportLayout(options = {}) { if (CURRENT_PAGE !== "setting" || !SETTINGS) return; settingViewportLayoutSignature = getSettingViewportLayoutSignature(); diff --git a/selfdrive/carrot/web/js/pages/setting_device.js b/selfdrive/carrot/web/js/pages/setting_device.js index 35c8b34df85..2586980aef2 100644 --- a/selfdrive/carrot/web/js/pages/setting_device.js +++ b/selfdrive/carrot/web/js/pages/setting_device.js @@ -428,7 +428,7 @@ function renderDeviceToggleItems(values) { getUIText("driving_personality_desc", "Aggressive, Standard, Relaxed"), getUIText(option.labelKey, option.defaultLabel), "btnDevicePersonality", - "val pill", + "val value-surface", ); return html; } diff --git a/selfdrive/carrot/web/js/realtime/app_realtime.js b/selfdrive/carrot/web/js/realtime/app_realtime.js index 46f0c7754bd..3af25bfe4f9 100644 --- a/selfdrive/carrot/web/js/realtime/app_realtime.js +++ b/selfdrive/carrot/web/js/realtime/app_realtime.js @@ -546,6 +546,25 @@ async function reconnectCarrotVisionRealtime(reason = "manual reconnect") { window.CarrotVisionReconnect = reconnectCarrotVisionRealtime; +async function stopCarrotVisionRealtime(reason = "user stop") { + if (!isCarrotVisionActive()) return; + console.warn("[vision] stop requested", reason); + setCarrotVisionActive(false, { + phase: CARROT_VISION_PHASE.INACTIVE, + reason, + statusText: getUIText("start_vision_hint", "Tap the start button to enable drive vision."), + updateRtcStatus: false, + }); + syncCarrotRealtimeLifecycle(true); + await exitCarrotFullscreen({ quiet: true }).catch(() => {}); + const overlay = document.getElementById("visionStartOverlay"); + if (overlay) overlay.style.removeProperty("display"); + rtcStatusSet(getUIText("start_vision_hint", "Tap the start button to enable drive vision.")); + requestCarrotVisionRender({ reason }); +} + +window.CarrotVisionStop = stopCarrotVisionRealtime; + window.CarrotVisionStart = async function() { if (isCarrotVisionActive()) { await reconnectCarrotVisionRealtime("start button while active"); @@ -571,6 +590,32 @@ window.CarrotVisionStart = async function() { function rtcInitAuto() { const btn = document.getElementById("btnStartVision"); if (btn) btn.onclick = window.CarrotVisionStart; + const reconnectButton = document.getElementById("btnVisionReconnect"); + const stopButton = document.getElementById("btnVisionStop"); + let actionBusy = false; + const runLoadingAction = async (action) => { + if (actionBusy) return; + actionBusy = true; + if (reconnectButton) reconnectButton.disabled = true; + if (stopButton) stopButton.disabled = true; + try { + await action(); + } finally { + actionBusy = false; + if (reconnectButton) reconnectButton.disabled = false; + if (stopButton) stopButton.disabled = false; + } + }; + if (reconnectButton) { + reconnectButton.onclick = () => runLoadingAction( + () => reconnectCarrotVisionRealtime("loading panel reconnect"), + ); + } + if (stopButton) { + stopButton.onclick = () => runLoadingAction( + () => stopCarrotVisionRealtime("loading panel stop"), + ); + } syncCarrotVisionAvailability().catch(() => {}); rtcBindVideoEvents(); } diff --git a/selfdrive/carrot/web/js/shared/i18n.js b/selfdrive/carrot/web/js/shared/i18n.js index faf30420658..f81136d9058 100644 --- a/selfdrive/carrot/web/js/shared/i18n.js +++ b/selfdrive/carrot/web/js/shared/i18n.js @@ -285,6 +285,8 @@ function renderUIText() { setText("logsDashcamTitle", s.logs_dashcam || "Dashcam"); setText("logsScreenTitle", s.logs_screenrecord || "Screen Record"); setText("btnStartVision", `▶ ${s.start_vision || "Start Drive Vision"}`); + setText("btnVisionReconnect", s.vision_reconnect || "Reconnect"); + setText("btnVisionStop", s.vision_stop || "Stop"); if (typeof applyRecordFabState === "function") applyRecordFabState(); if (window.HomeDrive && typeof window.HomeDrive.renderText === "function") { window.HomeDrive.renderText(); diff --git a/selfdrive/carrot/web/js/shared/ui/dialog.js b/selfdrive/carrot/web/js/shared/ui/dialog.js index 9f5f5c685c6..0d8477b15b7 100644 --- a/selfdrive/carrot/web/js/shared/ui/dialog.js +++ b/selfdrive/carrot/web/js/shared/ui/dialog.js @@ -24,6 +24,26 @@ function syncModalBodyLock() { document.body.classList.toggle("dialog-open", hasOpenDialog); } +// Tone → icon glyph + style class. Centralized here so every toast across the +// app (showAppToast is the single entry point) renders the same toast surface. +const APP_TOAST_TONE_GLYPH = { + success: "✓", // ✓ + error: "✕", // ✕ + warn: "!", + offline: "!", + info: "i", + hint: "i", + default: "i", +}; +const APP_TOAST_TONE_CLASS = { + success: "is-success", + error: "is-error", + warn: "is-warn", + offline: "is-warn", + info: "is-info", + hint: "is-hint", +}; + function showAppToast(message, opts = {}) { if (!appToastHost || !message) return; @@ -37,9 +57,19 @@ function showAppToast(message, opts = {}) { activeAppToast = toast; } - toast.className = "app-toast"; - if (tone && tone !== "default") toast.classList.add(`is-${tone}`); - toast.textContent = String(message); + const toneClass = APP_TOAST_TONE_CLASS[tone] || ""; + toast.className = toneClass ? `app-toast ${toneClass}` : "app-toast"; + + const icon = document.createElement("span"); + icon.className = "app-toast__icon"; + icon.setAttribute("aria-hidden", "true"); + icon.textContent = APP_TOAST_TONE_GLYPH[tone] || APP_TOAST_TONE_GLYPH.default; + + const msg = document.createElement("div"); + msg.className = "app-toast__msg"; + msg.textContent = String(message); + + toast.replaceChildren(icon, msg); if (appToastHideTimer) { clearTimeout(appToastHideTimer); @@ -66,7 +96,7 @@ function showAppToast(message, opts = {}) { activeAppToast.remove(); activeAppToast = null; appToastRemoveTimer = null; - }, 180); + }, 220); }, duration); } diff --git a/selfdrive/carrot/web/js/translations/en.js b/selfdrive/carrot/web/js/translations/en.js index 3192d419af2..0b7923b0c27 100644 --- a/selfdrive/carrot/web/js/translations/en.js +++ b/selfdrive/carrot/web/js/translations/en.js @@ -290,6 +290,8 @@ window.CarrotTranslations.register("en", { connecting: "Connecting...", connected: "Connected", reconnecting: "Reconnecting...", + vision_reconnect: "Reconnect", + vision_stop: "Stop", error: "Error", notice: "Notice", confirm_title: "Confirm", @@ -342,6 +344,12 @@ window.CarrotTranslations.register("en", { setting_reset_defaults_confirm: "Reset all settings to defaults?", setting_reset_defaults_done: "Settings reset complete", setting_reset_defaults_failed: "Settings reset failed", + setting_reset_default: "Default", + setting_reset_default_aria: "Reset to default", + setting_reset_default_title: "Reset to default", + setting_reset_default_confirm: "Reset to default ({value})?", + setting_reset_default_done: "Restored to default", + setting_already_default: "Already at default", setting_search_placeholder: "Search name, description, group", setting_search_empty: "No matching settings found.", setting_search_idle: "Type to find detailed settings.", diff --git a/selfdrive/carrot/web/js/translations/ko.js b/selfdrive/carrot/web/js/translations/ko.js index 8e8e8b2c351..6d8cd86c0c8 100644 --- a/selfdrive/carrot/web/js/translations/ko.js +++ b/selfdrive/carrot/web/js/translations/ko.js @@ -288,6 +288,8 @@ window.CarrotTranslations.register("ko", { not_set: "미설정", connecting: "연결중...", reconnecting: "재연결중...", + vision_reconnect: "다시 연결", + vision_stop: "중지", error: "오류", notice: "알림", confirm_title: "확인", @@ -340,6 +342,12 @@ window.CarrotTranslations.register("ko", { setting_reset_defaults_confirm: "전체 설정을 기본값으로 초기화할까요?", setting_reset_defaults_done: "설정 초기화 성공", setting_reset_defaults_failed: "설정 초기화 실패", + setting_reset_default: "기본값", + setting_reset_default_aria: "기본값으로 되돌리기", + setting_reset_default_title: "기본값 복원", + setting_reset_default_confirm: "기본값({value})으로 되돌리겠습니까?", + setting_reset_default_done: "기본값으로 되돌렸습니다", + setting_already_default: "이미 기본값입니다", setting_search_placeholder: "이름, 설명, 그룹 검색", setting_search_empty: "검색 결과가 없습니다.", setting_search_idle: "검색어를 입력하면 세부 설정을 찾을 수 있습니다.", diff --git a/selfdrive/carrot/web/js/translations/zh.js b/selfdrive/carrot/web/js/translations/zh.js index a2909769a77..263195cd485 100644 --- a/selfdrive/carrot/web/js/translations/zh.js +++ b/selfdrive/carrot/web/js/translations/zh.js @@ -288,6 +288,8 @@ window.CarrotTranslations.register("zh", { not_set: "未设置", connecting: "连接中...", reconnecting: "重连中...", + vision_reconnect: "重新连接", + vision_stop: "停止", error: "错误", notice: "提示", confirm_title: "确认", @@ -340,6 +342,12 @@ window.CarrotTranslations.register("zh", { setting_reset_defaults_confirm: "将全部设置重置为默认值?", setting_reset_defaults_done: "设置重置成功", setting_reset_defaults_failed: "设置重置失败", + setting_reset_default: "默认值", + setting_reset_default_aria: "恢复为默认值", + setting_reset_default_title: "恢复默认值", + setting_reset_default_confirm: "要恢复为默认值({value})吗?", + setting_reset_default_done: "已恢复为默认值", + setting_already_default: "已是默认值", setting_search_placeholder: "搜索名称、描述、分组", setting_search_empty: "没有匹配的设置项。", setting_search_idle: "输入关键词以查找详细设置。", From ecfea7bfb23c42f7c44668242b1b8c224d825c41 Mon Sep 17 00:00:00 2001 From: jominki354 Date: Thu, 25 Jun 2026 14:25:17 +0900 Subject: [PATCH 03/12] f --- .../carrot/web/css/pages/settings/base.css | 8 ++- selfdrive/carrot/web/css/pages/tools/main.css | 25 +++++++ selfdrive/carrot/web/index.html | 70 ++++++++++--------- selfdrive/carrot/web/js/pages/tools.js | 11 ++- 4 files changed, 73 insertions(+), 41 deletions(-) diff --git a/selfdrive/carrot/web/css/pages/settings/base.css b/selfdrive/carrot/web/css/pages/settings/base.css index 0beb2502c60..5d0845c5c09 100644 --- a/selfdrive/carrot/web/css/pages/settings/base.css +++ b/selfdrive/carrot/web/css/pages/settings/base.css @@ -180,9 +180,11 @@ align-items: flex-end; pointer-events: auto; transform-origin: right bottom; - transition: - opacity 0.12s ease, - transform 0.16s cubic-bezier(0.2, 0, 0, 1); + /* No layout transition on the container itself: on load / resize / orientation + (portrait fixed ↔ landscape relative) and during page transitions its + position recomputes, and an always-on transform/opacity transition animated + those changes — the "흔들림". The FAB must just stay put. Open/close motion + lives on .setting-fab-actions and the bounce on .fab--setting-menu. */ } /* 전환 중 FAB 를 숨기지 않는다 — FAB 는 position:fixed 라 슬라이드와 무관하고, diff --git a/selfdrive/carrot/web/css/pages/tools/main.css b/selfdrive/carrot/web/css/pages/tools/main.css index 7f1a16f20e1..faed85186f3 100644 --- a/selfdrive/carrot/web/css/pages/tools/main.css +++ b/selfdrive/carrot/web/css/pages/tools/main.css @@ -152,6 +152,31 @@ .tools-group__body { margin-top: 8px; + display: grid; + grid-template-rows: 1fr; + overflow: hidden; + transition: + grid-template-rows var(--motion-medium) var(--ease-emphasized), + opacity var(--motion-base) var(--ease-emphasized), + margin-top var(--motion-base) var(--ease-emphasized); +} + +.tools-group:not(.is-open) > .tools-group__body { + grid-template-rows: 0fr; + margin-top: 0; + opacity: 0; + pointer-events: none; +} + +.tools-group__bodyInner { + min-height: 0; + overflow: hidden; +} + +@media (prefers-reduced-motion: reduce) { + .tools-group__body { + transition: none; + } } .page--tools .section.tools-group { diff --git a/selfdrive/carrot/web/index.html b/selfdrive/carrot/web/index.html index 154d15080a7..9745a0f0f1a 100644 --- a/selfdrive/carrot/web/index.html +++ b/selfdrive/carrot/web/index.html @@ -78,12 +78,12 @@ - + - + @@ -242,18 +242,20 @@

Tools

Git Commands -
- - - - - - - - +
+
+ + + + + + + + +
@@ -264,16 +266,18 @@

Tools

User / System - @@ -284,13 +288,15 @@

Tools

Settings - @@ -709,7 +715,7 @@

Home

- + diff --git a/selfdrive/carrot/web/js/pages/tools.js b/selfdrive/carrot/web/js/pages/tools.js index b60b4c714f4..fd4d82ab48f 100644 --- a/selfdrive/carrot/web/js/pages/tools.js +++ b/selfdrive/carrot/web/js/pages/tools.js @@ -1030,7 +1030,7 @@ function initToolsPage() { const groups = Array.from(document.querySelectorAll("#pageTools .tools-group")); const applyToolsStagger = () => { const items = Array.from(document.querySelectorAll( - "#pageTools .tools-scroll-stack > .row-wrap:first-child, #pageTools .tools-group, #pageTools .tools-group.is-open .tools-group__body > *" + "#pageTools .tools-scroll-stack > .row-wrap:first-child, #pageTools .tools-group, #pageTools .tools-group.is-open .tools-group__bodyInner > *" )).filter((node) => !node.hidden && !node.classList.contains("hidden")); items.forEach((node, index) => { node.classList.add("ui-stagger-item"); @@ -1047,15 +1047,14 @@ function initToolsPage() { const savedState = localStorage.getItem("tools_group_" + groupName); const shouldOpen = savedState !== null ? savedState === "true" : true; + // Open/close is driven by the .is-open class so the body can animate its + // height (grid-template-rows 1fr↔0fr). Using `hidden` (display:none) would + // break the transition, so it is no longer toggled here. group.classList.toggle("is-open", shouldOpen); - body.hidden = !shouldOpen; - body.classList.toggle("hidden", !shouldOpen); toggle.setAttribute("aria-expanded", shouldOpen ? "true" : "false"); bindNodeOnce(toggle, "toolsGroupToggle", () => { - const nextOpen = body.hidden; - body.hidden = !nextOpen; - body.classList.toggle("hidden", !nextOpen); + const nextOpen = !group.classList.contains("is-open"); group.classList.toggle("is-open", nextOpen); toggle.setAttribute("aria-expanded", nextOpen ? "true" : "false"); localStorage.setItem("tools_group_" + groupName, nextOpen ? "true" : "false"); From c45aac531c2b21ba3418df678d8d5911d021451b Mon Sep 17 00:00:00 2001 From: jominki354 Date: Thu, 25 Jun 2026 14:33:42 +0900 Subject: [PATCH 04/12] f --- .../carrot/web/css/pages/settings/base.css | 20 +++++++++++ selfdrive/carrot/web/css/pages/tools/main.css | 35 +++++++++++++++---- selfdrive/carrot/web/index.html | 6 ++-- selfdrive/carrot/web/js/pages/tools.js | 12 ++++++- 4 files changed, 63 insertions(+), 10 deletions(-) diff --git a/selfdrive/carrot/web/css/pages/settings/base.css b/selfdrive/carrot/web/css/pages/settings/base.css index 5d0845c5c09..f8f280d9cb1 100644 --- a/selfdrive/carrot/web/css/pages/settings/base.css +++ b/selfdrive/carrot/web/css/pages/settings/base.css @@ -187,12 +187,32 @@ lives on .setting-fab-actions and the bounce on .fab--setting-menu. */ } +/* Enter together with the page (one-shot). The FAB is position:fixed and lives + outside the sliding page, so without this it just popped in instantly while + the page content animated — "혼자 먼저 로딩된" 느낌. This is an animation (not a + transition), so it plays once on page entry and never re-fires on layout + reflow — the "stay put" fix above is preserved. */ +@keyframes setting-fab-enter { + from { opacity: 0; transform: translateY(8px) scale(0.96); } + to { opacity: 1; transform: none; } +} + +body[data-page="setting"] .page-fab-layer--setting .setting-fab-menu { + animation: setting-fab-enter var(--motion-medium) var(--ease-emphasized) both; +} + /* 전환 중 FAB 를 숨기지 않는다 — FAB 는 position:fixed 라 슬라이드와 무관하고, 끝에 다시 나타나며(opacity/scale) 한 번 깜박이던 원인이었다. 클릭만 잠시 막는다. */ .page--setting.setting-screen-transitioning .setting-fab-menu { pointer-events: none; } +@media (prefers-reduced-motion: reduce) { + body[data-page="setting"] .page-fab-layer--setting .setting-fab-menu { + animation: none; + } +} + .setting-fab-actions { position: absolute; right: 0; diff --git a/selfdrive/carrot/web/css/pages/tools/main.css b/selfdrive/carrot/web/css/pages/tools/main.css index faed85186f3..2f27db66057 100644 --- a/selfdrive/carrot/web/css/pages/tools/main.css +++ b/selfdrive/carrot/web/css/pages/tools/main.css @@ -155,10 +155,7 @@ display: grid; grid-template-rows: 1fr; overflow: hidden; - transition: - grid-template-rows var(--motion-medium) var(--ease-emphasized), - opacity var(--motion-base) var(--ease-emphasized), - margin-top var(--motion-base) var(--ease-emphasized); + will-change: grid-template-rows, opacity; } .tools-group:not(.is-open) > .tools-group__body { @@ -168,14 +165,40 @@ pointer-events: none; } +/* One-shot fold (same pattern as the settings profile section). JS adds the + motion class then removes it after the run, so the finish lands on the + static open/closed state instead of a lingering transition — that lingering + transition was what made the finish look off with taller (2-line) buttons. */ +.tools-group.is-collapsing > .tools-group__body { + animation: tools-group-fold var(--motion-medium) var(--ease-emphasized) both; +} + +.tools-group.is-expanding > .tools-group__body { + animation: tools-group-fold var(--motion-medium) var(--ease-emphasized) reverse both; +} + .tools-group__bodyInner { min-height: 0; overflow: hidden; } +@keyframes tools-group-fold { + from { + grid-template-rows: 1fr; + margin-top: 8px; + opacity: 1; + } + to { + grid-template-rows: 0fr; + margin-top: 0; + opacity: 0; + } +} + @media (prefers-reduced-motion: reduce) { - .tools-group__body { - transition: none; + .tools-group.is-collapsing > .tools-group__body, + .tools-group.is-expanding > .tools-group__body { + animation: none; } } diff --git a/selfdrive/carrot/web/index.html b/selfdrive/carrot/web/index.html index 9745a0f0f1a..35f9f2c50ce 100644 --- a/selfdrive/carrot/web/index.html +++ b/selfdrive/carrot/web/index.html @@ -78,12 +78,12 @@ - + - + @@ -715,7 +715,7 @@

Home

- + diff --git a/selfdrive/carrot/web/js/pages/tools.js b/selfdrive/carrot/web/js/pages/tools.js index fd4d82ab48f..33f8a2994f9 100644 --- a/selfdrive/carrot/web/js/pages/tools.js +++ b/selfdrive/carrot/web/js/pages/tools.js @@ -1055,10 +1055,20 @@ function initToolsPage() { bindNodeOnce(toggle, "toolsGroupToggle", () => { const nextOpen = !group.classList.contains("is-open"); + // One-shot fold (same pattern as the settings profile section): reset + // motion state, commit a baseline reflow, flip the static state, then + // play the motion class and clear it after the run for a clean finish. + group.classList.remove("is-expanding", "is-collapsing"); + if (group.__toolsGroupMotionTimer) clearTimeout(group.__toolsGroupMotionTimer); + void group.offsetWidth; group.classList.toggle("is-open", nextOpen); + group.classList.add(nextOpen ? "is-expanding" : "is-collapsing"); toggle.setAttribute("aria-expanded", nextOpen ? "true" : "false"); localStorage.setItem("tools_group_" + groupName, nextOpen ? "true" : "false"); - applyToolsStagger(); + group.__toolsGroupMotionTimer = window.setTimeout(() => { + group.classList.remove("is-expanding", "is-collapsing"); + group.__toolsGroupMotionTimer = null; + }, 280); }); }); applyToolsStagger(); From 88d28bf60f454b827affbf7139b1088a8a986ecc Mon Sep 17 00:00:00 2001 From: jominki354 Date: Thu, 25 Jun 2026 14:39:03 +0900 Subject: [PATCH 05/12] f --- selfdrive/carrot/web/css/pages/settings/base.css | 15 ++++++++------- selfdrive/carrot/web/index.html | 2 +- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/selfdrive/carrot/web/css/pages/settings/base.css b/selfdrive/carrot/web/css/pages/settings/base.css index f8f280d9cb1..52cd593d46d 100644 --- a/selfdrive/carrot/web/css/pages/settings/base.css +++ b/selfdrive/carrot/web/css/pages/settings/base.css @@ -187,14 +187,15 @@ lives on .setting-fab-actions and the bounce on .fab--setting-menu. */ } -/* Enter together with the page (one-shot). The FAB is position:fixed and lives - outside the sliding page, so without this it just popped in instantly while - the page content animated — "혼자 먼저 로딩된" 느낌. This is an animation (not a - transition), so it plays once on page entry and never re-fires on layout - reflow — the "stay put" fix above is preserved. */ +/* Enter together with the page (one-shot) so the FAB doesn't pop in instantly + while the page content animates. OPACITY ONLY — no transform/position change: + the FAB is position:fixed and a transform here re-anchored / shifted it during + the page-transition (the "세로모드에서 위→아래로 튐"). Fading in place keeps the + FAB exactly at its resting position. It's an animation (not a transition), so + it plays once on page entry and never re-fires on layout reflow. */ @keyframes setting-fab-enter { - from { opacity: 0; transform: translateY(8px) scale(0.96); } - to { opacity: 1; transform: none; } + from { opacity: 0; } + to { opacity: 1; } } body[data-page="setting"] .page-fab-layer--setting .setting-fab-menu { diff --git a/selfdrive/carrot/web/index.html b/selfdrive/carrot/web/index.html index 35f9f2c50ce..640d541a5f2 100644 --- a/selfdrive/carrot/web/index.html +++ b/selfdrive/carrot/web/index.html @@ -78,7 +78,7 @@ - + From d236f04f366a06f5fd01118dc5b111fa2bd9114d Mon Sep 17 00:00:00 2001 From: jominki354 Date: Thu, 25 Jun 2026 14:46:00 +0900 Subject: [PATCH 06/12] f --- selfdrive/carrot/web/index.html | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/selfdrive/carrot/web/index.html b/selfdrive/carrot/web/index.html index 640d541a5f2..f8b9c15125f 100644 --- a/selfdrive/carrot/web/index.html +++ b/selfdrive/carrot/web/index.html @@ -23,6 +23,25 @@ document.documentElement.dataset.carrotStartPage = startPage; })(); +