From 0648ca14d6b04857cd308aea29d8755cb599be15 Mon Sep 17 00:00:00 2001 From: jominki354 Date: Wed, 24 Jun 2026 09:31:44 +0900 Subject: [PATCH] web f --- selfdrive/carrot/web/css/components.css | 2 +- .../carrot/web/css/pages/settings/base.css | 57 +- .../carrot/web/css/pages/settings/device.css | 105 ++-- .../carrot/web/css/pages/settings/panels.css | 218 ++++---- selfdrive/carrot/web/css/pages/tools/base.css | 22 - selfdrive/carrot/web/index.html | 17 +- selfdrive/carrot/web/js/app.js | 22 +- selfdrive/carrot/web/js/pages/setting.js | 524 ++++++++++++------ selfdrive/carrot/web/js/pages/tools.js | 6 - .../carrot/web/js/shared/ui/navigation.js | 198 ++++++- selfdrive/carrot/web/js/translations/en.js | 3 +- selfdrive/carrot/web/js/translations/ko.js | 3 +- selfdrive/carrot/web/js/translations/zh.js | 3 +- 13 files changed, 763 insertions(+), 417 deletions(-) diff --git a/selfdrive/carrot/web/css/components.css b/selfdrive/carrot/web/css/components.css index 0cd99e50b2d..4794fb9f7a8 100644 --- a/selfdrive/carrot/web/css/components.css +++ b/selfdrive/carrot/web/css/components.css @@ -991,11 +991,11 @@ body[data-page="terminal"] .app-toast-host { background: color-mix(in srgb, var(--md-error-cont) 18%, var(--md-surface-cont-h)); } +/* ── Setting Item ─────────────────────────────────────────── */ .ui-action-grid > .git-pull-btn { justify-content: space-between; } -/* ── Setting Item ─────────────────────────────────────────── */ .setting { padding: var(--sp-lg) 0; margin: 0; diff --git a/selfdrive/carrot/web/css/pages/settings/base.css b/selfdrive/carrot/web/css/pages/settings/base.css index fc24b6a6db1..76cba158ad5 100644 --- a/selfdrive/carrot/web/css/pages/settings/base.css +++ b/selfdrive/carrot/web/css/pages/settings/base.css @@ -13,8 +13,27 @@ --setting-profile-toolbar-height: 58px; } -.page--setting.setting-profile-active { - --page-gutter-block: 0px; +.page--setting #settingScreenHost.setting-screen-transitioning { + overflow: hidden !important; +} + +.page--setting #settingScreenHost.setting-screen-transitioning > .page-transitioning { + overflow: hidden; + background: var(--md-surface); + pointer-events: none; + contain: paint; +} + +.page--setting.setting-screen-transitioning #carrotTabContent, +.page--setting.setting-screen-transitioning #deviceTabContent, +.page--setting.setting-screen-transitioning #items, +.page--setting.setting-screen-transitioning #deviceItems { + animation: none !important; +} + +.page--setting .setting-screen-snapshot, +.page--setting .setting-screen-snapshot * { + animation: none !important; } .page--setting .setting.is-restored-live .val { @@ -160,6 +179,16 @@ flex-direction: column; 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); +} + +/* 전환 중 FAB 를 숨기지 않는다 — FAB 는 position:fixed 라 슬라이드와 무관하고, + 끝에 다시 나타나며(opacity/scale) 한 번 깜박이던 원인이었다. 클릭만 잠시 막는다. */ +.page--setting.setting-screen-transitioning .setting-fab-menu { + pointer-events: none; } .setting-fab-actions { @@ -294,14 +323,17 @@ } .setting-fab-menu.is-open .fab--setting-menu { - animation: setting-fab-pop 0.28s cubic-bezier(0.2, 0, 0, 1); box-shadow: 0 16px 34px rgba(255, 135, 58, 0.34); } +/* 터치/클릭 시 가벼운 바운스 (JS 가 is-bouncing 을 토글) */ +.fab--setting-menu.is-bouncing { + animation: setting-fab-bounce 0.3s cubic-bezier(0.2, 0, 0, 1); +} + .fab--setting-menu svg { width: 26px; height: 26px; - transition: transform 0.26s cubic-bezier(0.2, 0, 0, 1); } .fab--setting-menu .setting-fab-menu-icon { @@ -317,16 +349,15 @@ stroke-linejoin: round; } -.fab--setting-menu[aria-expanded="true"] svg { - transform: rotate(90deg) scale(0.92); -} - -@keyframes setting-fab-pop { +@keyframes setting-fab-bounce { 0% { - transform: scale(0.92); + transform: scale(1); + } + 30% { + transform: scale(0.93); } - 58% { - transform: scale(1.04); + 62% { + transform: scale(1.03); } 100% { transform: scale(1); @@ -339,7 +370,7 @@ .fab--setting-menu svg { transition: none !important; } - .setting-fab-menu.is-open .fab--setting-menu { + .fab--setting-menu.is-bouncing { animation: none !important; } .setting-fab-menu.is-open .setting-fab-action { diff --git a/selfdrive/carrot/web/css/pages/settings/device.css b/selfdrive/carrot/web/css/pages/settings/device.css index 59d9201c56a..0a1a7c2d2f9 100644 --- a/selfdrive/carrot/web/css/pages/settings/device.css +++ b/selfdrive/carrot/web/css/pages/settings/device.css @@ -27,35 +27,10 @@ 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, -.page--setting #deviceItems { - animation: setting-content-enter 0.16s ease both; -} - -@keyframes setting-content-enter { - from { - opacity: 0; - transform: translateY(4px); - } - to { - opacity: 1; - transform: translateY(0); - } -} +/* setting-content-enter(opacity 0→1 + translateY 4px→0) 제거 — + 단계 전환이 끝나며(전환 중 `animation:none` 억제가 풀릴 때) 이 애니메이션이 재생돼 + 콘텐츠가 한 번 페이드(깜박)+4px 세로 점프(하단 위아래 흔들)하던 직접 원인이었다. + 좌우 슬라이드가 이미 화면 진입을 표현하므로 별도 콘텐츠 진입 애니메이션은 불필요하다. */ .setting-search-result { width: 100%; @@ -236,51 +211,67 @@ .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); + --setting-profile-section-sticky-top: 72px; } .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-row { + min-height: 46px; + gap: var(--sp-sm); } - .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-action__icon { + width: 20px; + height: 20px; } - .page--setting #settingScreenItems.setting-screen-items--profile .setting-profile-panel::before { - left: var(--sp-lg); + .page--setting #settingScreenItems.setting-screen-items--profile .setting-profile-section__header { + margin-left: 0; + margin-right: 0; + padding-left: 2px; + padding-right: 2px; } - .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 > .row-between.mb-sm { + display: grid; } - .page--setting #settingScreenItems.setting-screen-items--profile .setting-toolbar-action { - min-width: 50px; - padding-inline: 10px; - font-size: 12.5px; + /* ── 세로(phone): 설정을 뷰포트 고정 높이로, 두 화면을 absolute inset:0 로 '항상' 겹쳐 + 각자 내부 스크롤시킨다 → 단계 전환이 순수 transform(레이아웃 변화 0)이라 끝에 + 흔들림/깜박임이 구조적으로 생기지 않는다. (가로 split 모드와 동일 원리) ── */ + .page.page--setting { + display: flex; + flex-direction: column; + height: calc(var(--app-vv-height, 100dvh) - var(--nav-bar-height) - env(safe-area-inset-bottom, 0px)); + min-block-size: 0; + padding-bottom: 0; + overflow: hidden; } - .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.page--setting > #settingsMeta { + flex: 0 0 auto; } - .page--setting #settingScreenHost, - .page--setting #settingScreenItems:not(.hidden) { - overflow: visible; - transform: none !important; - will-change: auto !important; + .page.page--setting > #settingScreenHost { + flex: 1 1 auto; + position: relative; + min-height: 0; + overflow: hidden; + } + + .page.page--setting > #settingScreenHost > #settingScreenGroups, + .page.page--setting > #settingScreenHost > #settingScreenItems { + position: absolute; + inset: 0; + overflow-x: hidden; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + scrollbar-gutter: stable; + padding-bottom: calc(var(--nav-bar-height) + 24px + env(safe-area-inset-bottom, 0px)); + will-change: transform; } .page--setting #settingSubnavWrap { @@ -376,7 +367,7 @@ .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); + --setting-profile-section-sticky-top: 68px; } .page--setting #settingCarRow { diff --git a/selfdrive/carrot/web/css/pages/settings/panels.css b/selfdrive/carrot/web/css/pages/settings/panels.css index 7fcc29fe5e5..e88e5c781e0 100644 --- a/selfdrive/carrot/web/css/pages/settings/panels.css +++ b/selfdrive/carrot/web/css/pages/settings/panels.css @@ -196,36 +196,33 @@ .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); + border-top: 0; } .setting-profile-section__header { - position: sticky; - top: var(--setting-profile-section-sticky-top, 60px); - z-index: 4; + position: static; min-width: 0; - min-height: 46px; + width: 100%; + box-sizing: border-box; + min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr) auto 18px; align-items: center; - gap: 10px; - padding: 11px 0 10px; + gap: 8px; + padding: 0 2px; 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); + background: transparent; + box-shadow: none; + color: var(--setting-profile-accent); font: inherit; - font-size: 13px; + font-size: var(--fs-title-sm); font-weight: 900; letter-spacing: 0; text-align: left; @@ -235,8 +232,8 @@ .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); + background: transparent; + color: color-mix(in srgb, var(--setting-profile-accent) 82%, var(--md-on-surface)); outline: none; } @@ -248,7 +245,7 @@ } .setting-profile-section__count { - min-width: 28px; + min-width: 0; padding: 2px 0; color: var(--setting-profile-accent); font-size: 12px; @@ -310,148 +307,161 @@ .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; + --setting-profile-section-sticky-top: 68px; 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; +/* ── 프로필 관리 카드 (Material 3 / One UI) ── + .setting-group-card 위에 얹는다. 세로 패딩은 헤더/행이 직접 갖는다 — + .setting-group-card 의 `padding: 0 var(--sp-lg)` 가 소스 순서상 뒤에 와서 + 단축속성으로 상하 패딩을 0 으로 덮어쓰므로 여기서 세로값을 주지 않는다. */ +.setting-profile-manage-card { + min-width: 0; + display: grid; + gap: 0; } -.setting-profile-panel__titleRow { - position: relative; - z-index: 1; +/* ── 라벨/값 행 — 설정 리스트의 .setting 행과 동일한 hairline 리듬 ── */ +.setting-profile-card__rows { 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-card__rows > .setting-profile-row:first-child { + border-top: none; + padding-top: var(--sp-md); } -.setting-profile-panel__name { +.setting-profile-row { 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; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-md); + min-height: 52px; + padding: var(--sp-sm) 0; + border-top: 1px solid color-mix(in srgb, var(--md-stroke-soft) 32%, transparent); } -.setting-profile-panel__name.is-saving { - color: color-mix(in srgb, var(--md-on-surface) 72%, var(--setting-profile-accent)); +.setting-profile-row:last-child { + padding-bottom: var(--sp-md); } -.setting-profile-panel__meta { - display: grid; - gap: 6px; +.setting-profile-row__label { + flex: 0 0 auto; + color: var(--md-on-surface-var); + font-size: var(--fs-label-md); + font-weight: 800; + line-height: 1.2; } -.setting-profile-panel__metaRow { +.setting-profile-row__value { 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; + flex: 1 1 auto; + overflow: hidden; + color: var(--md-on-surface); + font-size: var(--fs-body-sm); + font-weight: 700; + line-height: 1.3; + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; } -.setting-profile-panel__metaRow span { +.setting-profile-row__value a { + color: var(--md-primary); font-weight: 800; + text-decoration: none; } -.setting-profile-panel__metaRow strong { +.setting-profile-row--name .setting-profile-row__label { + color: var(--md-primary); + font-size: var(--fs-title-sm); + font-weight: 900; +} + +/* ── 이름 편집 — Material filled text field ── */ +.setting-profile-name-input { + flex: 1 1 auto; min-width: 0; - overflow-wrap: anywhere; + height: 40px; + box-sizing: border-box; + padding: 0 var(--sp-md); + border: 1px solid color-mix(in srgb, var(--md-outline-var) 30%, transparent); + border-radius: var(--r-sm); + background: color-mix(in srgb, var(--md-surface-cont-h) 60%, transparent); color: var(--md-on-surface); - font-weight: 850; + font: inherit; + font-size: var(--fs-body-sm); + font-weight: 800; + text-align: right; + outline: none; + transition: border-color 0.14s ease, background 0.14s ease, color 0.14s ease; } -.setting-profile-panel__metaRow a { - color: var(--md-primary); - text-decoration: none; +.setting-profile-name-input:focus { + border-color: color-mix(in srgb, var(--md-primary) 58%, var(--md-outline-var)); + background: color-mix(in srgb, var(--md-surface-cont-h) 82%, var(--md-primary)); } -.setting-profile-action-menu { - justify-self: end; +.setting-profile-name-input.is-saving { + color: color-mix(in srgb, var(--md-on-surface) 72%, var(--md-primary)); } -.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-menu { + flex: 0 0 auto; } -.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-menu__button { + width: 40px; + min-width: 40px; + min-height: 40px; + border-color: color-mix(in srgb, var(--md-outline-var) 24%, transparent); + background: color-mix(in srgb, var(--md-surface-cont-h) 48%, transparent); } -.setting-profile-action-menu__button svg { +.setting-profile-menu__button svg { width: 22px; height: 22px; } -.setting-profile-action-menu__panel { +.setting-profile-menu__panel { position: absolute; top: calc(100% + 8px); right: 0; - z-index: 8; + z-index: 9; + min-width: 168px; } -.setting-profile-action-menu__item { - display: flex; +.setting-profile-menu__item .setting-profile-action__icon { + width: 18px; + height: 18px; } -.setting-profile-action-menu__item--primary { +.setting-profile-menu__item--primary, +.setting-profile-menu__item--primary .setting-profile-action__icon { color: var(--md-primary); } -.setting-profile-action-menu__item--danger { +.setting-profile-menu__item--danger, +.setting-profile-menu__item--danger .setting-profile-action__icon { color: var(--md-error); } +.setting-profile-action__icon { + width: 22px; + height: 22px; + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 2; +} + .setting-toolbar-action { min-width: 56px; min-height: 42px; diff --git a/selfdrive/carrot/web/css/pages/tools/base.css b/selfdrive/carrot/web/css/pages/tools/base.css index 5ff831ba515..06a025e81a9 100644 --- a/selfdrive/carrot/web/css/pages/tools/base.css +++ b/selfdrive/carrot/web/css/pages/tools/base.css @@ -82,28 +82,6 @@ mask-image: none; } -.tools-meta__unreadBadge { - display: inline-flex; - align-items: center; - justify-content: center; - flex: 0 0 auto; - min-width: 18px; - height: 18px; - padding: 0 5px; - border: 1px solid color-mix(in srgb, #ff8a8a 48%, transparent); - border-radius: 999px; - background: color-mix(in srgb, #ff3b30 82%, #3a0608); - color: #fff; - box-shadow: - 0 0 0 1px color-mix(in srgb, #fff 10%, transparent) inset, - 0 0 12px color-mix(in srgb, #ff5252 30%, transparent); - font-family: inherit; - font-size: 11px; - font-weight: 900; - line-height: 1; - letter-spacing: 0; -} - .tools-meta__status:focus-visible { outline: 2px solid color-mix(in srgb, var(--md-primary) 72%, transparent); outline-offset: 2px; diff --git a/selfdrive/carrot/web/index.html b/selfdrive/carrot/web/index.html index 535b6f152e7..e30cb95c610 100644 --- a/selfdrive/carrot/web/index.html +++ b/selfdrive/carrot/web/index.html @@ -78,9 +78,9 @@ - - - + + + @@ -149,10 +149,7 @@

Mode @@ -698,9 +695,9 @@

Home

- + - + @@ -725,6 +722,6 @@

Home

- + diff --git a/selfdrive/carrot/web/js/app.js b/selfdrive/carrot/web/js/app.js index 72ea86689f0..19dc1fac266 100644 --- a/selfdrive/carrot/web/js/app.js +++ b/selfdrive/carrot/web/js/app.js @@ -21,6 +21,8 @@ window.addEventListener("popstate", async (ev) => { if (st.page === "setting") { const screen = st.screen || "groups"; + const previousDetail = CURRENT_SETTING_DETAIL; + const itemsWereActive = typeof isSettingItemsScreenActive === "function" && isSettingItemsScreenActive(); CURRENT_GROUP = st.group || null; showPage("setting", false); @@ -48,16 +50,26 @@ window.addEventListener("popstate", async (ev) => { } if (screen === "detail" && CURRENT_GROUP && st.settingName) { - showSettingScreen("items", false); - renderItems(CURRENT_GROUP, { + await transitionSettingItemsContent(() => renderItems(CURRENT_GROUP, { detailName: st.settingName, scrollMode: "restore", animateItems: false, - }); + }), previousDetail ? "backward" : "forward"); } else if (screen === "items" && CURRENT_GROUP) { - showSettingScreen("items", false); - renderItems(CURRENT_GROUP, { scrollMode: "restore", animateItems: false }); + if (itemsWereActive && previousDetail) { + await transitionSettingItemsContent( + () => renderItems(CURRENT_GROUP, { scrollMode: "restore", animateItems: false }), + "backward", + ); + } else { + await activateSettingGroup(CURRENT_GROUP, false, { + scrollMode: "restore", + animateGroups: false, + animateItems: false, + }); + } } else { + CURRENT_SETTING_DETAIL = null; showSettingScreen("groups", false); } if (st.search) { diff --git a/selfdrive/carrot/web/js/pages/setting.js b/selfdrive/carrot/web/js/pages/setting.js index 8ad65e96165..7514467dbb9 100644 --- a/selfdrive/carrot/web/js/pages/setting.js +++ b/selfdrive/carrot/web/js/pages/setting.js @@ -826,12 +826,64 @@ function getSettingControlConfig(p) { return { kind, min, max, unit, optionCount }; } +const SETTING_DISPLAY_UNIT_TYPES = Object.freeze({ + raw: "", + speedKph: "km/h", + distanceCm: "cm", + timeSec: "s", + timeMin: "min", + percent: "%", + degree: "deg", +}); + +const SETTING_PARAM_DISPLAY_TYPES = Object.freeze({ + PathOffset: "distanceCm", + CruiseOnDist: "distanceCm", + CruiseEcoControl: "speedKph", + StopDistanceCarrot: "distanceCm", + TrafficStopDistanceAdjust: "distanceCm", + AutoTurnControlSpeedTurn: "speedKph", + CruiseSpeedUnit: "speedKph", + CruiseSpeedUnitBasic: "speedKph", + CruiseSpeed1: "speedKph", + CruiseSpeed2: "speedKph", + CruiseSpeed3: "speedKph", + CruiseSpeed4: "speedKph", + CruiseSpeed5: "speedKph", + AutoGasTokSpeed: "speedKph", + AutoGasCancelSpeed: "speedKph", + MaxTimeOffroadMin: "timeMin", + AutoSpeedUptoRoadSpeedLimit: "percent", + AutoRoadSpeedAdjust: "percent", + ApplyModelSpeed: "percent", + UseLaneLineSpeed: "speedKph", + UseLaneLineCurveSpeed: "speedKph", + AdjustLaneOffset: "distanceCm", + SoundVolumeAdjust: "percent", + SoundVolumeAdjustEngage: "percent", + AutoCurveSpeedLowerLimit: "speedKph", + AutoNaviSpeedCtrlEnd: "timeSec", + AutoNaviSpeedBumpTime: "timeSec", + AutoNaviSpeedBumpSpeed: "speedKph", + AutoRoadSpeedLimitOffset: "speedKph", + AutoCurveSpeedFactor: "percent", + MapTurnSpeedFactor: "percent", + AutoNaviSpeedSafetyFactor: "percent", + RadarReactionFactor: "percent", + SteerRatioRate: "percent", + DynamicTFollowLC: "percent", + TFollowDecelBoost: "percent", + ShowCustomBrightness: "percent", + ClusterHudBrightness: "percent", +}); + +function getSettingDisplayType(name) { + const key = String(name || "").trim(); + return SETTING_PARAM_DISPLAY_TYPES[key] || "raw"; +} + function getSettingDisplayUnit(name) { - if (!name) return ""; - if (/Brightness|Volume/i.test(name)) return "%"; - if (/Speed/i.test(name)) return "km/h"; - if (/Dist|Distance|Offset/i.test(name)) return "cm"; - return ""; + return SETTING_DISPLAY_UNIT_TYPES[getSettingDisplayType(name)] || ""; } function formatSettingDisplayValue(p, value) { @@ -840,6 +892,14 @@ function formatSettingDisplayValue(p, value) { return unit ? `${text}${unit}` : text; } +function formatSettingRangeMeta(p) { + return [ + `min=${formatSettingDisplayValue(p, p?.min)}`, + `max=${formatSettingDisplayValue(p, p?.max)}`, + `default=${formatSettingDisplayValue(p, p?.default)}`, + ].join(", "); +} + const SETTING_UNIT_STORAGE_KEY = "carrot.settingUnitIndex.v1"; let settingUnitIndexStore = null; @@ -917,7 +977,7 @@ function getSettingOptionValues(config) { } function getSettingOptionLabel(name, value) { - return String(value); + return formatSettingDisplayValue({ name }, value); } function syncSettingControlState(row, value) { @@ -1072,42 +1132,6 @@ function formatSettingProfileDate(value) { } } -function settingProfileMetaRows(profile) { - const meta = profile?.meta || {}; - const rows = []; - if (profile?.created_at) { - rows.push([getUIText("setting_profile_created", "Created"), settingsDiffEscape(formatSettingProfileDate(profile.created_at))]); - } - if (meta.branch) rows.push([getUIText("branch", "Branch"), settingsDiffEscape(meta.branch)]); - if (meta.commit) { - const commitText = meta.commit_short || String(meta.commit).slice(0, 7); - const commitValue = meta.commit_url - ? `${settingsDiffEscape(commitText)}` - : settingsDiffEscape(commitText); - rows.push([getUIText("commit", "Commit"), commitValue]); - } - return rows; -} - -async function openSettingProfileInfo(profile) { - const rows = settingProfileMetaRows(profile); - const messageHtml = rows.length - ? `
${rows.map(([label, value]) => ` -
- ${settingsDiffEscape(label)} - ${value} -
- `).join("")}
` - : `
${settingsDiffEscape(getUIText("setting_profile_info_empty", "No profile metadata"))}
`; - await openAppDialog({ - mode: "alert", - title: getUIText("setting_profile_info", "Profile Info"), - html: true, - messageHtml, - confirmLabel: getUIText("ok", "OK"), - }); -} - async function saveSettingProfile(profileId, updates) { const payload = await postJson("/api/setting_profiles/update", { id: profileId, ...(updates || {}) }); updateSettingProfilesFromPayload(payload); @@ -1221,11 +1245,11 @@ async function deleteSettingProfile(profile) { } function closeSettingProfileActionMenus(exceptPanel = null) { - document.querySelectorAll(".setting-profile-action-menu.is-open").forEach((menu) => { + document.querySelectorAll(".setting-profile-menu.is-open").forEach((menu) => { if (exceptPanel && menu === exceptPanel) return; menu.classList.remove("is-open"); - const button = menu.querySelector(".setting-profile-action-menu__button"); - const panel = menu.querySelector(".setting-profile-action-menu__panel"); + const button = menu.querySelector(".setting-profile-menu__button"); + const panel = menu.querySelector(".setting-profile-menu__panel"); if (button) button.setAttribute("aria-expanded", "false"); if (panel) { panel.hidden = true; @@ -1234,38 +1258,84 @@ function closeSettingProfileActionMenus(exceptPanel = null) { }); } -function makeSettingProfileMenuItem(label, onClick, className = "") { +function settingProfileActionIcon(kind) { + const paths = { + edit: "M4 20h4l10.5-10.5a2.12 2.12 0 0 0-3-3L5 17v3m11-12 3 3", + search: "M10.5 18a7.5 7.5 0 1 1 5.3-12.8 7.5 7.5 0 0 1 0 10.6L20 20", + info: "M12 17v-6m0-4h.01M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20", + apply: "m5 12 4 4L19 6", + delete: "M6 7h12m-10 0 .7 13h6.6L16 7M10 7V4h4v3", + }; + const path = paths[kind] || paths.info; + return ` + + `; +} + +function makeSettingProfileMenuItem({ label, icon = "info", onClick, className = "" }) { const button = document.createElement("button"); button.type = "button"; - button.className = `setting-profile-action-menu__item ui-dropdown-menu__item${className ? ` ${className}` : ""}`; + button.className = `setting-profile-menu__item ui-dropdown-menu__item${className ? ` ${className}` : ""}`; button.setAttribute("role", "menuitem"); - button.textContent = label; + button.innerHTML = ` + ${settingProfileActionIcon(icon)} + ${settingsDiffEscape(label)} + `; button.onclick = (event) => { event.stopPropagation(); closeSettingProfileActionMenus(); - onClick(); + if (typeof onClick === "function") onClick(); }; return button; } +function renderSettingProfileMetaRows(profile) { + const meta = profile?.meta || {}; + const created = profile?.created_at ? settingsDiffEscape(formatSettingProfileDate(profile.created_at)) : "-"; + const branch = meta.branch ? settingsDiffEscape(meta.branch) : "-"; + let commit = "-"; + if (meta.commit) { + const commitText = meta.commit_short || String(meta.commit).slice(0, 7); + commit = meta.commit_url + ? `${settingsDiffEscape(commitText)}` + : settingsDiffEscape(commitText); + } + return [ + [getUIText("setting_profile_created", "Created"), created], + [getUIText("branch", "Branch"), branch], + [getUIText("commit", "Commit"), commit], + ]; +} + function appendSettingProfileHeader(profile, container) { + if (!container || !profile) return; const panel = document.createElement("div"); - panel.className = "setting-profile-panel"; - - const titleRow = document.createElement("div"); - titleRow.className = "setting-profile-panel__titleRow"; - const input = document.createElement("input"); - input.className = "setting-profile-panel__name"; - input.type = "text"; - input.maxLength = 40; - input.value = profile.name || ""; - input.setAttribute("aria-label", getUIText("setting_profile_name", "Profile name")); + panel.className = "setting-profile-panel setting-section-block ui-stagger-item"; + + const card = document.createElement("div"); + card.className = "setting-profile-manage-card setting-group-card"; + const valueCount = Object.keys(profile.values || {}).length; + + const nameRow = document.createElement("div"); + nameRow.className = "setting-profile-row setting-profile-row--name"; + const nameLabel = document.createElement("div"); + nameLabel.className = "setting-profile-row__label"; + nameLabel.textContent = getUIText("setting_profile_card_title", "Profile ({count})", { count: valueCount }); + const nameInput = document.createElement("input"); + nameInput.className = "setting-profile-name-input"; + nameInput.type = "text"; + nameInput.maxLength = 40; + nameInput.value = profile.name || ""; + nameInput.setAttribute("aria-label", getUIText("setting_profile_name", "Profile name")); + let nameSaveTimer = 0; let nameSaveInFlight = null; async function persistProfileName() { - const nextName = input.value.trim(); + const nextName = nameInput.value.trim(); if (!nextName) { - input.value = profile.name || ""; + nameInput.value = profile.name || ""; return; } if (nextName === profile.name) return; @@ -1274,49 +1344,51 @@ function appendSettingProfileHeader(profile, container) { if (nextName === profile.name) return; } try { - input.classList.add("is-saving"); + nameInput.classList.add("is-saving"); nameSaveInFlight = saveSettingProfile(profile.id, { name: nextName }); const nextProfile = await nameSaveInFlight; if (nextProfile) profile.name = nextProfile.name; - if (itemsTitle) itemsTitle.textContent = profile.name; renderGroups({ animateGroups: false }); renderSettingSubnav(); + setSettingItemsTitle(profile.name); + showAppToast(getUIText("setting_profile_saved", "Profile saved")); } catch (e) { showAppToast(e?.message || getUIText("setting_profile_save_failed", "Failed to save profile"), { tone: "error" }); } finally { nameSaveInFlight = null; - input.classList.remove("is-saving"); + nameInput.classList.remove("is-saving"); } } - function scheduleProfileNameSave(delay = 500) { + function scheduleProfileNameSave(delay = 650) { if (nameSaveTimer) clearTimeout(nameSaveTimer); nameSaveTimer = window.setTimeout(() => { nameSaveTimer = 0; persistProfileName().catch(() => {}); }, delay); } - input.addEventListener("input", () => scheduleProfileNameSave()); - input.addEventListener("blur", () => { + nameInput.addEventListener("input", () => scheduleProfileNameSave()); + nameInput.addEventListener("blur", () => { if (nameSaveTimer) { clearTimeout(nameSaveTimer); nameSaveTimer = 0; } persistProfileName().catch(() => {}); }); - input.addEventListener("keydown", (event) => { + nameInput.addEventListener("keydown", (event) => { if (event.key !== "Enter") return; event.preventDefault(); if (nameSaveTimer) { clearTimeout(nameSaveTimer); nameSaveTimer = 0; } - persistProfileName().then(() => input.blur()).catch(() => {}); + persistProfileName().then(() => nameInput.blur()).catch(() => {}); }); + const menu = document.createElement("div"); - menu.className = "setting-profile-action-menu ui-dropdown-menu"; + menu.className = "setting-profile-menu ui-dropdown-menu"; const menuBtn = document.createElement("button"); menuBtn.type = "button"; - menuBtn.className = "setting-profile-action-menu__button ui-dropdown-menu__button"; + menuBtn.className = "setting-profile-menu__button ui-dropdown-menu__button"; menuBtn.setAttribute("aria-haspopup", "menu"); menuBtn.setAttribute("aria-expanded", "false"); menuBtn.setAttribute("aria-label", getUIText("setting_profile_menu", "Profile menu")); @@ -1326,28 +1398,27 @@ function appendSettingProfileHeader(profile, container) { `; const menuPanel = document.createElement("div"); - menuPanel.className = "setting-profile-action-menu__panel ui-dropdown-menu__panel"; + menuPanel.className = "setting-profile-menu__panel ui-dropdown-menu__panel"; menuPanel.setAttribute("role", "menu"); menuPanel.setAttribute("aria-hidden", "true"); menuPanel.hidden = true; - menuPanel.appendChild(makeSettingProfileMenuItem( - getUIText("setting_profile_search", "Search Profile"), - () => openSettingSearchPanel({ scope: { type: "profile", profileId: profile.id } }).catch(() => {}), - )); - menuPanel.appendChild(makeSettingProfileMenuItem( - getUIText("setting_profile_info", "Info"), - () => openSettingProfileInfo(profile), - )); - menuPanel.appendChild(makeSettingProfileMenuItem( - getUIText("apply", "Apply"), - () => applySettingProfile(profile), - "setting-profile-action-menu__item--primary", - )); - menuPanel.appendChild(makeSettingProfileMenuItem( - getUIText("delete", "Delete"), - () => deleteSettingProfile(profile), - "setting-profile-action-menu__item--danger", - )); + menuPanel.appendChild(makeSettingProfileMenuItem({ + label: getUIText("apply", "Apply"), + icon: "apply", + onClick: () => applySettingProfile(profile), + className: "setting-profile-menu__item--primary", + })); + menuPanel.appendChild(makeSettingProfileMenuItem({ + label: getUIText("setting_profile_search", "Search Profile"), + icon: "search", + onClick: () => openSettingSearchPanel({ scope: { type: "profile", profileId: profile.id } }).catch(() => {}), + })); + menuPanel.appendChild(makeSettingProfileMenuItem({ + label: getUIText("delete", "Delete"), + icon: "delete", + onClick: () => deleteSettingProfile(profile), + className: "setting-profile-menu__item--danger", + })); menuBtn.onclick = (event) => { event.stopPropagation(); const nextOpen = !menu.classList.contains("is-open"); @@ -1359,10 +1430,31 @@ function appendSettingProfileHeader(profile, container) { }; menu.appendChild(menuBtn); menu.appendChild(menuPanel); - titleRow.appendChild(input); - titleRow.appendChild(menu); - panel.appendChild(titleRow); + const rows = document.createElement("div"); + rows.className = "setting-profile-card__rows"; + + nameRow.appendChild(nameLabel); + nameRow.appendChild(nameInput); + nameRow.appendChild(menu); + rows.appendChild(nameRow); + + renderSettingProfileMetaRows(profile).forEach(([label, value]) => { + const row = document.createElement("div"); + row.className = "setting-profile-row"; + const rowLabel = document.createElement("div"); + rowLabel.className = "setting-profile-row__label"; + rowLabel.textContent = label; + const rowValue = document.createElement("div"); + rowValue.className = "setting-profile-row__value"; + rowValue.innerHTML = value; + row.appendChild(rowLabel); + row.appendChild(rowValue); + rows.appendChild(row); + }); + + card.appendChild(rows); + panel.appendChild(card); container.appendChild(panel); } @@ -1536,8 +1628,17 @@ function hasSettingViewportLayoutChanged() { ); } +function isPortraitInternalScrollMode() { + return Boolean(window.matchMedia && window.matchMedia("(max-width: 640px) and (orientation: portrait)").matches); +} + function getSettingItemsScrollContainer() { if (isCompactLandscapeMode() && screenItems) return screenItems; + // 세로 구조(뷰포트 고정 높이 + 화면 내부 스크롤)에서는 활성 화면이 스크롤러다. + if (isPortraitInternalScrollMode()) { + if (screenItems && screenItems.style.display !== "none" && !screenItems.classList.contains("hidden")) return screenItems; + if (screenGroups && screenGroups.style.display !== "none" && !screenGroups.classList.contains("hidden")) return screenGroups; + } return document.scrollingElement || document.documentElement || document.body; } @@ -1606,6 +1707,7 @@ function isCarrotSettingTabActive() { function syncSettingGroupChrome(group = CURRENT_GROUP) { const meta = document.getElementById("groupMeta"); const list = getSettingItemEntriesForGroup(group); + const profile = getSettingProfileByGroup(group); const groupLabel = group ? getSettingGroupLabel(group) : ""; if (CURRENT_SETTING_DETAIL) { const detailEntry = getSettingDetailEntry(group, CURRENT_SETTING_DETAIL); @@ -1617,7 +1719,10 @@ function syncSettingGroupChrome(group = CURRENT_GROUP) { } return; } - if (meta && group) meta.textContent = `${group} / ${list.length}`; + if (meta && group) { + meta.classList.remove("setting-profile-meta"); + meta.textContent = profile ? "" : `${group} / ${list.length}`; + } if (group) { settingTitle.textContent = (UI_STRINGS[LANG].setting || "Setting") + " - " + groupLabel; setSettingItemsTitle(groupLabel); @@ -1647,7 +1752,6 @@ async function selectSettingDetail(group, name, pushHistory = true) { saveCurrentSettingScrollPosition(targetGroup); CURRENT_GROUP = targetGroup; CURRENT_SETTING_DETAIL = targetName; - showSettingScreen("items", false); if (pushHistory) { history.pushState({ page: "setting", @@ -1656,12 +1760,12 @@ async function selectSettingDetail(group, name, pushHistory = true) { settingName: targetName, }, ""); } - await renderItems(targetGroup, { + await transitionSettingItemsContent(() => renderItems(targetGroup, { detailName: targetName, scrollMode: "top", - animateItems: true, + animateItems: false, forceValues: true, - }); + }), "forward"); } function settingMarqueeHtml(text, className) { @@ -1862,7 +1966,6 @@ async function openSettingSearchPanel(options = {}) { const scope = options.scope || { type: "all", profileId: "" }; if (CURRENT_PAGE !== "setting") return; closeSettingFabMenu(); - closeSettingProfileActionMenus(); if (!SETTINGS) { try { await loadSettings(); @@ -1909,7 +2012,18 @@ async function openSettingSearchPanel(options = {}) { } if (btnSettingSearch) { - btnSettingSearch.onclick = () => toggleSettingFabMenu(); + btnSettingSearch.addEventListener("animationend", (event) => { + if (event.animationName === "setting-fab-bounce") { + btnSettingSearch.classList.remove("is-bouncing"); + } + }); + btnSettingSearch.onclick = () => { + // 빠른 연타에도 바운스가 다시 재생되도록 클래스 제거 후 reflow 강제 + btnSettingSearch.classList.remove("is-bouncing"); + void btnSettingSearch.offsetWidth; + btnSettingSearch.classList.add("is-bouncing"); + toggleSettingFabMenu(); + }; } if (btnSettingFabSearch) { @@ -1992,8 +2106,9 @@ window.addEventListener("keydown", (e) => { closeSettingSearchPanel({ syncHistory: true }); return; } - if (e.key === "Escape") { + if (e.key === "Escape" && document.querySelector(".setting-profile-menu.is-open")) { closeSettingProfileActionMenus(); + return; } if (e.key === "Escape" && settingFabMenuOpen) { closeSettingFabMenu(); @@ -2001,18 +2116,18 @@ window.addEventListener("keydown", (e) => { }); document.addEventListener("pointerdown", (e) => { - if (!(e.target instanceof Element && e.target.closest(".setting-profile-action-menu"))) { + if (!(e.target instanceof Element) || !e.target.closest(".setting-profile-menu")) { closeSettingProfileActionMenus(); } - if (!settingFabMenuOpen || !settingFabMenu) return; - if (settingFabMenu.contains(e.target)) return; - closeSettingFabMenu(); + if (settingFabMenuOpen && settingFabMenu && !settingFabMenu.contains(e.target)) { + closeSettingFabMenu(); + } }); window.addEventListener("carrot:pagechange", (event) => { if (event?.detail?.page !== "setting") { - closeSettingFabMenu(); closeSettingProfileActionMenus(); + closeSettingFabMenu(); } }); @@ -2072,10 +2187,90 @@ function getSettingSubnavShiftTarget(direction) { }; } -function stripIdsFromClone(root) { - if (!root) return; - if (root.id) root.removeAttribute("id"); - root.querySelectorAll("[id]").forEach((node) => node.removeAttribute("id")); +async function transitionSettingItemsContent(renderContent, direction = "forward") { + if (typeof renderContent !== "function") return false; + + const reduceMotion = Boolean(window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches); + const canAnimate = + !window.__CARROT_WEB_BOOTSTRAPPING && + !reduceMotion && + !isCompactLandscapeMode() && + !settingGroupTransitionLock && + settingScreenHost && + screenItems && + screenItems.style.display !== "none" && + !screenItems.classList.contains("hidden"); + + if (!canAnimate) { + if (screenItems && (screenItems.style.display === "none" || screenItems.classList.contains("hidden"))) { + showSettingScreen("items", false); + } + await renderContent(); + return false; + } + + settingGroupTransitionLock = true; + const snapshot = screenItems.cloneNode(true); + snapshot.classList.add("setting-screen-snapshot"); + snapshot.querySelectorAll(".is-longpressing, .is-bouncing").forEach((el) => { + el.classList.remove("is-longpressing", "is-bouncing"); + }); + snapshot.setAttribute("aria-hidden", "true"); + snapshot.style.pointerEvents = "none"; + + try { + settingScreenHost.classList.add("setting-screen-transitioning"); + document.getElementById("pageSetting")?.classList.add("setting-screen-transitioning"); + settingScreenHost.appendChild(snapshot); + prepareSwipeFrame(settingScreenHost, snapshot); + snapshot.style.zIndex = "1"; + screenItems.style.visibility = "hidden"; + screenItems.style.pointerEvents = "none"; + + await renderContent(); + + screenItems.querySelectorAll(".ui-stagger-item").forEach((el) => el.classList.remove("ui-stagger-item")); + screenItems.style.visibility = ""; + const frame = prepareSwipeFrame(settingScreenHost, snapshot, screenItems); + if (!frame) { + resetPageRuntimeStyles(screenItems); + snapshot.remove(); + settingScreenHost.classList.remove("setting-screen-transitioning"); + document.getElementById("pageSetting")?.classList.remove("setting-screen-transitioning"); + settingGroupTransitionLock = false; + return false; + } + + applySwipeDrag(frame, 0, direction, false, { fade: false }); + await new Promise((resolve) => { + settleSwipe(frame, direction, true, resolve, { + durationMs: SETTING_SCREEN_SLIDE_MS, + easing: SETTING_SCREEN_SLIDE_EASE, + fade: false, + }); + }); + + clearPageTransitionClasses(screenItems); + resetPageRuntimeStyles(screenItems); + snapshot.remove(); + settingScreenHost.style.minHeight = ""; + settingScreenHost.classList.remove("setting-screen-transitioning"); + document.getElementById("pageSetting")?.classList.remove("setting-screen-transitioning"); + settingGroupTransitionLock = false; + scheduleSettingOverflowSync(screenItems); + return true; + } catch (e) { + clearPageTransitionClasses(screenItems); + resetPageRuntimeStyles(screenItems); + if (snapshot.parentElement) snapshot.remove(); + if (settingScreenHost) { + settingScreenHost.style.minHeight = ""; + settingScreenHost.classList.remove("setting-screen-transitioning"); + } + document.getElementById("pageSetting")?.classList.remove("setting-screen-transitioning"); + settingGroupTransitionLock = false; + throw e; + } } async function activateSettingGroup(group, pushHistory = true, options = {}) { @@ -2096,7 +2291,9 @@ async function activateSettingGroup(group, pushHistory = true, options = {}) { } CURRENT_GROUP = group; - renderGroups({ animateGroups }); + // 드릴인 시 그룹 목록을 재생성/재-stagger 하지 않는다 — 그 변화가 '슬라이드로 나가는 중인' + // 최상위 메뉴에 보여서 어색했다. 활성 표시만 제자리 갱신(reuse 경로)한다. + renderGroups({ animateGroups: false }); if (isCompactLandscapeMode() && CURRENT_PAGE === "setting") { showSettingScreen("items", false); history.replaceState({ page: "setting", screen: "items", group: CURRENT_GROUP || null }, ""); @@ -2122,13 +2319,13 @@ async function activateSettingGroup(group, pushHistory = true, options = {}) { return; } - showSettingScreen("items", pushHistory); - if (!pushHistory) { - history.replaceState({ page: "setting", screen: "items", group: CURRENT_GROUP || null }, ""); - } - syncSettingGroupChrome(group); - if (typeof centerActiveSettingSubnavTab === "function") centerActiveSettingSubnavTab("auto"); if (canReuseRenderedGroup) { + showSettingScreen("items", pushHistory); + if (!pushHistory) { + history.replaceState({ page: "setting", screen: "items", group: CURRENT_GROUP || null }, ""); + } + syncSettingGroupChrome(group); + if (typeof centerActiveSettingSubnavTab === "function") centerActiveSettingSubnavTab("auto"); requestAnimationFrame(() => { if (scrollMode === "restore") { setSettingItemsScrollTop( @@ -2140,11 +2337,20 @@ async function activateSettingGroup(group, pushHistory = true, options = {}) { }); return; } + // 단계 진입(items)은 좌우 슬라이드로만 보여준다 — 행별 세로 stagger 없이 + // 한 덩어리로 슬라이드 인 (One UI). 세로 stagger 는 설정 첫 진입에서만. await renderItems(group, { scrollMode, scrollTop: options.scrollTop, - animateItems, + animateItems: false, + allowHidden: true, }); + showSettingScreen("items", pushHistory); + if (!pushHistory) { + history.replaceState({ page: "setting", screen: "items", group: CURRENT_GROUP || null }, ""); + } + syncSettingGroupChrome(group); + if (typeof centerActiveSettingSubnavTab === "function") centerActiveSettingSubnavTab("auto"); } async function animateSettingGroupSwitch(group, direction = "forward") { @@ -2153,47 +2359,16 @@ async function animateSettingGroupSwitch(group, direction = "forward") { return; } - if (settingGroupTransitionLock || !settingScreenHost || !screenItems || screenItems.style.display === "none") { + if (settingGroupTransitionLock || !settingScreenHost || !screenItems) { await activateSettingGroup(group, false); return; } - settingGroupTransitionLock = true; if (typeof stopSettingSubnavMotion === "function") stopSettingSubnavMotion(); - - const snapshot = screenItems.cloneNode(true); - stripIdsFromClone(snapshot); - snapshot.setAttribute("aria-hidden", "true"); - snapshot.style.pointerEvents = "none"; - - try { - settingScreenHost.appendChild(snapshot); - prepareSwipeFrame(settingScreenHost, snapshot); - screenItems.style.visibility = "hidden"; - await activateSettingGroup(group, false); - screenItems.style.visibility = ""; - const frame = prepareSwipeFrame(settingScreenHost, snapshot, screenItems); - if (!frame) { - snapshot.remove(); - settingGroupTransitionLock = false; - return; - } - - applySwipeDrag(frame, 0, direction); - settleSwipe(frame, direction, true, () => { - clearPageTransitionClasses(screenItems); - resetPageRuntimeStyles(screenItems); - if (snapshot.parentElement) snapshot.remove(); - settingScreenHost.style.minHeight = ""; - settingGroupTransitionLock = false; - }); - } catch (e) { - screenItems.style.visibility = ""; - if (snapshot.parentElement) snapshot.remove(); - settingScreenHost.style.minHeight = ""; - settingGroupTransitionLock = false; - throw e; - } + await transitionSettingItemsContent( + () => activateSettingGroup(group, false, { animateGroups: false, animateItems: false }), + direction, + ); } function getCenteredSettingSubnavGroup() { @@ -2463,6 +2638,7 @@ async function renderItems(group, options = {}) { const detailMode = Boolean(detailName); const scrollMode = options.scrollMode || "top"; const animateItems = options.animateItems !== false; + const allowHidden = options.allowHidden === true; const requestedScrollTop = Number.isFinite(options.scrollTop) ? options.scrollTop : null; itemsBox.innerHTML = ""; delete itemsBox.dataset.renderedGroup; @@ -2477,11 +2653,14 @@ async function renderItems(group, options = {}) { if (screenItems) screenItems.classList.toggle("setting-screen-items--profile", Boolean(profile)); if (screenItems) screenItems.classList.toggle("setting-screen-items--detail", detailMode); CURRENT_SETTING_DETAIL = detailMode ? detailName : null; - if (meta) meta.textContent = `${group} / ${detailMode ? "1" : list.length}`; const groupLabel = getSettingGroupLabel(group); const detailTitle = detailMode && list[0] ? getSettingDetailTitle(list[0]) : ""; settingTitle.textContent = (UI_STRINGS[LANG].setting || "Setting") + " - " + (detailTitle || groupLabel); setSettingItemsTitle(detailTitle || groupLabel); + if (meta) { + meta.classList.remove("setting-profile-meta"); + meta.textContent = profile && !detailMode ? "" : `${group} / ${detailMode ? "1" : list.length}`; + } let values = {}; try { @@ -2493,7 +2672,12 @@ async function renderItems(group, options = {}) { values = {}; } - if (renderToken !== settingRenderToken || CURRENT_GROUP !== group || !isCarrotSettingTabActive() || screenItems?.style.display === "none") { + if ( + renderToken !== settingRenderToken || + CURRENT_GROUP !== group || + !isCarrotSettingTabActive() || + (!allowHidden && screenItems?.style.display === "none") + ) { return; } @@ -2599,7 +2783,7 @@ async function renderItems(group, options = {}) { if (!detailMode && profile && originGroup !== lastProfileGroup) { lastProfileGroup = originGroup; const section = document.createElement("div"); - section.className = animateItems ? "setting-profile-section ui-stagger-item" : "setting-profile-section"; + section.className = animateItems ? "setting-section-block setting-profile-section ui-stagger-item" : "setting-section-block setting-profile-section"; if (animateItems) section.style.setProperty("--i", String(Math.min(index + 1, 14))); const stateKey = `${profile.id}:${originGroup}`; const expanded = settingProfileSectionExpandedState.has(stateKey) @@ -2623,7 +2807,7 @@ async function renderItems(group, options = {}) { const body = document.createElement("div"); body.className = "setting-profile-section__body"; const bodyInner = document.createElement("div"); - bodyInner.className = "setting-profile-section__bodyInner"; + bodyInner.className = "setting-group-card setting-group-card__body setting-profile-section__bodyInner"; header.onclick = () => { const wasCollapsed = section.classList.contains("is-collapsed"); const nextExpanded = wasCollapsed; @@ -2650,6 +2834,7 @@ async function renderItems(group, options = {}) { const title = formatItemText(p, "title", "etitle", ""); const descr = formatItemText(p, "descr", "edescr", ""); + const rangeMeta = formatSettingRangeMeta(p); const el = document.createElement("div"); el.className = animateItems ? "setting ui-stagger-item" : "setting"; @@ -2670,7 +2855,7 @@ async function renderItems(group, options = {}) { ${settingMarqueeHtml(name, "name")}
- min=${p.min}, max=${p.max}, default=${p.default} + ${escapeHtml(rangeMeta)}
`; @@ -2876,7 +3061,10 @@ async function renderItems(group, options = {}) { let repeatTimer = null; let pointerActive = false; let activePointerId = null; - let ignoreNextClick = false; + let suppressClickUntil = 0; + const holdDelayMs = 900; + const repeatDelayMs = 160; + const clickSuppressMs = 450; function clearTimers() { if (holdTimer) { @@ -2901,15 +3089,12 @@ async function renderItems(group, options = {}) { } } activePointerId = null; - window.setTimeout(() => { - ignoreNextClick = false; - }, 0); } function repeatDelta() { if (!pointerActive) return; requestDelta(sign); - repeatTimer = window.setTimeout(repeatDelta, 120); + repeatTimer = window.setTimeout(repeatDelta, repeatDelayMs); } button.addEventListener("pointerdown", (event) => { @@ -2919,7 +3104,7 @@ async function renderItems(group, options = {}) { stopHold(); pointerActive = true; activePointerId = event.pointerId; - ignoreNextClick = true; + suppressClickUntil = Date.now() + clickSuppressMs; button.classList.add("is-holding"); if (typeof button.setPointerCapture === "function") { try { @@ -2929,18 +3114,19 @@ async function renderItems(group, options = {}) { } } requestDelta(sign); - holdTimer = window.setTimeout(repeatDelta, 420); + holdTimer = window.setTimeout(repeatDelta, holdDelayMs); }); button.addEventListener("pointerup", (event) => { event.stopPropagation(); + suppressClickUntil = Date.now() + clickSuppressMs; stopHold(); }); button.addEventListener("pointercancel", stopHold); button.addEventListener("lostpointercapture", stopHold); button.addEventListener("click", (event) => { event.stopPropagation(); - if (ignoreNextClick) { + if (Date.now() < suppressClickUntil) { event.preventDefault(); return; } @@ -2950,7 +3136,7 @@ async function renderItems(group, options = {}) { async function promptSettingValue() { const input = await appPrompt( - `min=${p.min}, max=${p.max}, default=${p.default}`, + rangeMeta, { title: title || name, defaultValue: val.dataset.rawValue ?? String(p.default), @@ -2987,7 +3173,7 @@ async function renderItems(group, options = {}) { choiceLayout: "value-grid", title: title || name, html: true, - messageHtml: `
${escapeHtml(name)}
min=${escapeHtml(p.min)}, max=${escapeHtml(p.max)}, default=${escapeHtml(p.default)}
`, + messageHtml: `
${escapeHtml(name)}
${escapeHtml(rangeMeta)}
`, choices, cancelLabel: getUIText("cancel", "Cancel"), showCancel: true, diff --git a/selfdrive/carrot/web/js/pages/tools.js b/selfdrive/carrot/web/js/pages/tools.js index 20d9c8b6a0a..b60b4c714f4 100644 --- a/selfdrive/carrot/web/js/pages/tools.js +++ b/selfdrive/carrot/web/js/pages/tools.js @@ -445,12 +445,6 @@ function renderToolsMeta() { const unreadCount = Math.max(0, Number(window.CarrotToolsNotifications?.unreadCount?.() || 0)); statusEl.classList.toggle("has-unread", unreadCount > 0); statusEl.dataset.unreadCount = unreadCount > 0 ? String(unreadCount) : ""; - if (unreadCount > 0) { - const badge = document.createElement("span"); - badge.className = "tools-meta__unreadBadge"; - badge.textContent = unreadCount > 99 ? "99+" : String(unreadCount); - statusEl.appendChild(badge); - } const track = document.createElement("span"); track.className = "tools-meta__statusTrack"; track.textContent = toolsMetaStatusText || "-"; diff --git a/selfdrive/carrot/web/js/shared/ui/navigation.js b/selfdrive/carrot/web/js/shared/ui/navigation.js index 4bf0ed7b4b0..501c511f6c4 100644 --- a/selfdrive/carrot/web/js/shared/ui/navigation.js +++ b/selfdrive/carrot/web/js/shared/ui/navigation.js @@ -49,6 +49,9 @@ function resetPageRuntimeStyles(el) { el.style.left = ""; el.style.right = ""; el.style.width = ""; + el.style.visibility = ""; + el.style.pointerEvents = ""; + el.style.boxShadow = ""; } function setPageRendered(el, rendered) { @@ -430,49 +433,92 @@ function commitPageChange(page, prevPage, pushHistory, options = {}) { else history.replaceState(state, ""); } -function applySwipeDrag(frame, dx, direction, withResistance = false) { +function applySwipeDrag(frame, dx, direction, withResistance = false, options = {}) { if (!frame) return; const { fromEl, toEl, width } = frame; + const fade = options.fade !== false; + const hierarchy = options.hierarchy === true; + const parallax = Number.isFinite(options.parallax) ? options.parallax : 0.18; const dragX = withResistance ? dx * SWIPE_EDGE_RESISTANCE : dx; const progress = Math.min(Math.abs(dragX) / width, 1); const targetBase = direction === "forward" ? width : -width; - fromEl.style.transform = `translateX(${dragX}px)`; - fromEl.style.opacity = `${1 - (progress * 0.14)}`; + if (hierarchy && direction === "backward") { + fromEl.style.transform = `translateX(${dragX}px)`; + fromEl.style.zIndex = "2"; + } else { + fromEl.style.transform = `translateX(${dragX}px)`; + fromEl.style.zIndex = "1"; + } + fromEl.style.opacity = fade ? `${1 - (progress * 0.14)}` : "1"; if (toEl) { - toEl.style.transform = `translateX(${targetBase + dragX}px)`; - toEl.style.opacity = `${0.82 + (progress * 0.18)}`; - toEl.style.zIndex = "2"; + const targetX = hierarchy && direction === "backward" + ? (-width * parallax) + (dragX * parallax) + : targetBase + dragX; + toEl.style.transform = `translateX(${targetX}px)`; + toEl.style.opacity = fade ? `${0.82 + (progress * 0.18)}` : "1"; + toEl.style.zIndex = hierarchy && direction === "backward" ? "1" : "2"; + } + + if (hierarchy) { + const foreground = direction === "backward" ? fromEl : toEl; + const background = direction === "backward" ? toEl : fromEl; + if (foreground) foreground.style.boxShadow = "-14px 0 30px rgba(0, 0, 0, 0.22)"; + if (background) background.style.boxShadow = "none"; } updateSwipeFrameHeight(frame); } -function settleSwipe(frame, direction, commit, done) { +function settleSwipe(frame, direction, commit, done, options = {}) { if (!frame) { done(); return; } const { fromEl, toEl, width } = frame; - const outX = direction === "forward" ? -width : width; + const durationMs = Number.isFinite(options.durationMs) ? options.durationMs : SWIPE_SETTLE_MS; + const easing = options.easing || "cubic-bezier(0.22, 1, 0.36, 1)"; + const fade = options.fade !== false; + const hierarchy = options.hierarchy === true; + const parallax = Number.isFinite(options.parallax) ? options.parallax : 0.18; + const outX = hierarchy && direction === "forward" + ? -(width * parallax) + : (direction === "forward" ? -width : width); const inX = direction === "forward" ? width : -width; - const transition = `transform ${SWIPE_SETTLE_MS}ms cubic-bezier(0.22, 1, 0.36, 1), opacity ${SWIPE_SETTLE_MS}ms ease`; + const transition = `transform ${durationMs}ms ${easing}, opacity ${durationMs}ms ease`; fromEl.style.transition = transition; if (toEl) toEl.style.transition = transition; + void fromEl.offsetWidth; + + // 전환이 '실제로 끝난' 시점에 정리한다. setTimeout(durationMs) 만 쓰면 rAF 로 + // 1프레임 늦게 시작된 transition 보다 먼저 발화해, 마지막에 살짝 튀는 잔상이 생긴다. + let settleDone = false; + const finishSettle = () => { + if (settleDone) return; + settleDone = true; + fromEl.removeEventListener("transitionend", onTransitionEnd); + done(); + }; + const onTransitionEnd = (event) => { + if (event.target === fromEl && event.propertyName === "transform") finishSettle(); + }; + fromEl.addEventListener("transitionend", onTransitionEnd); + requestAnimationFrame(() => { fromEl.style.transform = commit ? `translateX(${outX}px)` : "translateX(0px)"; - fromEl.style.opacity = commit ? "0" : "1"; + fromEl.style.opacity = fade && commit ? "0" : "1"; if (toEl) { toEl.style.transform = commit ? "translateX(0px)" : `translateX(${inX}px)`; - toEl.style.opacity = commit ? "1" : "0.82"; + toEl.style.opacity = fade ? (commit ? "1" : "0.82") : "1"; } }); - window.setTimeout(done, SWIPE_SETTLE_MS); + // transitionend 누락(예: 값 변화 없음) 대비 폴백. + window.setTimeout(finishSettle, durationMs + 80); } @@ -519,6 +565,89 @@ function showPage(page, pushHistory = false, transition = null) { commitPageChange(page, prevPage, pushHistory); } +/* One UI 기본 모션 곡선(공식): 빠르게 가속 후 천천히 감속. + 전체 화면 좌우 슬라이드라 제스처 릴리스(기본 220ms ease-out)보다 살짝 + 길게(320ms) 잡아 또렷하고 의도적으로 보이게 한다. */ +const SETTING_SCREEN_SLIDE_MS = 320; +const SETTING_SCREEN_SLIDE_EASE = "cubic-bezier(0.22, 0.25, 0, 1)"; + +/* groups ↔ items 단계 전환을 탭으로 들어갈 때 One UI 식 좌우 슬라이드로 재생. + 방향: 들어갈 때(items) forward, 나올 때(groups) backward. */ +function runSettingScreenSlide(showEl, hideEl, direction, token) { + if (!settingScreenHost || !showEl || !hideEl) return false; + + // 단계 전환에서는 세로 stagger 를 재생하지 않고 좌우 슬라이드만 보인다(들어오는·나가는 화면 모두). + [showEl, hideEl].forEach((scr) => { + scr.querySelectorAll(".ui-stagger-item").forEach((el) => el.classList.remove("ui-stagger-item")); + }); + + // 세로 구조: 두 화면은 CSS 로 host 안에서 position:absolute inset:0 로 '항상' 겹쳐 있다. + // 따라서 전환은 순수 transform 만으로 끝난다 — pin/unpin·minHeight 가 없어, 끝날 때 + // 재배치(흔들)나 재페인트(깜박)가 구조적으로 생기지 않는다. + [showEl, hideEl].forEach((el) => { + clearPageTransitionClasses(el); + el.style.transition = "none"; + el.style.transform = ""; + el.style.display = ""; + el.classList.remove("hidden"); + }); + + settingScreenHost.classList.add("setting-screen-transitioning"); + document.getElementById("pageSetting")?.classList.add("setting-screen-transitioning"); + + const enterFrom = direction === "forward" ? "100%" : "-100%"; + const exitTo = direction === "forward" ? "-100%" : "100%"; + showEl.style.zIndex = "2"; + showEl.style.willChange = "transform"; + showEl.style.transform = `translateX(${enterFrom})`; + hideEl.style.zIndex = "1"; + hideEl.style.willChange = "transform"; + hideEl.style.transform = "translateX(0)"; + + void showEl.offsetWidth; + + let settled = false; + const finalize = () => { + if (settled) return; + settled = true; + showEl.removeEventListener("transitionend", onTransitionEnd); + if (token !== settingScreenTransitionToken) return; + // transition 을 끈 채로 정리한다 — 그래야 transform 을 지울 때 .screen 의 .16s 전환이 + // 재가동돼 끝에 살짝 움직이거나 깜박이지 않는다. 정리를 즉시 커밋한 뒤 CSS 전환을 복구한다. + showEl.style.transition = "none"; + hideEl.style.transition = "none"; + hideEl.style.display = "none"; + hideEl.classList.add("hidden"); + showEl.style.transform = ""; + hideEl.style.transform = ""; + showEl.style.zIndex = ""; + hideEl.style.zIndex = ""; + void showEl.offsetWidth; + showEl.style.transition = ""; + hideEl.style.transition = ""; + showEl.style.willChange = ""; + hideEl.style.willChange = ""; + settingScreenHost.classList.remove("setting-screen-transitioning"); + document.getElementById("pageSetting")?.classList.remove("setting-screen-transitioning"); + }; + const onTransitionEnd = (event) => { + if (event.target === showEl && event.propertyName === "transform") finalize(); + }; + showEl.addEventListener("transitionend", onTransitionEnd); + + requestAnimationFrame(() => { + if (token !== settingScreenTransitionToken) { finalize(); return; } + const slide = `transform ${SETTING_SCREEN_SLIDE_MS}ms ${SETTING_SCREEN_SLIDE_EASE}`; + showEl.style.transition = slide; + hideEl.style.transition = slide; + showEl.style.transform = "translateX(0)"; + hideEl.style.transform = `translateX(${exitTo})`; + }); + + window.setTimeout(finalize, SETTING_SCREEN_SLIDE_MS + 80); + return true; +} + function showSettingScreen(which, pushHistory = false) { const isGroups = (which === "groups"); const showEl = isGroups ? screenGroups : screenItems; @@ -531,6 +660,15 @@ function showSettingScreen(which, pushHistory = false) { const transitionToken = ++settingScreenTransitionToken; settingScreenHideTimer = clearPendingScreenHide(settingScreenHideTimer); + if (settingScreenHost?.classList.contains("setting-screen-transitioning") && !settingGroupTransitionLock) { + [screenGroups, screenItems].forEach((el) => { + clearPageTransitionClasses(el); + resetPageRuntimeStyles(el); + }); + settingScreenHost.classList.remove("setting-screen-transitioning"); + document.getElementById("pageSetting")?.classList.remove("setting-screen-transitioning"); + settingScreenHost.style.minHeight = ""; + } syncSettingSplitLayoutClass(splitLandscape); document.getElementById("pageSetting")?.classList.toggle("setting-profile-active", isProfileItems); @@ -550,24 +688,36 @@ function showSettingScreen(which, pushHistory = false) { settingTitle.textContent = isGroups ? (UI_STRINGS[LANG].setting || "Setting") : ((UI_STRINGS[LANG].setting || "Setting") + " - " + currentGroupLabel); if (settingSubnavWrap) settingSubnavWrap.style.display = (isGroups || isProfileItems) ? "none" : ""; - showEl.style.display = ""; - requestAnimationFrame(() => { - if (transitionToken !== settingScreenTransitionToken) return; - showEl.classList.remove("hidden"); - }); + const reduceMotion = Boolean(window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches); + const canSlide = + !window.__CARROT_WEB_BOOTSTRAPPING && + !reduceMotion && + settingScreenHost && + showEl !== hideEl && + hideEl.style.display !== "none" && + !hideEl.classList.contains("hidden"); + const didSlide = canSlide && runSettingScreenSlide(showEl, hideEl, isGroups ? "backward" : "forward", transitionToken); + + if (!didSlide) { + showEl.style.display = ""; + requestAnimationFrame(() => { + if (transitionToken !== settingScreenTransitionToken) return; + showEl.classList.remove("hidden"); + }); - hideEl.classList.add("hidden"); - settingScreenHideTimer = window.setTimeout(() => { - if (transitionToken !== settingScreenTransitionToken) return; - hideEl.style.display = "none"; - settingScreenHideTimer = null; - }, 170); + hideEl.classList.add("hidden"); + settingScreenHideTimer = window.setTimeout(() => { + if (transitionToken !== settingScreenTransitionToken) return; + hideEl.style.display = "none"; + settingScreenHideTimer = null; + }, 170); + } if (pushHistory) { history.pushState({ page: "setting", screen: which, group: CURRENT_GROUP || null }, ""); } - if (settingScreenHost) settingScreenHost.style.minHeight = ""; + if (settingScreenHost && !didSlide) settingScreenHost.style.minHeight = ""; if (typeof syncSettingSubnavFixedOffset === "function") { requestAnimationFrame(syncSettingSubnavFixedOffset); } diff --git a/selfdrive/carrot/web/js/translations/en.js b/selfdrive/carrot/web/js/translations/en.js index cf4fa0e316b..3192d419af2 100644 --- a/selfdrive/carrot/web/js/translations/en.js +++ b/selfdrive/carrot/web/js/translations/en.js @@ -325,8 +325,6 @@ window.CarrotTranslations.register("en", { setting_profile_create_prompt: "Enter a profile name.", setting_profile_name: "Profile name", setting_profile_created: "Created", - setting_profile_info: "Info", - setting_profile_info_empty: "No profile metadata", setting_profile_value: "Profile", setting_profile_apply_title: "Apply Profile", setting_profile_apply_done: "Profile applied", @@ -337,6 +335,7 @@ window.CarrotTranslations.register("en", { setting_profile_deleted: "Profile deleted", setting_profile_save_failed: "Failed to save profile", setting_profile_menu: "Profile menu", + setting_profile_card_title: "Profile ({count})", setting_profile_search: "Search Profile", setting_profile_search_placeholder: "Search in this profile", setting_reset_defaults: "Reset Settings", diff --git a/selfdrive/carrot/web/js/translations/ko.js b/selfdrive/carrot/web/js/translations/ko.js index 4671d58e85f..8e8e8b2c351 100644 --- a/selfdrive/carrot/web/js/translations/ko.js +++ b/selfdrive/carrot/web/js/translations/ko.js @@ -323,8 +323,6 @@ window.CarrotTranslations.register("ko", { setting_profile_create_prompt: "프로필 이름을 입력하세요.", setting_profile_name: "프로필 이름", setting_profile_created: "생성", - setting_profile_info: "정보", - setting_profile_info_empty: "프로필 정보가 없습니다", setting_profile_value: "프로필", setting_profile_apply_title: "프로필 적용", setting_profile_apply_done: "프로필이 적용되었습니다", @@ -335,6 +333,7 @@ window.CarrotTranslations.register("ko", { setting_profile_deleted: "프로필이 삭제되었습니다", setting_profile_save_failed: "프로필 저장에 실패했습니다", setting_profile_menu: "프로필 메뉴", + setting_profile_card_title: "프로필 ({count}개)", setting_profile_search: "프로필 검색", setting_profile_search_placeholder: "이 프로필에서 검색", setting_reset_defaults: "설정 초기화", diff --git a/selfdrive/carrot/web/js/translations/zh.js b/selfdrive/carrot/web/js/translations/zh.js index a9b37dbac5a..a2909769a77 100644 --- a/selfdrive/carrot/web/js/translations/zh.js +++ b/selfdrive/carrot/web/js/translations/zh.js @@ -323,8 +323,6 @@ window.CarrotTranslations.register("zh", { setting_profile_create_prompt: "输入配置名称。", setting_profile_name: "配置名称", setting_profile_created: "创建时间", - setting_profile_info: "信息", - setting_profile_info_empty: "没有配置元数据", setting_profile_value: "配置", setting_profile_apply_title: "应用配置", setting_profile_apply_done: "配置已应用", @@ -335,6 +333,7 @@ window.CarrotTranslations.register("zh", { setting_profile_deleted: "配置已删除", setting_profile_save_failed: "保存配置失败", setting_profile_menu: "配置菜单", + setting_profile_card_title: "配置 ({count})", setting_profile_search: "搜索配置", setting_profile_search_placeholder: "在此配置中搜索", setting_reset_defaults: "重置设置",