diff --git a/selfdrive/carrot/carrot_man.py b/selfdrive/carrot/carrot_man.py index b244ea1f55..1498463b49 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) diff --git a/selfdrive/carrot/server/features/params.py b/selfdrive/carrot/server/features/params.py index a8893ad132..0873c42a4a 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 cadcb57c3d..e05b5f8348 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 4794fb9f7a..4a4cf5fb91 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 88948e51c8..215654e184 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 b3e9df5056..d03d0a6e05 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 76cba158ad..52cd593d46 100644 --- a/selfdrive/carrot/web/css/pages/settings/base.css +++ b/selfdrive/carrot/web/css/pages/settings/base.css @@ -180,9 +180,26 @@ 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. */ +} + +/* 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; } + to { opacity: 1; } +} + +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 라 슬라이드와 무관하고, @@ -191,6 +208,12 @@ 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; @@ -234,7 +257,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 0a1a7c2d2f..47c943c555 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 12px, #000 calc(100% - 6px), transparent 100%); + mask-image: linear-gradient(to bottom, transparent 0, #000 12px, #000 calc(100% - 6px), 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,9 +364,17 @@ box-sizing: border-box; scrollbar-gutter: stable; transition: none; - /* One UI 스타일 스크롤 가장자리 페이드 (상 14px / 하 24px) */ - -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.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 12px, #000 calc(100% - 6px), transparent 100%); + mask-image: linear-gradient(to bottom, transparent 0, #000 12px, #000 calc(100% - 6px), transparent 100%); } .page.page--setting > #settingScreenHost > #settingScreenGroups { @@ -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)); } } @@ -1199,7 +1325,6 @@ } .page--setting #settingSubnav[hidden], -.page--setting #deviceSubnav[hidden], .page--setting #carrotTabContent[hidden], .page--setting #deviceTabContent[hidden], .page--setting #items[hidden], @@ -1211,6 +1336,12 @@ display: none; } +/* The device tab has no top quick-tab subnav (old, unused). Hide the subnav + wrap on the device tab so no empty bar shows at any width / adaptive layout. */ +.page--setting.setting-tab-device #settingSubnavWrap { + display: none !important; +} + .page--setting #deviceItems .ctrl { display: flex; grid-template-columns: none; @@ -1657,7 +1788,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 +1798,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 +1820,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 +1849,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 { @@ -1747,3 +1879,34 @@ .device-info-modal__body p { margin: 0 0 var(--sp-sm); } + +/* Portrait (phone): anchor the settings FAB as page-relative ABSOLUTE flow, + the same approach landscape already uses. As position:fixed the FAB was + captured by .page-active's transform (an ancestor transform is the + containing block for fixed children), so it followed the page geometry — + --app-vv-height settling on load, page transitions — and drifted up/down. + As a normal absolute child of the page it simply sits at the page's + bottom-right and stays put. The page already excludes the nav bar + safe + area from its height, so the offset here is just the small visual gap. */ +@media (max-width: 640px) and (orientation: portrait) { + .page.page--setting > .page-fab-layer--setting { + position: absolute; + top: auto; + left: auto; + right: max(var(--sp-lg), env(safe-area-inset-right, 0px)); + bottom: calc(10px + var(--sp-lg)); + width: auto; + height: auto; + display: flex; + justify-content: flex-end; + z-index: 130; + pointer-events: none; + } + + .page.page--setting > .page-fab-layer--setting .setting-fab-menu { + position: relative; + right: auto; + left: auto; + bottom: auto; + } +} diff --git a/selfdrive/carrot/web/css/pages/settings/panels.css b/selfdrive/carrot/web/css/pages/settings/panels.css index e88e5c781e..0f56c990b5 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 3399f12b1e..781f1a653a 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/pages/tools/main.css b/selfdrive/carrot/web/css/pages/tools/main.css index 7f1a16f20e..2f27db6605 100644 --- a/selfdrive/carrot/web/css/pages/tools/main.css +++ b/selfdrive/carrot/web/css/pages/tools/main.css @@ -152,6 +152,54 @@ .tools-group__body { margin-top: 8px; + display: grid; + grid-template-rows: 1fr; + overflow: hidden; + will-change: grid-template-rows, opacity; +} + +.tools-group:not(.is-open) > .tools-group__body { + grid-template-rows: 0fr; + margin-top: 0; + opacity: 0; + 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.is-collapsing > .tools-group__body, + .tools-group.is-expanding > .tools-group__body { + animation: none; + } } .page--tools .section.tools-group { diff --git a/selfdrive/carrot/web/css/tokens.css b/selfdrive/carrot/web/css/tokens.css index 6d0d809208..87ed08460b 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 e30cb95c61..033a1f3887 100644 --- a/selfdrive/carrot/web/index.html +++ b/selfdrive/carrot/web/index.html @@ -23,6 +23,25 @@ document.documentElement.dataset.carrotStartPage = startPage; })(); + - + - - - - - - + + + + + + - - + + @@ -195,7 +214,6 @@

Home

- - - + + + - + - + - + - + - + @@ -716,12 +744,12 @@

Home

- + - + diff --git a/selfdrive/carrot/web/js/app.js b/selfdrive/carrot/web/js/app.js index 19dc1fac26..5d963d0b74 100644 --- a/selfdrive/carrot/web/js/app.js +++ b/selfdrive/carrot/web/js/app.js @@ -19,6 +19,14 @@ window.addEventListener("popstate", async (ev) => { return; } + if (st.page === "setting" && st.tab === "device") { + showPage("setting", false); + if (typeof restoreSettingDeviceTab === "function") { + await restoreSettingDeviceTab(st.screen || "groups", st.deviceGroup || null); + } + return; + } + if (st.page === "setting") { const screen = st.screen || "groups"; const previousDetail = CURRENT_SETTING_DETAIL; diff --git a/selfdrive/carrot/web/js/pages/setting.js b/selfdrive/carrot/web/js/pages/setting.js index 7514467dbb..3c5d75f20e 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 35c8b34df8..3806d04c9d 100644 --- a/selfdrive/carrot/web/js/pages/setting_device.js +++ b/selfdrive/carrot/web/js/pages/setting_device.js @@ -65,14 +65,12 @@ function syncSettingTabChrome(tab = CURRENT_SETTING_TAB) { function syncSettingTabPanels(tab = CURRENT_SETTING_TAB) { const isDevice = tab === "device"; const carrotTabContent = document.getElementById("carrotTabContent"); - const deviceSubnav = document.getElementById("deviceSubnav"); const items = document.getElementById("items"); const deviceItems = document.getElementById("deviceItems"); setSettingDeviceHidden(carrotTabContent, isDevice); setSettingDeviceHidden(deviceTabContent, !isDevice); setSettingDeviceHidden(settingSubnav, isDevice); - setSettingDeviceHidden(deviceSubnav, !isDevice); setSettingDeviceHidden(items, isDevice); setSettingDeviceHidden(deviceItems, !isDevice); } @@ -141,7 +139,6 @@ async function loadDeviceNetwork(useCache = true) { function renderDeviceGroups(options = {}) { const groupContainer = document.getElementById("deviceGroupList"); - const subnavContainer = document.getElementById("deviceSubnav"); if (!groupContainer) return; const animateGroups = options.animateGroups !== false; @@ -158,8 +155,7 @@ function renderDeviceGroups(options = {}) { if ( !animateGroups && groupContainer.dataset.deviceGroupsSignature === signature && - groupContainer.children.length === groupEntries.length && - (!subnavContainer || subnavContainer.children.length === groupEntries.length) + groupContainer.children.length === groupEntries.length ) { Array.from(groupContainer.children).forEach((button, index) => { const entry = groupEntries[index]; @@ -169,27 +165,12 @@ function renderDeviceGroups(options = {}) { button.innerHTML = `${escapeHtml(entry.label)}`; button.onclick = () => selectDeviceGroup(entry.group.id); }); - - if (subnavContainer && subnavContainer.children.length === groupEntries.length) { - Array.from(subnavContainer.children).forEach((tab, index) => { - const entry = groupEntries[index]; - tab.className = "setting-subnav__tab"; - if (entry.group.id === CURRENT_DEVICE_GROUP) tab.classList.add("is-active"); - tab.dataset.deviceGroup = entry.group.id; - tab.textContent = entry.label; - tab.onclick = () => selectDeviceGroup(entry.group.id); - }); - } if (typeof scheduleSettingOverflowSync === "function") scheduleSettingOverflowSync(groupContainer); return; } groupContainer.innerHTML = ""; groupContainer.dataset.deviceGroupsSignature = signature; - if (subnavContainer) { - subnavContainer.innerHTML = ""; - subnavContainer.dataset.deviceGroupsSignature = signature; - } groupEntries.forEach((entry, index) => { const group = entry.group; @@ -203,28 +184,21 @@ function renderDeviceGroups(options = {}) { button.innerHTML = `${escapeHtml(label)}`; button.onclick = () => selectDeviceGroup(group.id); groupContainer.appendChild(button); - - if (subnavContainer) { - const tab = document.createElement("button"); - tab.type = "button"; - tab.className = animateGroups ? "setting-subnav__tab ui-stagger-item" : "setting-subnav__tab"; - if (animateGroups) tab.style.setProperty("--i", String(index)); - if (group.id === CURRENT_DEVICE_GROUP) tab.classList.add("is-active"); - tab.dataset.deviceGroup = group.id; - tab.textContent = label; - tab.onclick = () => selectDeviceGroup(group.id); - subnavContainer.appendChild(tab); - } }); if (typeof scheduleSettingOverflowSync === "function") scheduleSettingOverflowSync(groupContainer); } function applyDeviceItemsStagger(container) { if (!container) return; - Array.from(container.children).forEach((child, index) => { - if (!child.classList?.contains("setting")) return; - child.classList.add("ui-stagger-item"); - child.style.setProperty("--i", String(index)); + // Stagger the section-block card(s) like the CarrotPilot tab. Falls back to + // direct .setting children if items aren't card-wrapped (defensive). + const blocks = container.querySelectorAll(".setting-section-block"); + const targets = blocks.length + ? Array.from(blocks) + : Array.from(container.children).filter((c) => c.classList?.contains("setting")); + targets.forEach((el, index) => { + el.classList.add("ui-stagger-item"); + el.style.setProperty("--i", String(index)); }); } @@ -245,14 +219,38 @@ async function renderDeviceTab(options = {}) { } } -async function selectDeviceGroup(groupId) { +async function selectDeviceGroup(groupId, pushHistory = true) { CURRENT_DEVICE_GROUP = groupId || CURRENT_DEVICE_GROUP; renderDeviceGroups(); syncSettingTabState("device"); syncDeviceGroupChrome(CURRENT_DEVICE_GROUP); + // Same history-based navigation as the CarrotPilot tab: an "items" entry lets + // the title back-chevron / device back button return to the device groups + // screen. Skip in compact-landscape split (it always shows items). + const splitLandscape = + typeof isCompactLandscapeMode === "function" && isCompactLandscapeMode() && CURRENT_PAGE === "setting"; + if (pushHistory && !splitLandscape) { + history.pushState({ page: "setting", tab: "device", screen: "items", deviceGroup: CURRENT_DEVICE_GROUP }, ""); + } await renderDeviceItems(CURRENT_DEVICE_GROUP, true, { animateItems: true }); } +// Restore the device tab from a popstate without touching history (no push / +// replace) — mirrors how app.js restores the CarrotPilot tab. +async function restoreSettingDeviceTab(screen, deviceGroup) { + if (typeof CURRENT_SETTING_TAB !== "undefined") CURRENT_SETTING_TAB = "device"; + syncSettingTabState("device"); + await renderDeviceTab({ animateGroups: false, animateItems: false }); + if (screen === "items" && deviceGroup) { + await selectDeviceGroup(deviceGroup, false); + } else if (typeof showSettingScreen === "function") { + showSettingScreen("groups", false); + } + syncDeviceGroupChrome(CURRENT_DEVICE_GROUP); + if (typeof syncDeviceSshRefresh === "function") syncDeviceSshRefresh(); +} +window.restoreSettingDeviceTab = restoreSettingDeviceTab; + async function loadDeviceSshStatus(useCache = true) { if (deviceSshStatus && useCache) return deviceSshStatus; const payload = await requestJson("/api/ssh_keys", { cache: "no-store" }); @@ -337,6 +335,14 @@ async function renderDeviceItems(groupId, showItemsScreen = true, options = {}) if (!itemsContainer) return; const silentRefresh = options.silentRefresh === true; + // A drill-in from the groups screen triggers the left/right screen slide. + // Don't ALSO play the per-item rise (stagger) then — the slide + rise mix is + // the jarring combo the user saw. CarrotPilot is slide-only in this case. + // Detect it before the screen swaps (items screen still hidden = drill-in). + const screenItemsEl = document.getElementById("settingScreenItems"); + const willSlide = showItemsScreen && !!screenItemsEl && + (screenItemsEl.style.display === "none" || screenItemsEl.classList.contains("hidden")); + syncSettingTabState("device"); if (showItemsScreen && typeof showSettingScreen === "function") { showSettingScreen("items", false); @@ -354,8 +360,14 @@ async function renderDeviceItems(groupId, showItemsScreen = true, options = {}) return; } - itemsContainer.innerHTML = renderDeviceGroupItems(groupId, values) || `
-
`; - if (!silentRefresh && options.animateItems !== false) { + // Wrap device items in the same card box the CarrotPilot tab uses + // (setting-section-block > setting-group-card > setting-group-card__body) so + // the device submenu looks identical, not the old flat rows. + const deviceItemsHtml = renderDeviceGroupItems(groupId, values); + itemsContainer.innerHTML = deviceItemsHtml + ? `
${deviceItemsHtml}
` + : `
-
`; + if (!silentRefresh && options.animateItems !== false && !willSlide) { applyDeviceItemsStagger(itemsContainer); } bindDeviceTabEvents(itemsContainer); @@ -428,7 +440,7 @@ function renderDeviceToggleItems(values) { getUIText("driving_personality_desc", "Aggressive, Standard, Relaxed"), getUIText(option.labelKey, option.defaultLabel), "btnDevicePersonality", - "val pill", + "val value-surface", ); return html; } @@ -461,7 +473,12 @@ function syncDeviceGroupChrome(groupId = CURRENT_DEVICE_GROUP) { if (typeof settingTitle !== "undefined" && settingTitle) { settingTitle.textContent = (UI_STRINGS[LANG].setting || "Setting") + " - " + label; } - if (typeof itemsTitle !== "undefined" && itemsTitle) { + // Use the shared title renderer so the device submenu gets the same + // "‹ back" chevron as the CarrotPilot tab (the global itemsTitle click + // handler then drives history.back()). + if (typeof setSettingItemsTitle === "function") { + setSettingItemsTitle(label); + } else if (typeof itemsTitle !== "undefined" && itemsTitle) { itemsTitle.textContent = label; } } @@ -493,6 +510,9 @@ async function switchSettingTab(tab) { await renderDeviceTab(); if (!(typeof isCompactLandscapeMode === "function" && isCompactLandscapeMode()) && typeof showSettingScreen === "function") { showSettingScreen("groups", false); + // Mark the device-groups base entry so back from a device submenu returns + // here (not to the CarrotPilot groups). Mirrors the CarrotPilot flow. + history.replaceState({ page: "setting", tab: "device", screen: "groups" }, ""); } syncDeviceGroupChrome(CURRENT_DEVICE_GROUP); syncDeviceSshRefresh(); @@ -513,6 +533,15 @@ async function switchSettingTab(tab) { if (typeof showSettingScreen === "function") { showSettingScreen("groups", false); + // Match the device tab: re-render the CarrotPilot groups with the stagger + // entrance so switching tabs animates both sides consistently (device + // re-renders via renderDeviceTab, CarrotPilot didn't → no animation). + if (typeof renderGroups === "function") renderGroups({ animateGroups: true }); + // Re-sync history to the CarrotPilot groups so back/forward stays in step + // with the visible tab after a tab switch. + if (!(typeof isCompactLandscapeMode === "function" && isCompactLandscapeMode())) { + history.replaceState({ page: "setting", screen: "groups", group: null }, ""); + } } if (typeof syncSettingGroupChrome === "function") syncSettingGroupChrome(CURRENT_GROUP); } diff --git a/selfdrive/carrot/web/js/pages/tools.js b/selfdrive/carrot/web/js/pages/tools.js index b60b4c714f..33f8a2994f 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,19 +1047,28 @@ 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"); + // 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(); diff --git a/selfdrive/carrot/web/js/realtime/app_realtime.js b/selfdrive/carrot/web/js/realtime/app_realtime.js index 46f0c7754b..3af25bfe4f 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 faf3042065..f81136d905 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 9f5f5c685c..0d8477b15b 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 3192d419af..0b7923b0c2 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 8e8e8b2c35..6d8cd86c0c 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 a2909769a7..263195cd48 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: "输入关键词以查找详细设置。",