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 @@