diff --git a/selfdrive/carrot/server/config.py b/selfdrive/carrot/server/config.py index 347a3c1908..924056563f 100644 --- a/selfdrive/carrot/server/config.py +++ b/selfdrive/carrot/server/config.py @@ -22,6 +22,7 @@ CARROT_TOOL_JOBS_STATE_PATH = os.path.join(CARROT_STATE_DIR, "tool_jobs.json") CARROT_WEB_SETTINGS_PATH = os.path.join(CARROT_STATE_DIR, "web_settings.json") CARROT_SETTING_FAVORITES_PATH = os.path.join(CARROT_STATE_DIR, "setting_favorites.json") +CARROT_SETTING_PROFILES_PATH = os.path.join(CARROT_STATE_DIR, "setting_profiles.json") # Dashcam DASHCAM_ROOT = "/data/media/0/realdata" diff --git a/selfdrive/carrot/server/features/__init__.py b/selfdrive/carrot/server/features/__init__.py index a1dedb4095..db0d26d5ee 100644 --- a/selfdrive/carrot/server/features/__init__.py +++ b/selfdrive/carrot/server/features/__init__.py @@ -7,6 +7,7 @@ screenrecord, settings, setting_favorites, + setting_profiles, ssh_keys, static, stream, @@ -25,6 +26,7 @@ def register_all(app: web.Application) -> None: settings.register(app) params.register(app) setting_favorites.register(app) + setting_profiles.register(app) web_settings.register(app) ssh_keys.register(app) cars.register(app) diff --git a/selfdrive/carrot/server/features/setting_profiles.py b/selfdrive/carrot/server/features/setting_profiles.py new file mode 100644 index 0000000000..dbd888a474 --- /dev/null +++ b/selfdrive/carrot/server/features/setting_profiles.py @@ -0,0 +1,103 @@ +from aiohttp import web + +from ..services.setting_profiles import ( + apply_setting_profile, + create_setting_profile, + delete_setting_profile, + preview_setting_profile, + read_setting_profiles, + update_setting_profile, +) + + +async def get_setting_profiles(request: web.Request) -> web.Response: + return web.json_response({"ok": True, **read_setting_profiles()}) + + +async def create_setting_profile_route(request: web.Request) -> web.Response: + try: + body = await request.json() + except Exception: + body = {} + try: + profile = create_setting_profile(body.get("name", "")) + return web.json_response({"ok": True, "profile": profile, **read_setting_profiles()}) + except Exception as e: + return web.json_response({"ok": False, "error": str(e)}, status=400) + + +async def update_setting_profile_route(request: web.Request) -> web.Response: + try: + body = await request.json() + except Exception: + body = {} + profile_id = str(body.get("id") or "").strip() + if not profile_id: + return web.json_response({"ok": False, "error": "missing profile id"}, status=400) + try: + profile = update_setting_profile(profile_id, body) + return web.json_response({"ok": True, "profile": profile, **read_setting_profiles()}) + except KeyError as e: + return web.json_response({"ok": False, "error": str(e)}, status=404) + except Exception as e: + return web.json_response({"ok": False, "error": str(e)}, status=400) + + +async def delete_setting_profile_route(request: web.Request) -> web.Response: + try: + body = await request.json() + except Exception: + body = {} + profile_id = str(body.get("id") or "").strip() + if not profile_id: + return web.json_response({"ok": False, "error": "missing profile id"}, status=400) + try: + delete_setting_profile(profile_id) + return web.json_response({"ok": True, **read_setting_profiles()}) + except KeyError as e: + return web.json_response({"ok": False, "error": str(e)}, status=404) + except Exception as e: + return web.json_response({"ok": False, "error": str(e)}, status=400) + + +async def preview_setting_profile_route(request: web.Request) -> web.Response: + try: + body = await request.json() + except Exception: + body = {} + profile_id = str(body.get("id") or "").strip() + if not profile_id: + return web.json_response({"ok": False, "error": "missing profile id"}, status=400) + try: + preview = preview_setting_profile(profile_id, body.get("values") if isinstance(body, dict) else None) + return web.json_response({"ok": True, "preview": preview}) + except KeyError as e: + return web.json_response({"ok": False, "error": str(e)}, status=404) + except Exception as e: + return web.json_response({"ok": False, "error": str(e)}, status=400) + + +async def apply_setting_profile_route(request: web.Request) -> web.Response: + try: + body = await request.json() + except Exception: + body = {} + profile_id = str(body.get("id") or "").strip() + if not profile_id: + return web.json_response({"ok": False, "error": "missing profile id"}, status=400) + try: + result = apply_setting_profile(profile_id, body.get("values") if isinstance(body, dict) else None) + return web.json_response({"ok": True, **result}) + except KeyError as e: + return web.json_response({"ok": False, "error": str(e)}, status=404) + except Exception as e: + return web.json_response({"ok": False, "error": str(e)}, status=400) + + +def register(app: web.Application) -> None: + app.router.add_get("/api/setting_profiles", get_setting_profiles) + app.router.add_post("/api/setting_profiles", create_setting_profile_route) + app.router.add_post("/api/setting_profiles/update", update_setting_profile_route) + app.router.add_post("/api/setting_profiles/delete", delete_setting_profile_route) + app.router.add_post("/api/setting_profiles/preview", preview_setting_profile_route) + app.router.add_post("/api/setting_profiles/apply", apply_setting_profile_route) diff --git a/selfdrive/carrot/server/features/system.py b/selfdrive/carrot/server/features/system.py index 0dc13045e9..abc7f70d8f 100644 --- a/selfdrive/carrot/server/features/system.py +++ b/selfdrive/carrot/server/features/system.py @@ -9,7 +9,8 @@ from ..live_runtime.normalize import to_transport_safe from ..config import OFFROAD_ASSETS_DIR from ..services.device_info import get_calibration_status, get_device_network -from ..services.params import HAS_PARAMS, Params, set_param_value +from ..services.params import HAS_PARAMS, Params, restore_param_values_validated +from ..services.settings import get_settings_cached from ..services.time_sync import TIME_SYNC_DEBUG_DEFAULT, sync_system_time_from_browser @@ -25,6 +26,69 @@ "carrotMan", ) +_CARROT_DEFAULT_RESET_EXCLUDED_NAMES = { + # Vehicle identity / selection / fingerprinting. + "CarName", + "CarParams", + "CarParamsCache", + "CarParamsPersistent", + "CarParamsPrevRoute", + "CarModel", + "CarFingerprint", + "SupportedCars", + # Training, terms, calibration, and learned live parameters. + "CompletedTrainingVersion", + "TrainingVersion", + "TermsVersion", + "HasAcceptedTerms", + "CalibrationParams", + "LiveParameters", + "LiveTorqueParameters", + # Device/user/system identity and platform settings. + "DongleId", + "HardwareSerial", + "DeviceSerial", + "DeviceType", + "LanguageSetting", + "IsMetric", + "OpenpilotEnabledToggle", + "ExperimentalMode", + "ExperimentalModeConfirmed", + "SshEnabled", + "AdbEnabled", + "GithubUsername", + "GithubSshKeys", + # Recording/upload/update/git state should not be reset by Carrot tuning reset. + "RecordFront", + "RecordAudio", + "GitBranch", + "GitCommit", + "GitCommitDate", + "UpdaterState", + "UpdaterTargetBranch", + "UpdaterCurrentDescription", +} + +_CARROT_DEFAULT_RESET_EXCLUDED_PREFIXES = ( + "CarParams", + "Calibration", + "CompletedTraining", + "Git", + "Github", + "Updater", + "Dongle", + "Hardware", + "DeviceSerial", +) + + +def _is_carrot_default_reset_param(name: str, meta: Any) -> bool: + if not name or not isinstance(meta, dict) or "default" not in meta: + return False + if name in _CARROT_DEFAULT_RESET_EXCLUDED_NAMES: + return False + return not any(name.startswith(prefix) for prefix in _CARROT_DEFAULT_RESET_EXCLUDED_PREFIXES) + def _select_live_runtime_services(snapshot: dict[str, Any]) -> dict[str, Any]: services = snapshot.get("services") @@ -225,8 +289,28 @@ async def api_set_default(request: web.Request) -> web.Response: if not HAS_PARAMS: return web.json_response({"ok": False, "error": "params unavailable"}, status=500) try: - Params().put_int("SoftRestartTriggered", 2) - return web.json_response({"ok": True}) + _, _, by_name, _ = get_settings_cached() + values = { + name: meta.get("default", 0) + for name, meta in by_name.items() + if _is_carrot_default_reset_param(name, meta) + } + restored = restore_param_values_validated(values) + result = restored.get("result") or {} + ok = int(result.get("fail_cnt") or 0) == 0 + applied_values = { + entry["key"]: entry["value"] + for entry in restored.get("preview", {}).get("entries", []) + if entry.get("apply") + } + status = 200 if ok else 500 + return web.json_response({ + "ok": ok, + "message": "설정 초기화 성공" if ok else "설정 초기화 실패", + "error": None if ok else "설정 초기화 실패", + "values": applied_values, + **restored, + }, status=status) except Exception as e: return web.json_response({"ok": False, "error": str(e)}, status=500) diff --git a/selfdrive/carrot/server/services/setting_profiles.py b/selfdrive/carrot/server/services/setting_profiles.py new file mode 100644 index 0000000000..9a21bd0a1a --- /dev/null +++ b/selfdrive/carrot/server/services/setting_profiles.py @@ -0,0 +1,243 @@ +from __future__ import annotations + +import json +import os +import re +import subprocess +import uuid +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +from ..config import CARROT_SETTING_PROFILES_PATH +from .params import ( + get_param_values, + preview_param_restore_values, + restore_param_values_validated, +) +from .settings import get_settings_cached + + +REPO_DIR = "/data/openpilot" +MAX_SETTING_PROFILES = 40 +MAX_PROFILE_NAME_LEN = 40 + + +def _now_iso() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat() + + +def _git(args: List[str], timeout: float = 3.0) -> str: + try: + out = subprocess.check_output( + ["git", *args], + cwd=REPO_DIR, + stderr=subprocess.DEVNULL, + timeout=timeout, + ) + return out.decode("utf-8", "replace").strip() + except Exception: + return "" + + +def _commit_url(remote: str, commit: str) -> str: + remote = str(remote or "").strip() + commit = str(commit or "").strip() + if not remote or not commit: + return "" + + match = re.match(r"^https?://github\.com/([^/]+)/([^/#?]+?)(?:\.git)?/?$", remote) + if not match: + match = re.match(r"^git@github\.com:([^/]+)/(.+?)(?:\.git)?$", remote) + if not match: + return "" + + owner, repo = match.groups() + return f"https://github.com/{owner}/{repo}/commit/{commit}" + + +def read_git_profile_meta() -> Dict[str, Any]: + branch = _git(["branch", "--show-current"]) + commit = _git(["rev-parse", "HEAD"]) + commit_date = _git(["show", "-s", "--format=%cI", "HEAD"]) + remote = _git(["config", "--get", "remote.origin.url"]) + return { + "branch": branch, + "commit": commit, + "commit_short": commit[:7] if commit else "", + "commit_date": commit_date, + "remote": remote, + "commit_url": _commit_url(remote, commit), + } + + +def _clean_name(value: Any) -> str: + name = str(value or "").strip() + name = re.sub(r"\s+", " ", name) + return name[:MAX_PROFILE_NAME_LEN] + + +def _clean_values(values: Any) -> Dict[str, Any]: + if not isinstance(values, dict): + return {} + _, _, by_name, _ = get_settings_cached() + allowed = set(by_name.keys()) + return { + str(key): value + for key, value in values.items() + if str(key) in allowed + } + + +def _setting_defaults() -> Dict[str, Any]: + _, _, by_name, _ = get_settings_cached() + return { + name: meta.get("default", 0) + for name, meta in by_name.items() + } + + +def snapshot_current_setting_values() -> Dict[str, Any]: + defaults = _setting_defaults() + return get_param_values(list(defaults.keys()), defaults) + + +def _sanitize_profile(raw: Any) -> Optional[Dict[str, Any]]: + if not isinstance(raw, dict): + return None + profile_id = str(raw.get("id") or "").strip() + name = _clean_name(raw.get("name")) + values = _clean_values(raw.get("values")) + if not profile_id or not name or not values: + return None + + created_at = str(raw.get("created_at") or "").strip() + updated_at = str(raw.get("updated_at") or created_at).strip() + meta = raw.get("meta") if isinstance(raw.get("meta"), dict) else {} + return { + "id": profile_id, + "name": name, + "created_at": created_at, + "updated_at": updated_at, + "meta": { + "branch": str(meta.get("branch") or ""), + "commit": str(meta.get("commit") or ""), + "commit_short": str(meta.get("commit_short") or ""), + "commit_date": str(meta.get("commit_date") or ""), + "remote": str(meta.get("remote") or ""), + "commit_url": str(meta.get("commit_url") or ""), + }, + "values": values, + } + + +def read_setting_profiles() -> Dict[str, Any]: + try: + with open(CARROT_SETTING_PROFILES_PATH, "r", encoding="utf-8") as f: + raw = json.load(f) + except Exception: + raw = {} + + profiles = raw.get("profiles") if isinstance(raw, dict) else [] + clean = [] + seen = set() + for item in profiles if isinstance(profiles, list) else []: + profile = _sanitize_profile(item) + if not profile or profile["id"] in seen: + continue + seen.add(profile["id"]) + clean.append(profile) + if len(clean) >= MAX_SETTING_PROFILES: + break + return {"profiles": clean} + + +def write_setting_profiles(data: Dict[str, Any]) -> Dict[str, Any]: + clean = {"profiles": []} + seen = set() + for item in data.get("profiles", []) if isinstance(data, dict) else []: + profile = _sanitize_profile(item) + if not profile or profile["id"] in seen: + continue + seen.add(profile["id"]) + clean["profiles"].append(profile) + if len(clean["profiles"]) >= MAX_SETTING_PROFILES: + break + + os.makedirs(os.path.dirname(CARROT_SETTING_PROFILES_PATH), exist_ok=True) + tmp_path = CARROT_SETTING_PROFILES_PATH + ".tmp" + with open(tmp_path, "w", encoding="utf-8") as f: + json.dump(clean, f, ensure_ascii=False, indent=2, sort_keys=True) + f.write("\n") + os.replace(tmp_path, CARROT_SETTING_PROFILES_PATH) + return clean + + +def get_setting_profile(profile_id: str) -> Optional[Dict[str, Any]]: + profile_id = str(profile_id or "").strip() + for profile in read_setting_profiles()["profiles"]: + if profile["id"] == profile_id: + return profile + return None + + +def create_setting_profile(name: str) -> Dict[str, Any]: + clean_name = _clean_name(name) + if not clean_name: + raise ValueError("missing profile name") + + data = read_setting_profiles() + now = _now_iso() + profile = { + "id": uuid.uuid4().hex, + "name": clean_name, + "created_at": now, + "updated_at": now, + "meta": read_git_profile_meta(), + "values": snapshot_current_setting_values(), + } + data["profiles"].append(profile) + write_setting_profiles(data) + return profile + + +def update_setting_profile(profile_id: str, updates: Dict[str, Any]) -> Dict[str, Any]: + data = read_setting_profiles() + for profile in data["profiles"]: + if profile["id"] != profile_id: + continue + if "name" in updates: + name = _clean_name(updates.get("name")) + if not name: + raise ValueError("missing profile name") + profile["name"] = name + if "values" in updates: + profile["values"] = _clean_values(updates.get("values")) + profile["updated_at"] = _now_iso() + write_setting_profiles(data) + return profile + raise KeyError("profile not found") + + +def delete_setting_profile(profile_id: str) -> None: + data = read_setting_profiles() + next_profiles = [profile for profile in data["profiles"] if profile["id"] != profile_id] + if len(next_profiles) == len(data["profiles"]): + raise KeyError("profile not found") + data["profiles"] = next_profiles + write_setting_profiles(data) + + +def preview_setting_profile(profile_id: str, values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + profile = get_setting_profile(profile_id) + if profile is None: + raise KeyError("profile not found") + restore_values = _clean_values(values) if values is not None else profile["values"] + return preview_param_restore_values(restore_values) + + +def apply_setting_profile(profile_id: str, values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + profile = get_setting_profile(profile_id) + if profile is None: + raise KeyError("profile not found") + restore_values = _clean_values(values) if values is not None else profile["values"] + return restore_param_values_validated(restore_values) diff --git a/selfdrive/carrot/server/services/web_settings.py b/selfdrive/carrot/server/services/web_settings.py index 24cc4d1019..a7e576d653 100644 --- a/selfdrive/carrot/server/services/web_settings.py +++ b/selfdrive/carrot/server/services/web_settings.py @@ -6,7 +6,7 @@ WEB_PRIMARY_PAGES = {"last", "carrot", "setting", "tools", "logs", "terminal"} -WEB_LANGUAGES = {"", "en", "ko", "zh", "ja", "fr"} +WEB_LANGUAGES = {"", "en", "ko", "zh"} DEFAULT_WEB_SETTINGS: Dict[str, Any] = { "auto_update_git_pull": False, @@ -28,18 +28,12 @@ def _normalize_language(value: Any) -> str: "main_en": "en", "main_zh-chs": "zh", "main_zh-cht": "zh", - "main_ja": "ja", - "main_fr": "fr", } lang = aliases.get(lang, lang) if lang.startswith("ko"): return "ko" if lang.startswith("zh"): return "zh" - if lang.startswith("ja"): - return "ja" - if lang.startswith("fr"): - return "fr" if lang.startswith("en"): return "en" return lang if lang in WEB_LANGUAGES else "" diff --git a/selfdrive/carrot/web/css/base.css b/selfdrive/carrot/web/css/base.css index 5bbdd1c01d..ba38e16fcf 100644 --- a/selfdrive/carrot/web/css/base.css +++ b/selfdrive/carrot/web/css/base.css @@ -322,6 +322,225 @@ body:has(.page-transitioning) { pointer-events: auto; } +.page-fab-layer .setting-fab-menu .fab { + position: static; + top: auto; + right: auto; +} + +.settings-diff-summary { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0; + margin-bottom: 10px; + border-top: 1px solid color-mix(in srgb, var(--md-outline-var) 30%, transparent); + border-bottom: 1px solid color-mix(in srgb, var(--md-outline-var) 30%, transparent); +} + +.settings-diff-summary__item { + min-width: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: baseline; + gap: 5px; + padding: 6px 6px 7px; + border-right: 1px solid color-mix(in srgb, var(--md-outline-var) 22%, transparent); +} + +.settings-diff-summary__item:last-child { + border-right: 0; +} + +.settings-diff-summary__item span { + min-width: 0; + overflow: hidden; + color: var(--md-on-surface-var); + font-size: 10px; + font-weight: 800; + line-height: 1; + text-overflow: ellipsis; + white-space: nowrap; +} + +.settings-diff-summary__item strong { + color: var(--md-on-surface); + font-size: 14px; + font-weight: 900; + line-height: 1; + white-space: nowrap; +} + +.settings-diff-summary__item--changed strong { + color: color-mix(in srgb, #8fdc9b 84%, var(--md-on-surface)); +} + +.settings-diff-summary__item--invalid strong { + color: color-mix(in srgb, #ff8a80 78%, var(--md-on-surface)); +} + +.settings-diff__list { + max-height: min(38dvh, 330px); + overflow: auto; + display: grid; + gap: 0; + margin-top: 2px; + border-top: 1px solid color-mix(in srgb, var(--md-outline-var) 22%, transparent); +} + +.settings-diff__row { + min-width: 0; + display: grid; + gap: 8px; + padding: 12px 0 14px; + border-bottom: 1px solid color-mix(in srgb, var(--md-outline-var) 30%, transparent); +} + +.settings-diff__head { + min-width: 0; + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; +} + +.settings-diff__key { + min-width: 0; + overflow: hidden; + color: var(--md-on-surface); + font-size: 13px; + font-weight: 850; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.settings-diff__status { + flex: 0 0 auto; + color: color-mix(in srgb, #8fdc9b 82%, var(--md-on-surface)); + font-size: 11px; + font-weight: 900; + line-height: 1.2; +} + +.settings-diff__compare { + min-width: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) 20px minmax(0, 1fr); + align-items: stretch; + gap: 10px; +} + +.settings-diff__value { + min-width: 0; + display: grid; + align-content: start; + gap: 5px; + padding: 2px 0 0; + border-top: 2px solid color-mix(in srgb, var(--md-outline-var) 28%, transparent); +} + +.settings-diff__value span { + min-width: 0; + color: var(--md-on-surface-var); + font-size: 11px; + font-weight: 900; + line-height: 1.15; +} + +.settings-diff__value code { + min-width: 0; + overflow-wrap: anywhere; + color: var(--md-on-surface); + font-family: var(--font-mono); + font-size: 13px; + font-weight: 850; + line-height: 1.25; + white-space: normal; +} + +.settings-diff__value--old { + border-top-color: color-mix(in srgb, #ff8a80 52%, var(--md-outline-var)); +} + +.settings-diff__value--new { + border-top-color: color-mix(in srgb, #8fdc9b 56%, var(--md-outline-var)); +} + +.settings-diff__value--old code { + color: color-mix(in srgb, #ff8a80 76%, var(--md-on-surface)); +} + +.settings-diff__value--new code { + color: color-mix(in srgb, #8fdc9b 84%, var(--md-on-surface)); +} + +.settings-diff__arrow { + width: 20px; + min-width: 20px; + display: flex; + align-items: center; + justify-content: center; + color: var(--md-on-surface-var); + font-size: 16px; + font-weight: 900; +} + +.settings-diff-empty, +.settings-diff-more { + color: var(--md-on-surface-var); + font-size: 13px; + font-weight: 750; + line-height: 1.4; +} + +@media (max-width: 640px), (orientation: portrait) { + .settings-diff-summary { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .settings-diff-summary__item:nth-child(2n) { + border-right: 1px solid color-mix(in srgb, var(--md-outline-var) 22%, transparent); + } + + .settings-diff-summary__item:last-child { + border-right: 0; + } + + .settings-diff-summary__item:nth-child(n + 3) { + border-top: 0; + } + + .settings-diff-summary__item { + gap: 3px; + padding-inline: 4px; + } + + .settings-diff-summary__item span { + font-size: 9.5px; + } + + .settings-diff-summary__item strong { + font-size: 13px; + } + + .settings-diff__list { + max-height: min(42dvh, 360px); + } + + .settings-diff__compare { + grid-template-columns: minmax(0, 1fr) 16px minmax(0, 1fr); + gap: 8px; + } + + .settings-diff__arrow { + width: 16px; + min-width: 16px; + height: auto; + transform: none; + font-size: 13px; + } +} + .fab::before { content: none; } diff --git a/selfdrive/carrot/web/css/components.css b/selfdrive/carrot/web/css/components.css index bf94181d1d..ee6e10e0ed 100644 --- a/selfdrive/carrot/web/css/components.css +++ b/selfdrive/carrot/web/css/components.css @@ -503,6 +503,88 @@ body[data-page="terminal"] .app-toast-host { color: var(--md-on-error-cont); } +/* ── Dropdown menus ──────────────────────────────────────── */ +.ui-dropdown-menu { + position: relative; + display: inline-flex; + align-items: center; + justify-content: flex-end; +} + +.ui-dropdown-menu__button { + appearance: none; + -webkit-appearance: none; + min-width: 42px; + min-height: 42px; + border: 1px solid color-mix(in srgb, var(--md-outline-var) 46%, transparent); + border-radius: 8px; + background: var(--md-surface-cont); + color: var(--md-on-surface); + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + cursor: pointer; + font: inherit; + font-size: 13px; + font-weight: 800; + line-height: 1; + transition: background 0.14s ease, border-color 0.14s ease, color 0.14s ease, transform 0.12s ease; +} + +.ui-dropdown-menu__button:hover, +.ui-dropdown-menu__button:focus-visible, +.ui-dropdown-menu.is-open .ui-dropdown-menu__button { + 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; +} + +.ui-dropdown-menu__button:active { + transform: translateY(1px); +} + +.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); + color: var(--md-on-surface); + box-shadow: 0 16px 34px rgba(0, 0, 0, 0.32); +} + +.ui-dropdown-menu__panel[hidden] { + display: none !important; +} + +.ui-dropdown-menu__item { + width: 100%; + min-height: 40px; + padding: 0 12px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--md-on-surface); + display: flex; + align-items: center; + justify-content: flex-start; + gap: 8px; + font: inherit; + font-size: 13px; + font-weight: 800; + text-align: left; + cursor: pointer; + transition: background 0.14s ease, color 0.14s ease; +} + +.ui-dropdown-menu__item:hover, +.ui-dropdown-menu__item:focus-visible { + background: color-mix(in srgb, var(--md-primary) 10%, transparent); + color: var(--md-on-surface); + outline: none; +} + /* ── Utility ──────────────────────────────────────────────── */ .row-between { display: flex; diff --git a/selfdrive/carrot/web/css/pages/settings.css b/selfdrive/carrot/web/css/pages/settings.css index 61eb78e645..d76f565798 100644 --- a/selfdrive/carrot/web/css/pages/settings.css +++ b/selfdrive/carrot/web/css/pages/settings.css @@ -6,6 +6,15 @@ --setting-menu-list-gap: 6px; --setting-menu-item-min-height: 46px; --setting-menu-item-padding-inline: 12px; + --setting-profile-accent: var(--md-primary, #ffab66); + --setting-profile-ink: var(--md-on-surface); + --setting-profile-surface: var(--md-surface-cont-h); + --setting-profile-outline: color-mix(in srgb, var(--md-outline-var) 46%, transparent); + --setting-profile-toolbar-height: 58px; +} + +.page--setting.setting-profile-active { + --page-gutter-block: 0px; } .page--setting .setting.is-restored-live .val { @@ -14,6 +23,16 @@ color: color-mix(in srgb, #a9e8b2 82%, var(--md-on-surface)); } +.page--setting #items > .setting.ui-stagger-item, +.page--setting #items > .setting-profile-section.ui-stagger-item, +.page--setting #deviceItems > .setting.ui-stagger-item { + animation-name: setting-item-stagger-in; + animation-duration: 0.32s; + animation-timing-function: cubic-bezier(.2, 0, 0, 1); + animation-fill-mode: both; + animation-delay: min(calc(var(--i, 0) * 34ms), 360ms); +} + .setting-car-entry { display: flex; align-items: center; @@ -129,6 +148,114 @@ -webkit-backdrop-filter: blur(6px) saturate(108%); } +.page-fab-layer--setting .setting-fab-menu { + --fab-size: 68px; + position: fixed; + right: max(var(--sp-lg), env(safe-area-inset-right, 0px)); + bottom: calc(var(--nav-bar-height) + env(safe-area-inset-bottom, 0px) + 10px + var(--sp-lg)); + z-index: 130; + display: flex; + flex-direction: column; + align-items: flex-end; + pointer-events: auto; +} + +.setting-fab-actions { + position: absolute; + right: 0; + bottom: calc(var(--fab-size) + 10px); + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 10px; + opacity: 0; + transform: translateY(8px) scale(0.98); + transform-origin: right bottom; + transition: opacity 0.16s ease, transform 0.16s ease; + pointer-events: none; +} + +.setting-fab-actions[hidden] { + display: none !important; +} + +.setting-fab-menu.is-open .setting-fab-actions { + opacity: 1; + transform: translateY(0) scale(1); + pointer-events: auto; + visibility: visible; +} + +.setting-fab-action { + min-width: 152px; + min-height: 44px; + display: inline-grid; + grid-template-columns: 20px minmax(0, 1fr); + align-items: center; + gap: 10px; + padding: 0 16px; + border: 1px solid color-mix(in srgb, var(--md-outline-var) 84%, transparent); + border-radius: 999px; + background: color-mix(in srgb, var(--md-surface-cont) 92%, #000); + color: var(--md-on-surface); + font-size: var(--fs-body-sm); + font-weight: 800; + line-height: 1; + text-align: left; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.26); + cursor: pointer; + -webkit-tap-highlight-color: transparent; +} + +.setting-fab-action svg { + width: 20px; + height: 20px; + color: var(--md-primary); +} + +.setting-fab-action span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.setting-fab-action:active { + background: color-mix(in srgb, var(--md-primary) 12%, var(--md-surface-cont)); +} + +.setting-fab-action:disabled, +.setting-fab-action[aria-disabled="true"] { + color: color-mix(in srgb, var(--md-on-surface-var) 64%, transparent); + cursor: default; + opacity: 0.72; +} + +.setting-fab-action:disabled svg, +.setting-fab-action[aria-disabled="true"] svg { + color: color-mix(in srgb, var(--md-on-surface-var) 72%, transparent); +} + +.fab--setting-menu { + background: #ffab66; + border-color: #ffab66; + color: #111; + box-shadow: 0 12px 28px rgba(255, 135, 58, 0.22); +} + +.fab--setting-menu.active, +.fab--setting-menu:active { + background: #ff9c4a; + border-color: #ff9c4a; + color: #111; + box-shadow: 0 12px 30px rgba(255, 135, 58, 0.3); +} + +.fab--setting-menu svg { + width: 26px; + height: 26px; +} + .setting-search-panel { --setting-search-form-width: clamp(320px, 42vw, 520px); --setting-search-results-width: min(72vw, 760px); @@ -230,6 +357,35 @@ display: none; } +.setting-search-section { + display: grid; + gap: 0; + padding: 0 0 2px; +} + +.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; + font-weight: 900; + box-shadow: 0 1px 0 color-mix(in srgb, var(--md-outline-var) 18%, transparent); +} + +.setting-search-section__title strong { + color: var(--md-primary); + font-size: 12px; + font-weight: 900; +} + .page--setting #groupList, .page--setting #deviceGroupList { display: flex; @@ -258,6 +414,409 @@ color: color-mix(in srgb, #bae6fd 82%, var(--md-on-surface)); } +.page--setting #groupList .groupBtn--profile { + border-color: var(--setting-profile-outline); + background: var(--md-surface-cont-h); + color: var(--md-on-surface); +} + +.page--setting #groupList .groupBtn--profile.active { + border-color: color-mix(in srgb, var(--md-primary) 48%, var(--md-outline-var)); + background: color-mix(in srgb, var(--md-primary) 14%, var(--md-surface-cont-h)); + color: var(--md-primary); +} + +.setting-profile-divider { + min-width: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + align-items: center; + gap: 10px; + padding: 10px 2px 4px; + color: var(--md-on-surface-var); + font-size: 12px; + font-weight: 900; + letter-spacing: 0; +} + +.setting-profile-divider span { + height: 1px; + background: color-mix(in srgb, var(--md-outline-var) 46%, transparent); +} + +.setting-profile-divider strong { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.setting-profile-section { + --setting-profile-accent: var(--md-primary, #ffab66); + --setting-profile-surface: color-mix(in srgb, var(--md-surface) 96%, #000); + --setting-profile-divider: color-mix(in srgb, var(--setting-profile-accent) 34%, var(--md-outline-var)); + min-width: 0; + display: grid; + border-top: 0; + scroll-margin-top: calc(var(--setting-profile-section-sticky-top, 64px) + 8px); +} + +.setting-profile-section + .setting-profile-section { + border-top: 1px solid color-mix(in srgb, var(--md-outline-var) 42%, transparent); +} + +.setting-profile-section__header { + position: sticky; + top: var(--setting-profile-section-sticky-top, 60px); + z-index: 4; + min-width: 0; + min-height: 46px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto 18px; + align-items: center; + gap: 10px; + padding: 11px 0 10px; + border: 0; + border-bottom: 1px solid color-mix(in srgb, var(--md-outline-var) 42%, transparent); + background: var(--setting-profile-surface); + box-shadow: 0 1px 0 color-mix(in srgb, var(--md-outline-var) 18%, transparent); + color: var(--md-on-surface); + font: inherit; + font-size: 13px; + font-weight: 900; + letter-spacing: 0; + text-align: left; + cursor: pointer; + transition: background 0.14s ease, color 0.14s ease, border-color 0.14s ease; +} + +.setting-profile-section__header:hover, +.setting-profile-section__header:focus-visible { + background: color-mix(in srgb, var(--md-primary) 10%, var(--setting-profile-surface)); + color: var(--setting-profile-accent); + outline: none; +} + +.setting-profile-section__label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.setting-profile-section__count { + min-width: 28px; + padding: 2px 0; + color: var(--setting-profile-accent); + font-size: 12px; + font-weight: 900; + text-align: right; +} + +.setting-profile-section__chevron { + width: 18px; + height: 18px; + color: color-mix(in srgb, var(--setting-profile-accent) 84%, var(--md-on-surface)); + fill: none; + stroke: currentColor; + stroke-width: 2.4; + stroke-linecap: round; + stroke-linejoin: round; + transition: transform 0.16s ease; +} + +.setting-profile-section.is-collapsed .setting-profile-section__chevron { + transform: rotate(-90deg); +} + +.setting-profile-section__body { + min-width: 0; + display: grid; + grid-template-rows: 1fr; + overflow: hidden; + opacity: 1; + transform: translateY(0); + transition: + grid-template-rows 0.24s cubic-bezier(.2, 0, 0, 1), + opacity 0.16s ease, + transform 0.2s cubic-bezier(.2, 0, 0, 1); +} + +.setting-profile-section.is-collapsed .setting-profile-section__body { + grid-template-rows: 0fr; + opacity: 0; + pointer-events: none; + transform: translateY(-4px); +} + +.setting-profile-section__bodyInner { + min-height: 0; + overflow: hidden; +} + +.setting-subnav__tab--profile { + border-color: color-mix(in srgb, var(--md-primary) 34%, var(--md-outline-var)); +} + +.setting-profile-panel { + --setting-profile-accent: var(--md-primary, #ffab66); + --setting-profile-ink: var(--md-on-surface); + --setting-profile-surface: color-mix(in srgb, var(--md-surface-cont-h) 74%, var(--md-surface)); + --setting-profile-divider: color-mix(in srgb, var(--md-outline-var) 50%, transparent); + --setting-profile-section-sticky-top: var(--setting-profile-toolbar-height); + position: sticky; + top: var(--setting-profile-panel-sticky-top, 0px); + z-index: 5; + min-width: 0; + box-sizing: border-box; + height: var(--setting-profile-toolbar-height); + min-height: var(--setting-profile-toolbar-height); + margin-bottom: 0; + padding: 7px 0 7px 14px; + border: 1px solid var(--setting-profile-divider); + border-left: 0; + border-right: 0; + border-bottom: 1px solid var(--setting-profile-divider); + background: var(--setting-profile-surface); + color: var(--setting-profile-ink); + box-shadow: + 0 8px 18px rgba(0, 0, 0, 0.22), + inset 0 -1px 0 rgba(255, 255, 255, 0.03); + animation: setting-profile-toolbar-enter 0.2s cubic-bezier(.2, 0, 0, 1) both; +} + +.setting-profile-panel::before { + display: none; +} + +.setting-profile-panel__titleRow { + position: relative; + z-index: 1; + min-width: 0; + display: grid; + grid-template-columns: minmax(104px, 1fr) auto; + gap: 8px; + align-items: center; +} + +.page--setting #settingScreenItems.setting-screen-items--profile > .row-between.mb-sm { + display: none; +} + +.setting-profile-panel__name { + min-width: 0; + height: 42px; + padding: 0 2px 1px 0; + border: 0; + border-bottom: 1px solid color-mix(in srgb, var(--md-outline-var) 58%, transparent); + border-radius: 0; + background: transparent; + color: var(--setting-profile-ink); + font: inherit; + font-weight: 850; +} + +.setting-profile-panel__name:focus { + border-bottom-color: color-mix(in srgb, var(--setting-profile-accent) 76%, var(--md-outline-var)); + outline: none; +} + +.setting-profile-panel__name.is-saving { + color: color-mix(in srgb, var(--md-on-surface) 72%, var(--setting-profile-accent)); +} + +.setting-profile-panel__meta { + display: grid; + gap: 6px; +} + +.setting-profile-panel__metaRow { + min-width: 0; + display: grid; + grid-template-columns: 74px minmax(0, 1fr); + gap: 8px; + align-items: baseline; + color: var(--md-on-surface-var); + font-size: 12px; + line-height: 1.35; +} + +.setting-profile-panel__metaRow span { + font-weight: 800; +} + +.setting-profile-panel__metaRow strong { + min-width: 0; + overflow-wrap: anywhere; + color: var(--md-on-surface); + font-weight: 850; +} + +.setting-profile-panel__metaRow a { + color: var(--md-primary); + text-decoration: none; +} + +.setting-profile-action-menu { + justify-self: end; +} + +.setting-profile-action-menu__button { + width: 42px; + height: 42px; + min-width: 42px; + min-height: 42px; + border-color: transparent; + background: transparent; + color: var(--md-on-surface-var); +} + +.setting-profile-action-menu__button:hover, +.setting-profile-action-menu__button:focus-visible, +.setting-profile-action-menu.is-open .setting-profile-action-menu__button { + border-color: transparent; + background: color-mix(in srgb, var(--md-primary) 10%, transparent); + color: var(--md-on-surface); +} + +.setting-profile-action-menu__button svg { + width: 22px; + height: 22px; +} + +.setting-profile-action-menu__panel { + position: absolute; + top: calc(100% + 8px); + right: 0; + z-index: 8; +} + +.setting-profile-action-menu__item { + display: flex; +} + +.setting-profile-action-menu__item--primary { + color: var(--md-primary); +} + +.setting-profile-action-menu__item--danger { + color: var(--md-error); +} + +.setting-toolbar-action { + min-width: 56px; + min-height: 42px; + padding: 0 14px; + border: 1px solid color-mix(in srgb, var(--md-outline-var) 46%, transparent); + border-radius: 8px; + background: var(--md-surface-cont); + color: var(--md-on-surface); + cursor: pointer; + font: inherit; + font-size: 13px; + font-weight: 850; + line-height: 1; + transition: background 0.14s ease, border-color 0.14s ease, color 0.14s ease, transform 0.12s ease; +} + +.setting-toolbar-action:hover, +.setting-toolbar-action:focus-visible { + 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; +} + +.setting-toolbar-action:active { + transform: translateY(1px); +} + +.setting-toolbar-action--primary { + border-color: color-mix(in srgb, var(--md-primary) 76%, var(--md-outline-var)); + background: var(--md-primary); + color: var(--md-on-primary); +} + +.setting-toolbar-action--danger { + color: var(--md-error); +} + +@keyframes setting-profile-toolbar-enter { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes setting-item-stagger-in { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (prefers-reduced-motion: reduce) { + .setting-profile-panel, + .page--setting #items > .setting.ui-stagger-item, + .page--setting #items > .setting-profile-section.ui-stagger-item, + .page--setting #deviceItems > .setting.ui-stagger-item { + animation: none; + } + + .setting-profile-section__header, + .setting-profile-section__body, + .setting-profile-section__chevron, + .setting-toolbar-action { + transition: none; + } +} + +.app-dialog--settings-diff .app-dialog__sheet { + width: min(calc(100vw - 24px), 520px); + max-height: min(calc(100dvh - 24px), 680px); + overflow: hidden; +} + +.app-dialog--settings-diff .app-dialog__body { + min-height: 0; + overflow: auto; + white-space: normal; +} + +.setting-profile-apply { + min-width: 0; + display: grid; + gap: 10px; +} + +.setting-profile-apply__title { + min-width: 0; + overflow: hidden; + color: var(--md-on-surface); + font-size: 14px; + font-weight: 900; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.setting-profile-info { + display: grid; + gap: 7px; + min-width: 0; + white-space: normal; +} + +.setting-profile-info--empty { + color: var(--md-on-surface-var); + font-weight: 800; +} + .page--setting #carrotTabContent, .page--setting #deviceTabContent, .page--setting #items, @@ -454,6 +1013,49 @@ padding-top: calc(var(--setting-fixed-subnav-height, 174px) + 10px); } + .page--setting #settingScreenItems.setting-screen-items--profile { + padding-top: 0; + --setting-profile-panel-sticky-top: 0px; + --setting-profile-section-sticky-top: var(--setting-profile-toolbar-height); + } + + .page--setting #settingScreenItems.setting-screen-items--profile #items { + padding-top: 0; + } + + .page--setting #settingScreenItems.setting-screen-items--profile .setting-profile-panel + .setting-profile-section { + margin-top: 0; + } + + .page--setting #settingScreenItems.setting-screen-items--profile .setting-profile-panel { + margin-left: calc(-1 * var(--sp-lg)); + margin-right: calc(-1 * var(--sp-lg)); + padding-left: calc(var(--sp-lg) + 14px); + padding-right: var(--sp-lg); + } + + .page--setting #settingScreenItems.setting-screen-items--profile .setting-profile-panel::before { + left: var(--sp-lg); + } + + .page--setting #settingScreenItems.setting-screen-items--profile .setting-profile-panel__titleRow { + grid-template-columns: minmax(0, 1fr) auto; + gap: 7px; + } + + .page--setting #settingScreenItems.setting-screen-items--profile .setting-toolbar-action { + min-width: 50px; + padding-inline: 10px; + font-size: 12.5px; + } + + .page--setting #settingScreenItems.setting-screen-items--profile .setting-profile-section__header { + margin-left: calc(-1 * var(--sp-lg)); + margin-right: calc(-1 * var(--sp-lg)); + padding-left: var(--sp-lg); + padding-right: var(--sp-lg); + } + .page--setting #settingScreenHost, .page--setting #settingScreenItems:not(.hidden) { overflow: visible; @@ -562,6 +1164,12 @@ var(--setting-landscape-page-inset); } + .page.page--setting > #settingScreenHost > #settingScreenItems.setting-screen-items--profile { + padding-top: 0; + --setting-profile-panel-sticky-top: 0px; + --setting-profile-section-sticky-top: var(--setting-profile-toolbar-height); + } + .page--setting #settingCarRow { display: flex; overflow: hidden; @@ -838,7 +1446,6 @@ } :lang(ko) .page--setting #settingScreenItems .setting-favorites-empty__desc, -:lang(ja) .page--setting #settingScreenItems .setting-favorites-empty__desc, :lang(zh) .page--setting #settingScreenItems .setting-favorites-empty__desc { word-break: keep-all; overflow-wrap: break-word; diff --git a/selfdrive/carrot/web/css/pages/tools.css b/selfdrive/carrot/web/css/pages/tools.css index be82bd460e..c3de27841b 100644 --- a/selfdrive/carrot/web/css/pages/tools.css +++ b/selfdrive/carrot/web/css/pages/tools.css @@ -135,36 +135,15 @@ } .tools-lang-menu { - position: relative; - display: inline-flex; - align-items: center; - justify-content: flex-end; height: 100%; } .tools-lang-menu__button { - appearance: none; - -webkit-appearance: none; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 6px; height: 26px; min-height: 26px; padding: 2px 7px; - border: 0; - border-radius: 6px; - background: transparent; - color: var(--md-on-surface); - box-shadow: none; font-size: 13px; - line-height: 1; white-space: nowrap; - cursor: pointer; -} - -.tools-lang-menu__button:hover { - background: color-mix(in srgb, var(--md-on-surface) 6%, transparent); } .tools-lang-menu__globe { @@ -197,24 +176,10 @@ width: min(148px, calc(100vw - 24px)); max-height: calc(100dvh - var(--tools-lang-menu-panel-top, 48px) - 12px); overflow: auto; - padding: 8px 10px 6px; - border: 1px solid var(--md-outline-var); - border-radius: 6px; - background: color-mix(in srgb, var(--md-surface) 96%, var(--md-surface-cont-h)); - box-shadow: 0 10px 24px rgba(0, 0, 0, 0.22); } .tools-lang-menu__panel::before { - content: ""; - position: absolute; - top: -7px; - right: 46px; - width: 12px; - height: 12px; - border-left: 1px solid var(--md-outline-var); - border-top: 1px solid var(--md-outline-var); - background: inherit; - transform: rotate(45deg); + content: none; } .tools-lang-menu__current { @@ -227,35 +192,11 @@ .tools-lang-menu__divider { height: 1px; margin-bottom: 4px; - background: var(--md-outline-var); - opacity: 0.65; + background: color-mix(in srgb, var(--md-outline-var) 42%, transparent); } .tools-lang-menu__item { - appearance: none; - -webkit-appearance: none; - width: 100%; - min-height: 38px; - padding: 6px 10px; margin-bottom: 2px; - border: 0; - border-radius: 6px; - background: transparent; - color: var(--md-on-surface); - display: flex; - align-items: center; - justify-content: flex-start; - gap: 8px; - font-size: var(--fs-body-md); - text-align: left; - cursor: pointer; - transition: background 0.14s ease, color 0.14s ease; -} - -.tools-lang-menu__item:hover, -.tools-lang-menu__item:focus-visible { - background: color-mix(in srgb, var(--md-on-surface) 6%, transparent); - outline: none; } .tools-lang-menu__item[aria-checked="true"] { diff --git a/selfdrive/carrot/web/css/responsive.css b/selfdrive/carrot/web/css/responsive.css index 89ec7c690d..ec27c9ee86 100644 --- a/selfdrive/carrot/web/css/responsive.css +++ b/selfdrive/carrot/web/css/responsive.css @@ -45,6 +45,11 @@ top: calc(var(--app-vv-height, 100dvh) - var(--nav-bar-height-desktop) - env(safe-area-inset-bottom, 0px) - var(--fab-size) - 12px - var(--sp-lg)); } + .page-fab-layer--setting .setting-fab-menu { + --fab-size: 68px; + bottom: calc(var(--nav-bar-height-desktop) + env(safe-area-inset-bottom, 0px) + 12px + var(--sp-lg)); + } + } /* ── Mobile tweaks ────────────────────────────────────────── */ @@ -60,6 +65,10 @@ font-size: 11px; } + .page-fab-layer--setting .setting-fab-menu { + --fab-size: 62px; + } + } @media (orientation: landscape) { @@ -138,4 +147,24 @@ top: calc(var(--app-vv-height, 100dvh) - env(safe-area-inset-bottom, 0px) - var(--fab-size) - 18px); } + .page-fab-layer--setting .setting-fab-menu { + --fab-size: 56px; + right: auto; + left: calc( + var(--nav-rail-width) + + var(--setting-landscape-page-inset, 24px) + + var(--setting-landscape-left-width, 260px) - + var(--fab-size) - + 18px + ); + bottom: calc(var(--setting-landscape-page-inset, 24px) + env(safe-area-inset-bottom, 0px)); + } + + .setting-fab-actions { + right: 0; + left: auto; + align-items: flex-end; + transform-origin: right bottom; + } + } diff --git a/selfdrive/carrot/web/index.html b/selfdrive/carrot/web/index.html index 066ae911fd..ea72565d79 100644 --- a/selfdrive/carrot/web/index.html +++ b/selfdrive/carrot/web/index.html @@ -72,15 +72,15 @@ - + - + - - + + - + @@ -119,12 +119,34 @@

Mode