From 5ca62c8d53a968f54ea56970e101bc0127b8b810 Mon Sep 17 00:00:00 2001 From: jominki354 Date: Mon, 11 May 2026 10:06:39 +0900 Subject: [PATCH 01/23] js f --- selfdrive/carrot/web/index.html | 6 +- .../carrot/web/js/pages/setting_device.js | 39 ++++++++++- selfdrive/carrot/web/js/pages/tools.js | 2 + .../web/js/pages/tools_notifications.js | 65 ++++++++++++++++++- 4 files changed, 105 insertions(+), 7 deletions(-) diff --git a/selfdrive/carrot/web/index.html b/selfdrive/carrot/web/index.html index 844e9c20cf..5e25cbd886 100644 --- a/selfdrive/carrot/web/index.html +++ b/selfdrive/carrot/web/index.html @@ -629,10 +629,10 @@

Home

- + - - + + diff --git a/selfdrive/carrot/web/js/pages/setting_device.js b/selfdrive/carrot/web/js/pages/setting_device.js index 4085a68317..363ae3786f 100644 --- a/selfdrive/carrot/web/js/pages/setting_device.js +++ b/selfdrive/carrot/web/js/pages/setting_device.js @@ -229,6 +229,7 @@ async function renderDeviceTab(options = {}) { const animateGroups = options.animateGroups !== false; const animateItems = options.animateItems !== false; renderDeviceGroups({ animateGroups }); + syncDeviceGroupChrome(CURRENT_DEVICE_GROUP); if (!deviceTabLoaded) { deviceTabLoaded = true; loadDeviceParams("Device", true).then(() => { @@ -244,6 +245,7 @@ async function selectDeviceGroup(groupId) { CURRENT_DEVICE_GROUP = groupId || CURRENT_DEVICE_GROUP; renderDeviceGroups(); syncSettingTabState("device"); + syncDeviceGroupChrome(CURRENT_DEVICE_GROUP); await renderDeviceItems(CURRENT_DEVICE_GROUP, true, { animateItems: true }); } @@ -282,6 +284,7 @@ async function renderDeviceItems(groupId, showItemsScreen = true, options = {}) } bindDeviceTabEvents(itemsContainer); syncDeviceGroupActiveState(groupId); + syncDeviceGroupChrome(groupId); syncDeviceNetworkRefresh(); } @@ -373,12 +376,30 @@ function syncDeviceGroupActiveState(groupId = CURRENT_DEVICE_GROUP) { }); } +function syncDeviceGroupChrome(groupId = CURRENT_DEVICE_GROUP) { + const label = getDeviceGroupLabel(groupId); + const meta = document.getElementById("groupMeta"); + const itemCount = document.getElementById("deviceItems")?.children.length || 0; + if (meta && groupId) meta.textContent = `${groupId} / ${itemCount}`; + if (typeof settingTitle !== "undefined" && settingTitle) { + settingTitle.textContent = (UI_STRINGS[LANG].setting || "Setting") + " - " + label; + } + if (typeof itemsTitle !== "undefined" && itemsTitle) { + itemsTitle.textContent = label; + } +} + async function switchSettingTab(tab) { const nextTab = tab === "device" ? "device" : "carrot"; if (CURRENT_SETTING_TAB === nextTab) { syncSettingTabState(nextTab); - if (nextTab !== "device") stopDeviceNetworkRefresh(); - else syncDeviceNetworkRefresh(); + if (nextTab !== "device") { + stopDeviceNetworkRefresh(); + if (typeof syncSettingGroupChrome === "function") syncSettingGroupChrome(CURRENT_GROUP); + } else { + syncDeviceGroupChrome(CURRENT_DEVICE_GROUP); + syncDeviceNetworkRefresh(); + } return; } @@ -391,12 +412,26 @@ async function switchSettingTab(tab) { if (!(typeof isCompactLandscapeMode === "function" && isCompactLandscapeMode()) && typeof showSettingScreen === "function") { showSettingScreen("groups", false); } + syncDeviceGroupChrome(CURRENT_DEVICE_GROUP); return; } + if (typeof isCompactLandscapeMode === "function" && isCompactLandscapeMode() && typeof activateSettingGroup === "function") { + const targetGroup = CURRENT_GROUP || (typeof getLandscapeDefaultSettingGroup === "function" ? getLandscapeDefaultSettingGroup() : null); + if (targetGroup) { + await activateSettingGroup(targetGroup, false, { + animateGroups: false, + animateItems: false, + scrollMode: "restore", + }); + return; + } + } + if (typeof showSettingScreen === "function") { showSettingScreen("groups", false); } + if (typeof syncSettingGroupChrome === "function") syncSettingGroupChrome(CURRENT_GROUP); } if (settingTabDevice) { diff --git a/selfdrive/carrot/web/js/pages/tools.js b/selfdrive/carrot/web/js/pages/tools.js index 80e5cf319c..7ef43c0f01 100644 --- a/selfdrive/carrot/web/js/pages/tools.js +++ b/selfdrive/carrot/web/js/pages/tools.js @@ -170,6 +170,7 @@ function setToolsLogExpanded(expanded, options = {}) { window.clearTimeout(toolsLogAttentionTimer); toolsLogAttentionTimer = null; } + window.CarrotToolsNotifications?.focusLatest?.({ expand: true }); } scrollToolsLogToBottom(); scrollToolsLogToBottom(280); @@ -189,6 +190,7 @@ function renderToolsOut() { }, { onClear: clearToolsNotificationHistory, onClose: () => setToolsLogExpanded(false), + autoFocusLatest: true, }); } else { out.textContent = currentText || historyText || " "; diff --git a/selfdrive/carrot/web/js/pages/tools_notifications.js b/selfdrive/carrot/web/js/pages/tools_notifications.js index 1ff1b5d1e0..afbd471bec 100644 --- a/selfdrive/carrot/web/js/pages/tools_notifications.js +++ b/selfdrive/carrot/web/js/pages/tools_notifications.js @@ -27,6 +27,7 @@ let collapseHost = null; const detailScrollState = new Map(); let lastRenderSignature = ""; + let lastAutoFocusedEntryId = ""; function uiText(key, fallback, vars = null) { return typeof getUIText === "function" ? getUIText(key, fallback, vars) : fallback; @@ -613,6 +614,54 @@ if (!model.entries.some((entry) => entry.id === activeNotificationId)) activeNotificationId = ""; } + function latestEntry(model) { + if (!model?.entries?.length) return null; + let latest = null; + let latestTime = 0; + model.entries.forEach((entry) => { + const timestamp = Number(entry.timestamp || 0); + if (Number.isFinite(timestamp) && timestamp > 0 && timestamp >= latestTime) { + latest = entry; + latestTime = timestamp; + } + }); + if (latest) return latest; + return model.entries.slice().reverse().find((entry) => entry.source === "current") || model.entries[model.entries.length - 1]; + } + + function createEntryFocus(out, entryId, expanded = true) { + const scroller = getLogScroller(out); + const card = findCardById(scroller, entryId); + const cardRect = card?.getBoundingClientRect?.(); + return { + id: entryId, + expanded, + keyboard: false, + mode: out?.dataset?.toolsNotificationMode || getMode(), + scrollTop: scroller?.scrollTop || 0, + cardTop: cardRect ? cardRect.top : null, + }; + } + + function focusEntry(out, entryId, options = {}) { + if (!entryId) return ""; + const expanded = options.expand !== false; + clearCollapseRenderTimer(); + activeNotificationId = expanded ? entryId : ""; + pendingEntryFocus = createEntryFocus(out, entryId, expanded); + detailScrollState.delete(entryId); + return entryId; + } + + function focusLatestEntry(out = lastHost, options = {}) { + const model = buildModel(lastState); + const entry = latestEntry(model); + if (!entry) return ""; + const focusedId = focusEntry(out, entry.id, options); + if (out) render(out, model.state, lastOptions, { force: true, preserveScroll: false }); + return focusedId; + } + function captureScrollAnchor(out, anchorId = activeNotificationId) { const scroller = getLogScroller(out); if (!scroller) return null; @@ -919,8 +968,8 @@ function render(out, state = {}, options = {}, renderOptions = {}) { if (!out) return; bindModeSync(); - const interactionFocus = pendingEntryFocus; - const scrollAnchor = renderOptions.preserveScroll === false || interactionFocus ? null : captureScrollAnchor(out); + let interactionFocus = pendingEntryFocus; + let scrollAnchor = renderOptions.preserveScroll === false || interactionFocus ? null : captureScrollAnchor(out); const mode = getMode(); const model = buildModel(state); lastState = model.state; @@ -934,6 +983,14 @@ } normalizeActiveEntry(model); + const autoFocusLatest = options.autoFocusLatest === true && !renderOptions.skipAutoFocusLatest; + const latest = autoFocusLatest ? latestEntry(model) : null; + if (latest && latest.id !== lastAutoFocusedEntryId) { + lastAutoFocusedEntryId = latest.id; + focusEntry(out, latest.id, { expand: true }); + interactionFocus = pendingEntryFocus; + scrollAnchor = null; + } const signature = renderSignature(model, mode); const canPatchExisting = !interactionFocus && signature === lastRenderSignature && out.childElementCount > 0; const context = { out, mode, model, options }; @@ -993,8 +1050,12 @@ render, resetDetail() { activeNotificationId = ""; + lastAutoFocusedEntryId = ""; detailScrollState.clear(); }, + focusLatest(options = {}) { + return focusLatestEntry(options.out || lastHost, options); + }, syncMode(out = lastHost) { if (out) syncHostMode(out); }, From 7e0af4f683628a70d4a85cf9d6f9c3a9c6ab2777 Mon Sep 17 00:00:00 2001 From: jominki354 Date: Mon, 11 May 2026 10:10:46 +0900 Subject: [PATCH 02/23] scroll --- selfdrive/carrot/web/index.html | 2 +- selfdrive/carrot/web/js/pages/tools.js | 15 ++++++--------- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/selfdrive/carrot/web/index.html b/selfdrive/carrot/web/index.html index 5e25cbd886..24c9b287a1 100644 --- a/selfdrive/carrot/web/index.html +++ b/selfdrive/carrot/web/index.html @@ -632,7 +632,7 @@

Home

- + diff --git a/selfdrive/carrot/web/js/pages/tools.js b/selfdrive/carrot/web/js/pages/tools.js index 7ef43c0f01..63b6f71150 100644 --- a/selfdrive/carrot/web/js/pages/tools.js +++ b/selfdrive/carrot/web/js/pages/tools.js @@ -140,7 +140,6 @@ function requestToolsMetaTickerSync() { function pulseToolsLogPanel() { const page = document.getElementById("pageTools"); if (!page || page.classList.contains("tools-log-expanded")) { - scrollToolsLogToBottom(); return; } page.classList.add("tools-log-attention"); @@ -148,10 +147,7 @@ function pulseToolsLogPanel() { toolsLogAttentionTimer = window.setTimeout(() => { page.classList.remove("tools-log-attention"); toolsLogAttentionTimer = null; - scrollToolsLogToBottom(); - scrollToolsLogToBottom(280); }, 3200); - scrollToolsLogToBottom(280); } function setToolsLogExpanded(expanded, options = {}) { @@ -172,8 +168,10 @@ function setToolsLogExpanded(expanded, options = {}) { } window.CarrotToolsNotifications?.focusLatest?.({ expand: true }); } - scrollToolsLogToBottom(); - scrollToolsLogToBottom(280); + if (!window.CarrotToolsNotifications?.render) { + scrollToolsLogToBottom(); + scrollToolsLogToBottom(280); + } } function renderToolsOut() { @@ -194,10 +192,9 @@ function renderToolsOut() { }); } else { out.textContent = currentText || historyText || " "; + requestAnimationFrame(() => scrollToolsLogToBottom()); + scrollToolsLogToBottom(280); } - - requestAnimationFrame(() => scrollToolsLogToBottom()); - scrollToolsLogToBottom(280); } async function clearToolsNotificationHistory() { From 82cc9e5079ec065aa2f834797f192d6769303c36 Mon Sep 17 00:00:00 2001 From: jominki354 Date: Mon, 11 May 2026 10:38:57 +0900 Subject: [PATCH 03/23] f --- selfdrive/carrot/web/css/pages/tools.css | 3 +++ selfdrive/carrot/web/index.html | 6 +++--- selfdrive/carrot/web/js/pages/tools.js | 2 +- .../carrot/web/js/pages/tools_notifications.js | 14 ++++++++++---- 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/selfdrive/carrot/web/css/pages/tools.css b/selfdrive/carrot/web/css/pages/tools.css index c3de27841b..349648612a 100644 --- a/selfdrive/carrot/web/css/pages/tools.css +++ b/selfdrive/carrot/web/css/pages/tools.css @@ -1473,6 +1473,7 @@ width: calc(100% - var(--tools-detail-scroll-gap)); max-height: min(32dvh, 230px); overflow: auto; + overflow-anchor: none; overscroll-behavior: contain; scrollbar-gutter: stable; scrollbar-width: thin; @@ -1674,6 +1675,7 @@ flex: 1 1 auto; min-height: 0; overflow: auto; + overflow-anchor: none; padding: 0 var(--tools-log-scroll-gap) 16px 0; scroll-padding-bottom: 20px; scrollbar-gutter: stable; @@ -1886,6 +1888,7 @@ max-height: none; padding: clamp(22px, 7dvh, 58px) var(--tools-log-scroll-gap) 0 0; overflow: auto; + overflow-anchor: none; overscroll-behavior: contain; touch-action: pan-y; -webkit-user-select: none; diff --git a/selfdrive/carrot/web/index.html b/selfdrive/carrot/web/index.html index 24c9b287a1..3da1278aef 100644 --- a/selfdrive/carrot/web/index.html +++ b/selfdrive/carrot/web/index.html @@ -78,7 +78,7 @@ - + @@ -631,8 +631,8 @@

Home

- - + + diff --git a/selfdrive/carrot/web/js/pages/tools.js b/selfdrive/carrot/web/js/pages/tools.js index 63b6f71150..a09b6a5762 100644 --- a/selfdrive/carrot/web/js/pages/tools.js +++ b/selfdrive/carrot/web/js/pages/tools.js @@ -166,7 +166,7 @@ function setToolsLogExpanded(expanded, options = {}) { window.clearTimeout(toolsLogAttentionTimer); toolsLogAttentionTimer = null; } - window.CarrotToolsNotifications?.focusLatest?.({ expand: true }); + window.CarrotToolsNotifications?.focusLatest?.({ expand: true, instant: true }); } if (!window.CarrotToolsNotifications?.render) { scrollToolsLogToBottom(); diff --git a/selfdrive/carrot/web/js/pages/tools_notifications.js b/selfdrive/carrot/web/js/pages/tools_notifications.js index afbd471bec..7caa38c3d4 100644 --- a/selfdrive/carrot/web/js/pages/tools_notifications.js +++ b/selfdrive/carrot/web/js/pages/tools_notifications.js @@ -629,7 +629,7 @@ return model.entries.slice().reverse().find((entry) => entry.source === "current") || model.entries[model.entries.length - 1]; } - function createEntryFocus(out, entryId, expanded = true) { + function createEntryFocus(out, entryId, expanded = true, options = {}) { const scroller = getLogScroller(out); const card = findCardById(scroller, entryId); const cardRect = card?.getBoundingClientRect?.(); @@ -637,6 +637,7 @@ id: entryId, expanded, keyboard: false, + instant: options.instant === true, mode: out?.dataset?.toolsNotificationMode || getMode(), scrollTop: scroller?.scrollTop || 0, cardTop: cardRect ? cardRect.top : null, @@ -648,7 +649,7 @@ const expanded = options.expand !== false; clearCollapseRenderTimer(); activeNotificationId = expanded ? entryId : ""; - pendingEntryFocus = createEntryFocus(out, entryId, expanded); + pendingEntryFocus = createEntryFocus(out, entryId, expanded, options); detailScrollState.delete(entryId); return entryId; } @@ -759,8 +760,9 @@ if (Math.abs(delta) < 2) return; const target = clampScrollTop(scroller, scroller.scrollTop + delta); + const behavior = opts.behavior || (prefersReducedMotion() ? "auto" : "smooth"); try { - scroller.scrollTo({ top: target, behavior: prefersReducedMotion() ? "auto" : "smooth" }); + scroller.scrollTo({ top: target, behavior }); } catch { scroller.scrollTop = target; } @@ -784,6 +786,10 @@ const mode = getMode(); global.requestAnimationFrame(() => { if (token !== entryFocusToken) return; + if (focus.instant) { + scrollEntryIntoView(out, focus, "settled", { behavior: "auto" }); + return; + } if (focus.expanded && !prefersReducedMotion()) { const scroller = getLogScroller(out); const card = findCardById(scroller, focus.id); @@ -987,7 +993,7 @@ const latest = autoFocusLatest ? latestEntry(model) : null; if (latest && latest.id !== lastAutoFocusedEntryId) { lastAutoFocusedEntryId = latest.id; - focusEntry(out, latest.id, { expand: true }); + focusEntry(out, latest.id, { expand: true, instant: true }); interactionFocus = pendingEntryFocus; scrollAnchor = null; } From 3dc96836d8420acd4b8da91d441c91f4a89ebe1d Mon Sep 17 00:00:00 2001 From: jominki354 Date: Mon, 11 May 2026 10:51:45 +0900 Subject: [PATCH 04/23] f --- selfdrive/carrot/web/index.html | 2 +- .../web/js/pages/tools_notifications.js | 94 ++++++++++++++++++- 2 files changed, 92 insertions(+), 4 deletions(-) diff --git a/selfdrive/carrot/web/index.html b/selfdrive/carrot/web/index.html index 3da1278aef..ace56ff8e4 100644 --- a/selfdrive/carrot/web/index.html +++ b/selfdrive/carrot/web/index.html @@ -631,7 +631,7 @@

Home

- + diff --git a/selfdrive/carrot/web/js/pages/tools_notifications.js b/selfdrive/carrot/web/js/pages/tools_notifications.js index 7caa38c3d4..22e565e314 100644 --- a/selfdrive/carrot/web/js/pages/tools_notifications.js +++ b/selfdrive/carrot/web/js/pages/tools_notifications.js @@ -552,6 +552,17 @@ wrap.style.maxHeight = `${wrap.scrollHeight}px`; } + function stabilizeExpandedDetail(card) { + const wrap = card?.querySelector?.(".tools-console-log__detailWrap"); + if (!wrap) return; + wrap.style.transition = "none"; + wrap.style.animation = "none"; + wrap.style.opacity = "1"; + wrap.style.transform = "translateY(0)"; + setMeasuredDetailHeight(card); + wrap.getBoundingClientRect(); + } + function animateDetailCollapse(card) { const wrap = card?.querySelector?.(".tools-console-log__detailWrap"); if (!wrap) return; @@ -599,6 +610,62 @@ }); } + function isRunningEntry(entry) { + return String(entry?.status || "") === "running"; + } + + function canAutoFocusEntry(entry) { + return Boolean(entry) && !isRunningEntry(entry); + } + + function currentCards(out) { + const scroller = getLogScroller(out); + return scroller ? Array.from(scroller.querySelectorAll("[data-notification-id]")) : []; + } + + function canPatchExistingCards(out, model) { + const cards = currentCards(out); + return cards.length === model.entries.length && cards.every((card, index) => ( + card.dataset.notificationId === model.entries[index]?.id + )); + } + + function setNodeText(node, value) { + if (node && node.textContent !== value) node.textContent = value; + } + + function patchCard(card, entry, context) { + const expanded = activeNotificationId === entry.id; + card.classList.toggle("tools-console-log__current", entry.source === "current"); + card.classList.toggle("tools-console-log__history", entry.source === "history"); + card.classList.toggle("is-expanded", expanded); + card.dataset.toolsNotificationMode = context.mode; + card.setAttribute("aria-expanded", expanded ? "true" : "false"); + + setNodeText(card.querySelector(".tools-console-log__cardTitle"), entry.title); + const head = card.querySelector(".tools-console-log__cardHead"); + let time = card.querySelector(".tools-console-log__cardTime"); + if (entry.timeLabel) { + if (!time && head) { + time = document.createElement("span"); + time.className = "tools-console-log__cardTime"; + head.appendChild(time); + } + setNodeText(time, entry.timeLabel); + } else if (time) { + time.remove(); + } + setNodeText(card.querySelector(".tools-console-log__cardBody"), entry.summary); + setNodeText(card.querySelector(".tools-console-log__detail"), entry.text); + } + + function patchExistingCards(out, model, context) { + currentCards(out).forEach((card, index) => patchCard(card, model.entries[index], context)); + out.querySelectorAll(".tools-console-log__clearBtn").forEach((button) => { + button.disabled = !model.hasHistory; + }); + } + function updateRelativeTimeLabels(out, entries) { if (!out) return; const labels = new Map(entries.map((entry) => [entry.id, entry.timeLabel || ""])); @@ -991,27 +1058,48 @@ normalizeActiveEntry(model); const autoFocusLatest = options.autoFocusLatest === true && !renderOptions.skipAutoFocusLatest; const latest = autoFocusLatest ? latestEntry(model) : null; - if (latest && latest.id !== lastAutoFocusedEntryId) { + if (canAutoFocusEntry(latest) && latest.id !== lastAutoFocusedEntryId) { lastAutoFocusedEntryId = latest.id; focusEntry(out, latest.id, { expand: true, instant: true }); interactionFocus = pendingEntryFocus; scrollAnchor = null; } const signature = renderSignature(model, mode); - const canPatchExisting = !interactionFocus && signature === lastRenderSignature && out.childElementCount > 0; const context = { out, mode, model, options }; - if (canPatchExisting) { + if (!interactionFocus && signature === lastRenderSignature && out.childElementCount > 0) { updateRelativeTimeLabels(out, model.entries); scheduleRelativeTimeRefresh(model.entries); return; } + if (!interactionFocus && out.childElementCount > 0 && canPatchExistingCards(out, model)) { + patchExistingCards(out, model, context); + lastRenderSignature = signature; + scheduleRelativeTimeRefresh(model.entries); + if (activeNotificationId) { + global.requestAnimationFrame(() => { + const scroller = getLogScroller(out); + const card = findCardById(scroller, activeNotificationId); + setMeasuredDetailHeight(card); + restoreDetailScroll(out, activeNotificationId); + }); + } + return; + } out.replaceChildren(mode === MODE.PORTRAIT ? renderPortraitCenter(context) : renderLandscapePanel(context)); lastRenderSignature = signature; if (interactionFocus) { restoreEntryInteraction(out, interactionFocus); + if (interactionFocus.instant) { + const scroller = getLogScroller(out); + stabilizeExpandedDetail(findCardById(scroller, interactionFocus.id)); + } } else { restoreScrollAnchor(out, scrollAnchor); + if (activeNotificationId) { + const scroller = getLogScroller(out); + stabilizeExpandedDetail(findCardById(scroller, activeNotificationId)); + } } scheduleRelativeTimeRefresh(model.entries); if (interactionFocus) { From b2cdcd1587250c8f2ceaa42897add04f23cc15bd Mon Sep 17 00:00:00 2001 From: jominki354 Date: Mon, 11 May 2026 10:56:49 +0900 Subject: [PATCH 05/23] ani --- selfdrive/carrot/web/index.html | 4 ++-- selfdrive/carrot/web/js/pages/tools.js | 2 +- selfdrive/carrot/web/js/pages/tools_notifications.js | 10 ++++++++-- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/selfdrive/carrot/web/index.html b/selfdrive/carrot/web/index.html index ace56ff8e4..21f57273cc 100644 --- a/selfdrive/carrot/web/index.html +++ b/selfdrive/carrot/web/index.html @@ -631,8 +631,8 @@

Home

- - + + diff --git a/selfdrive/carrot/web/js/pages/tools.js b/selfdrive/carrot/web/js/pages/tools.js index a09b6a5762..3c467d2cda 100644 --- a/selfdrive/carrot/web/js/pages/tools.js +++ b/selfdrive/carrot/web/js/pages/tools.js @@ -166,7 +166,7 @@ function setToolsLogExpanded(expanded, options = {}) { window.clearTimeout(toolsLogAttentionTimer); toolsLogAttentionTimer = null; } - window.CarrotToolsNotifications?.focusLatest?.({ expand: true, instant: true }); + window.CarrotToolsNotifications?.focusLatest?.({ expand: true, smoothOnce: true, stableDetail: true }); } if (!window.CarrotToolsNotifications?.render) { scrollToolsLogToBottom(); diff --git a/selfdrive/carrot/web/js/pages/tools_notifications.js b/selfdrive/carrot/web/js/pages/tools_notifications.js index 22e565e314..8e23a33f1c 100644 --- a/selfdrive/carrot/web/js/pages/tools_notifications.js +++ b/selfdrive/carrot/web/js/pages/tools_notifications.js @@ -705,6 +705,8 @@ expanded, keyboard: false, instant: options.instant === true, + smoothOnce: options.smoothOnce === true, + stableDetail: options.stableDetail === true, mode: out?.dataset?.toolsNotificationMode || getMode(), scrollTop: scroller?.scrollTop || 0, cardTop: cardRect ? cardRect.top : null, @@ -857,6 +859,10 @@ scrollEntryIntoView(out, focus, "settled", { behavior: "auto" }); return; } + if (focus.smoothOnce) { + scrollEntryIntoView(out, focus, "settled"); + return; + } if (focus.expanded && !prefersReducedMotion()) { const scroller = getLogScroller(out); const card = findCardById(scroller, focus.id); @@ -1060,7 +1066,7 @@ const latest = autoFocusLatest ? latestEntry(model) : null; if (canAutoFocusEntry(latest) && latest.id !== lastAutoFocusedEntryId) { lastAutoFocusedEntryId = latest.id; - focusEntry(out, latest.id, { expand: true, instant: true }); + focusEntry(out, latest.id, { expand: true, smoothOnce: true, stableDetail: true }); interactionFocus = pendingEntryFocus; scrollAnchor = null; } @@ -1090,7 +1096,7 @@ lastRenderSignature = signature; if (interactionFocus) { restoreEntryInteraction(out, interactionFocus); - if (interactionFocus.instant) { + if (interactionFocus.instant || interactionFocus.stableDetail) { const scroller = getLogScroller(out); stabilizeExpandedDetail(findCardById(scroller, interactionFocus.id)); } From 9a58568e5f83b5a23e51fd42cd01dbd11f1b72a9 Mon Sep 17 00:00:00 2001 From: jominki354 Date: Mon, 11 May 2026 11:24:47 +0900 Subject: [PATCH 06/23] front --- selfdrive/carrot/web/css/base.css | 6 ++-- selfdrive/carrot/web/css/components.css | 14 ++++----- selfdrive/carrot/web/css/pages/drive.css | 34 ++++++++++++++++++--- selfdrive/carrot/web/css/pages/settings.css | 4 +-- selfdrive/carrot/web/css/pages/tools.css | 20 ++++++------ selfdrive/carrot/web/css/responsive.css | 7 ++++- selfdrive/carrot/web/css/tokens.css | 3 ++ selfdrive/carrot/web/index.html | 14 ++++----- 8 files changed, 68 insertions(+), 34 deletions(-) diff --git a/selfdrive/carrot/web/css/base.css b/selfdrive/carrot/web/css/base.css index ba38e16fcf..f1a72ee185 100644 --- a/selfdrive/carrot/web/css/base.css +++ b/selfdrive/carrot/web/css/base.css @@ -132,7 +132,7 @@ body:has(.page-transitioning) { } .topbar .nav-btn:focus-visible { - background: color-mix(in srgb, var(--md-primary-cont) 14%, transparent); + background: var(--md-primary-state-soft); } .topbar .nav-btn .nav-icon { @@ -181,7 +181,7 @@ body:has(.page-transitioning) { .topbar .nav-btn.active { color: var(--md-primary); font-weight: 700; - background: color-mix(in srgb, var(--md-primary) 12%, transparent); + background: var(--md-primary-state-soft); } .topbar .nav-btn.recording, @@ -565,7 +565,7 @@ body:has(.page-transitioning) { } .fab.active { - background: var(--md-primary-cont); + background: var(--md-primary-state-strong); border-color: color-mix(in srgb, var(--md-primary) 35%, var(--md-outline-var)); color: var(--md-primary); } diff --git a/selfdrive/carrot/web/css/components.css b/selfdrive/carrot/web/css/components.css index ee6e10e0ed..93372f46e3 100644 --- a/selfdrive/carrot/web/css/components.css +++ b/selfdrive/carrot/web/css/components.css @@ -177,7 +177,7 @@ body[data-page="terminal"] .app-toast-host { .app-dialog__choiceBtn.is-current { border-color: color-mix(in srgb, var(--md-primary) 56%, var(--md-stroke-soft)); - background: color-mix(in srgb, var(--md-primary-cont) 34%, var(--md-surface-cont-h)); + background: var(--md-primary-state); color: var(--md-primary); font-weight: 800; display: flex; @@ -338,7 +338,7 @@ body[data-page="terminal"] .app-toast-host { padding: 4px 8px; border-radius: var(--r-pill); border: 1px solid color-mix(in srgb, var(--md-primary) 42%, transparent); - background: color-mix(in srgb, var(--md-primary-cont) 62%, transparent); + background: var(--md-primary-state-soft); color: var(--md-primary); font-size: 11px; font-weight: 800; @@ -347,7 +347,7 @@ body[data-page="terminal"] .app-toast-host { .app-branch-picker__item.is-current { border-color: color-mix(in srgb, var(--md-primary) 56%, var(--md-stroke-soft)); - background: color-mix(in srgb, var(--md-primary-cont) 34%, var(--md-surface-cont-h)); + background: var(--md-primary-state); color: var(--md-primary); font-weight: 800; } @@ -404,7 +404,7 @@ body[data-page="terminal"] .app-toast-host { .btn.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)); + background: var(--md-primary-state); color: var(--md-primary); font-weight: 850; } @@ -695,7 +695,7 @@ body[data-page="terminal"] .app-toast-host { .setting-subnav__tab.is-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)); + background: var(--md-primary-state); color: var(--md-primary); font-weight: 850; } @@ -737,7 +737,7 @@ body[data-page="terminal"] .app-toast-host { .groupBtn.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)); + background: var(--md-primary-state); color: var(--md-primary); font-weight: 850; } @@ -843,7 +843,7 @@ body[data-page="terminal"] .app-toast-host { } .setting.is-focus-hit { - background: color-mix(in srgb, var(--md-primary-cont) 26%, transparent); + background: var(--md-primary-state-soft); border-bottom-color: color-mix(in srgb, var(--md-primary) 34%, var(--md-stroke-soft)); } diff --git a/selfdrive/carrot/web/css/pages/drive.css b/selfdrive/carrot/web/css/pages/drive.css index bed607ef3b..837fbb71fc 100644 --- a/selfdrive/carrot/web/css/pages/drive.css +++ b/selfdrive/carrot/web/css/pages/drive.css @@ -84,6 +84,32 @@ body[data-page="carrot"] #driveHudCard { display: none !important; } +.vision-start-overlay .btn--filled { + border-color: color-mix(in srgb, var(--md-primary) 82%, rgba(255, 255, 255, 0.22)); + background: + linear-gradient( + 180deg, + color-mix(in srgb, var(--md-primary) 96%, #fff 4%), + color-mix(in srgb, var(--md-primary) 82%, #ff8b37) + ); + color: var(--md-on-primary); + box-shadow: + 0 1px 0 color-mix(in srgb, #fff 28%, transparent) inset, + 0 14px 34px rgba(255, 176, 109, 0.18), + 0 10px 26px rgba(0, 0, 0, 0.28); +} + +.vision-start-overlay .btn--filled:hover, +.vision-start-overlay .btn--filled:focus-visible { + border-color: color-mix(in srgb, var(--md-primary) 90%, rgba(255, 255, 255, 0.28)); + background: + linear-gradient( + 180deg, + color-mix(in srgb, var(--md-primary) 88%, #fff 12%), + color-mix(in srgb, var(--md-primary) 90%, #ff9f55) + ); +} + .vision-start-overlay__message::after { content: attr(data-tooltip); position: absolute; @@ -125,10 +151,10 @@ body[data-page="carrot"] #driveHudCard { .vision-start-overlay .btn:disabled { opacity: 1; filter: none; - border-color: rgba(255, 255, 255, 0.16); - background: linear-gradient(180deg, #6e737b, #4c525a); - color: rgba(255, 255, 255, 0.88); - box-shadow: 0 10px 28px rgba(0, 0, 0, 0.30); + border-color: color-mix(in srgb, var(--md-outline-var) 42%, transparent); + background: color-mix(in srgb, var(--md-on-surface) 8%, transparent); + color: color-mix(in srgb, var(--md-on-surface-var) 72%, var(--md-outline-var)); + box-shadow: none; cursor: not-allowed; } diff --git a/selfdrive/carrot/web/css/pages/settings.css b/selfdrive/carrot/web/css/pages/settings.css index ec475dc6d2..043e185bdb 100644 --- a/selfdrive/carrot/web/css/pages/settings.css +++ b/selfdrive/carrot/web/css/pages/settings.css @@ -422,7 +422,7 @@ .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)); + background: var(--md-primary-state); color: var(--md-primary); } @@ -990,7 +990,7 @@ .page--setting .setting-subnav__tab.is-active { color: var(--md-primary); 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)); + background: var(--md-primary-state); } .page--setting .setting-subnav__tab:active { diff --git a/selfdrive/carrot/web/css/pages/tools.css b/selfdrive/carrot/web/css/pages/tools.css index 349648612a..a55befb02f 100644 --- a/selfdrive/carrot/web/css/pages/tools.css +++ b/selfdrive/carrot/web/css/pages/tools.css @@ -324,7 +324,7 @@ .web-settings-nav__item.is-active { border-color: color-mix(in srgb, var(--md-primary) 56%, var(--md-outline-var)); - background: color-mix(in srgb, var(--md-primary-cont) 42%, var(--md-surface-cont-h)); + background: var(--md-primary-state); color: var(--md-primary); } @@ -1344,7 +1344,7 @@ background: linear-gradient( 180deg, - color-mix(in srgb, var(--md-primary-cont) 16%, var(--md-surface-cont-h)), + color-mix(in srgb, var(--md-primary) 10%, var(--md-surface-cont-h)), color-mix(in srgb, var(--md-surface-cont) 88%, #05080d) ); } @@ -1368,8 +1368,8 @@ background: linear-gradient( 180deg, - color-mix(in srgb, var(--md-primary-cont) 28%, var(--md-surface-cont-h)), - color-mix(in srgb, var(--md-primary-cont) 16%, var(--md-surface-cont)) + color-mix(in srgb, var(--md-primary) 16%, var(--md-surface-cont-h)), + color-mix(in srgb, var(--md-primary) 10%, var(--md-surface-cont)) ); } @@ -1481,7 +1481,7 @@ padding: 9px 10px; border: 1px solid color-mix(in srgb, var(--md-primary) 26%, var(--md-outline-var)); border-radius: 10px; - background: color-mix(in srgb, var(--tools-console-bg) 82%, var(--md-primary-cont)); + background: color-mix(in srgb, var(--tools-console-bg) 88%, var(--md-primary)); color: color-mix(in srgb, var(--md-on-surface) 84%, var(--tools-console-current)); font-family: var(--font-mono); font-size: 12px; @@ -1529,8 +1529,8 @@ background: linear-gradient( 180deg, - color-mix(in srgb, var(--md-primary-cont) 76%, #05080d), - color-mix(in srgb, var(--md-primary-cont) 54%, #05080d) + color-mix(in srgb, var(--md-primary) 22%, var(--md-surface-cont-hh)), + color-mix(in srgb, var(--md-primary) 14%, var(--md-surface-cont-h)) ); color: var(--md-on-surface); } @@ -1550,8 +1550,8 @@ background: linear-gradient( 180deg, - color-mix(in srgb, var(--md-primary-cont) 30%, var(--md-surface-cont-h)), - color-mix(in srgb, var(--md-primary-cont) 18%, var(--md-surface-cont)) + color-mix(in srgb, var(--md-primary) 18%, var(--md-surface-cont-h)), + color-mix(in srgb, var(--md-primary) 12%, var(--md-surface-cont)) ); color: var(--tools-console-current); } @@ -2013,7 +2013,7 @@ } .tools-console-log__clearBtn { - background: color-mix(in srgb, var(--md-primary-cont) 72%, #05070c); + background: color-mix(in srgb, var(--md-primary) 16%, #05070c); border-color: color-mix(in srgb, var(--md-primary) 42%, transparent); } } diff --git a/selfdrive/carrot/web/css/responsive.css b/selfdrive/carrot/web/css/responsive.css index ec27c9ee86..7f0c6ce8f0 100644 --- a/selfdrive/carrot/web/css/responsive.css +++ b/selfdrive/carrot/web/css/responsive.css @@ -111,7 +111,12 @@ .topbar .nav-btn.active { color: var(--md-primary); font-weight: 700; - background: color-mix(in srgb, var(--md-primary) 12%, transparent); + background: + linear-gradient( + 90deg, + color-mix(in srgb, var(--md-primary) 84%, #fff) 0 4px, + var(--md-primary-state-soft) 4px 100% + ); } .topbar .nav-btn > span:last-child { diff --git a/selfdrive/carrot/web/css/tokens.css b/selfdrive/carrot/web/css/tokens.css index cdfc7f774c..88fe1ff781 100644 --- a/selfdrive/carrot/web/css/tokens.css +++ b/selfdrive/carrot/web/css/tokens.css @@ -22,6 +22,9 @@ --md-on-primary: #3a1800; --md-primary-cont: #7b3e10; --md-on-primary-cont:#fff0e2; + --md-primary-state-soft: color-mix(in srgb, var(--md-primary) 12%, transparent); + --md-primary-state: color-mix(in srgb, var(--md-primary) 16%, var(--md-surface-cont-h)); + --md-primary-state-strong: color-mix(in srgb, var(--md-primary) 22%, var(--md-surface-cont-hh)); /* ── Error ── */ --md-error: #ff9d94; diff --git a/selfdrive/carrot/web/index.html b/selfdrive/carrot/web/index.html index 21f57273cc..6ce4749732 100644 --- a/selfdrive/carrot/web/index.html +++ b/selfdrive/carrot/web/index.html @@ -69,18 +69,18 @@ background: color-mix(in srgb, var(--md-primary) 12%, transparent) !important; } - + - + - + - - - - + + + + From c3df62b8439b4291d502af35c7bbca3452942d70 Mon Sep 17 00:00:00 2001 From: jominki354 Date: Mon, 11 May 2026 11:39:17 +0900 Subject: [PATCH 07/23] f --- selfdrive/carrot/web/css/responsive.css | 7 +------ selfdrive/carrot/web/css/tokens.css | 10 +++++----- selfdrive/carrot/web/index.html | 6 +++--- 3 files changed, 9 insertions(+), 14 deletions(-) diff --git a/selfdrive/carrot/web/css/responsive.css b/selfdrive/carrot/web/css/responsive.css index 7f0c6ce8f0..efe7d05552 100644 --- a/selfdrive/carrot/web/css/responsive.css +++ b/selfdrive/carrot/web/css/responsive.css @@ -111,12 +111,7 @@ .topbar .nav-btn.active { color: var(--md-primary); font-weight: 700; - background: - linear-gradient( - 90deg, - color-mix(in srgb, var(--md-primary) 84%, #fff) 0 4px, - var(--md-primary-state-soft) 4px 100% - ); + background: var(--md-primary-state-soft); } .topbar .nav-btn > span:last-child { diff --git a/selfdrive/carrot/web/css/tokens.css b/selfdrive/carrot/web/css/tokens.css index 88fe1ff781..554bd47714 100644 --- a/selfdrive/carrot/web/css/tokens.css +++ b/selfdrive/carrot/web/css/tokens.css @@ -20,11 +20,11 @@ /* ── Primary — Carrot Dark Orange ── */ --md-primary: #ffb06d; --md-on-primary: #3a1800; - --md-primary-cont: #7b3e10; + --md-primary-cont: #2f3946; --md-on-primary-cont:#fff0e2; - --md-primary-state-soft: color-mix(in srgb, var(--md-primary) 12%, transparent); - --md-primary-state: color-mix(in srgb, var(--md-primary) 16%, var(--md-surface-cont-h)); - --md-primary-state-strong: color-mix(in srgb, var(--md-primary) 22%, var(--md-surface-cont-hh)); + --md-primary-state-soft: color-mix(in srgb, var(--md-surface-bright) 13%, transparent); + --md-primary-state: color-mix(in srgb, var(--md-surface-bright) 24%, var(--md-surface-cont-h)); + --md-primary-state-strong: color-mix(in srgb, var(--md-surface-bright) 28%, var(--md-surface-cont-hh)); /* ── Error ── */ --md-error: #ff9d94; @@ -88,7 +88,7 @@ --md-stroke-soft: #959fb2; --md-stroke-strong: #d2d9e8; --md-primary: #ffc087; - --md-primary-cont: #8b4713; + --md-primary-cont: #3a4655; --md-on-primary-cont:#fff4eb; } } diff --git a/selfdrive/carrot/web/index.html b/selfdrive/carrot/web/index.html index 6ce4749732..bcb92ca9f0 100644 --- a/selfdrive/carrot/web/index.html +++ b/selfdrive/carrot/web/index.html @@ -66,10 +66,10 @@ html[data-carrot-start-page="terminal"]:not([data-carrot-bootstrapped]) .topbar #btnTerminal { color: var(--md-primary) !important; font-weight: 700 !important; - background: color-mix(in srgb, var(--md-primary) 12%, transparent) !important; + background: color-mix(in srgb, var(--md-surface-bright) 13%, transparent) !important; } - + @@ -80,7 +80,7 @@ - + From 85e4246e58b365f6fa5c79c2200751eab1401a23 Mon Sep 17 00:00:00 2001 From: jominki354 Date: Mon, 11 May 2026 11:46:58 +0900 Subject: [PATCH 08/23] f --- selfdrive/carrot/web/css/components.css | 21 ++++++++++++++++++++- selfdrive/carrot/web/css/tokens.css | 10 +++------- selfdrive/carrot/web/index.html | 6 +++--- 3 files changed, 26 insertions(+), 11 deletions(-) diff --git a/selfdrive/carrot/web/css/components.css b/selfdrive/carrot/web/css/components.css index 93372f46e3..e69adc7ab9 100644 --- a/selfdrive/carrot/web/css/components.css +++ b/selfdrive/carrot/web/css/components.css @@ -488,12 +488,31 @@ body[data-page="terminal"] .app-toast-host { transform: translateY(1px); } -.btn--filled { +.btn--filled, +.smallBtn.btn--filled { background: var(--md-primary); border-color: color-mix(in srgb, var(--md-primary) 76%, var(--md-outline-var)); color: var(--md-on-primary); } +#appDialogConfirm.btn--filled { + background: var(--md-primary); + border-color: color-mix(in srgb, var(--md-primary) 76%, var(--md-outline-var)); + color: var(--md-on-primary); + box-shadow: none; +} + +#appDialogConfirm.btn--filled:hover, +#appDialogConfirm.btn--filled:focus-visible { + background: color-mix(in srgb, var(--md-primary) 88%, #fff); + border-color: color-mix(in srgb, var(--md-primary) 82%, var(--md-outline-var)); + color: var(--md-on-primary); +} + +#appDialogConfirm.btn--filled:active { + background: color-mix(in srgb, var(--md-primary) 82%, #fff); +} + .btn--danger { color: var(--md-error); } diff --git a/selfdrive/carrot/web/css/tokens.css b/selfdrive/carrot/web/css/tokens.css index 554bd47714..faa2f4d6fe 100644 --- a/selfdrive/carrot/web/css/tokens.css +++ b/selfdrive/carrot/web/css/tokens.css @@ -20,11 +20,9 @@ /* ── Primary — Carrot Dark Orange ── */ --md-primary: #ffb06d; --md-on-primary: #3a1800; - --md-primary-cont: #2f3946; - --md-on-primary-cont:#fff0e2; - --md-primary-state-soft: color-mix(in srgb, var(--md-surface-bright) 13%, transparent); - --md-primary-state: color-mix(in srgb, var(--md-surface-bright) 24%, var(--md-surface-cont-h)); - --md-primary-state-strong: color-mix(in srgb, var(--md-surface-bright) 28%, var(--md-surface-cont-hh)); + --md-primary-state-soft: color-mix(in srgb, var(--md-primary) 14%, transparent); + --md-primary-state: color-mix(in srgb, var(--md-primary) 18%, var(--md-surface-cont-h)); + --md-primary-state-strong: color-mix(in srgb, var(--md-primary) 22%, var(--md-surface-cont-hh)); /* ── Error ── */ --md-error: #ff9d94; @@ -88,7 +86,5 @@ --md-stroke-soft: #959fb2; --md-stroke-strong: #d2d9e8; --md-primary: #ffc087; - --md-primary-cont: #3a4655; - --md-on-primary-cont:#fff4eb; } } diff --git a/selfdrive/carrot/web/index.html b/selfdrive/carrot/web/index.html index bcb92ca9f0..de4802552a 100644 --- a/selfdrive/carrot/web/index.html +++ b/selfdrive/carrot/web/index.html @@ -66,15 +66,15 @@ html[data-carrot-start-page="terminal"]:not([data-carrot-bootstrapped]) .topbar #btnTerminal { color: var(--md-primary) !important; font-weight: 700 !important; - background: color-mix(in srgb, var(--md-surface-bright) 13%, transparent) !important; + background: color-mix(in srgb, var(--md-primary) 14%, transparent) !important; } - + - + From 2950bc554ac972fd3055f7394530a28bcd55955d Mon Sep 17 00:00:00 2001 From: jominki354 Date: Mon, 11 May 2026 12:00:46 +0900 Subject: [PATCH 09/23] loute --- .../carrot/server/features/dashcam/routes.py | 79 +++++++- selfdrive/carrot/web/css/pages/logs.css | 15 ++ selfdrive/carrot/web/index.html | 12 +- selfdrive/carrot/web/js/pages/branch.js | 1 + selfdrive/carrot/web/js/pages/logs.js | 188 ++++++++++++++++-- selfdrive/carrot/web/js/translations/en.js | 4 + selfdrive/carrot/web/js/translations/ko.js | 4 + selfdrive/carrot/web/js/translations/zh.js | 4 + 8 files changed, 278 insertions(+), 29 deletions(-) diff --git a/selfdrive/carrot/server/features/dashcam/routes.py b/selfdrive/carrot/server/features/dashcam/routes.py index 0bd2522c03..56ba492180 100644 --- a/selfdrive/carrot/server/features/dashcam/routes.py +++ b/selfdrive/carrot/server/features/dashcam/routes.py @@ -19,6 +19,11 @@ ) ROUTE_CACHE_TTL = 3.0 +DASHCAM_ROUTE_LIMIT_DEFAULT = 40 +DASHCAM_ROUTE_LIMIT_MAX = 200 +DASHCAM_SEGMENT_LIMIT_DEFAULT = 10 +DASHCAM_SEGMENT_LIMIT_MAX = 80 +DASHCAM_OFFSET_MAX = 1000000 _route_cache_lock = threading.Lock() _route_cache = {"time": 0.0, "routes": []} @@ -51,19 +56,62 @@ def cached_dashcam_routes() -> list[dict]: return list(routes) +def bounded_query_int(request: web.Request, name: str, default: int, maximum: int) -> int: + try: + value = int(request.query.get(name, str(default)) or default) + except (TypeError, ValueError): + value = default + return max(0 if name == "offset" else 1, min(maximum, value)) + + +def route_with_segment_page(entry: dict, segment_offset: int = 0, segment_limit: int = DASHCAM_SEGMENT_LIMIT_DEFAULT) -> dict: + segments = list(entry.get("segmentFolders") or []) + total = len(segments) + offset = max(0, min(segment_offset, total)) + limit = max(1, min(DASHCAM_SEGMENT_LIMIT_MAX, segment_limit)) + end = min(offset + limit, total) + result = dict(entry) + result["segmentFolders"] = segments[offset:end] + result["segmentCount"] = int(entry.get("segmentCount") or total) + result["segmentOffset"] = offset + result["segmentLimit"] = limit + result["segmentsNextOffset"] = end if end < total else None + result["segmentsHasMore"] = end < total + return result + + +def find_dashcam_route(routes: list[dict], route: str) -> dict | None: + if not route or "/" in route or "\\" in route or route in (".", ".."): + return None + for entry in routes: + if entry.get("route") == route: + return entry + return None + + async def api_dashcam_routes(request: web.Request) -> web.Response: try: - offset = max(0, int(request.query.get("offset", "0") or 0)) - limit = max(1, min(200, int(request.query.get("limit", "80") or 80))) + offset = bounded_query_int(request, "offset", 0, DASHCAM_OFFSET_MAX) + limit = bounded_query_int(request, "limit", DASHCAM_ROUTE_LIMIT_DEFAULT, DASHCAM_ROUTE_LIMIT_MAX) + segment_limit = bounded_query_int( + request, + "segment_limit", + DASHCAM_SEGMENT_LIMIT_DEFAULT, + DASHCAM_SEGMENT_LIMIT_MAX, + ) routes = await asyncio.to_thread(cached_dashcam_routes) total = len(routes) end = min(offset + limit, total) return web.json_response({ "ok": True, - "routes": routes[offset:end], + "routes": [ + route_with_segment_page(entry, 0, segment_limit) + for entry in routes[offset:end] + ], "root": DASHCAM_ROOT, "offset": offset, "limit": limit, + "segmentLimit": segment_limit, "total": total, "nextOffset": end if end < total else None, "hasMore": end < total, @@ -72,6 +120,30 @@ async def api_dashcam_routes(request: web.Request) -> web.Response: return web.json_response({"ok": False, "error": str(e)}, status=500) +async def api_dashcam_segments(request: web.Request) -> web.Response: + try: + route = request.match_info.get("route", "") + offset = bounded_query_int(request, "offset", 0, DASHCAM_OFFSET_MAX) + limit = bounded_query_int(request, "limit", DASHCAM_SEGMENT_LIMIT_DEFAULT, DASHCAM_SEGMENT_LIMIT_MAX) + routes = await asyncio.to_thread(cached_dashcam_routes) + entry = find_dashcam_route(routes, route) + if not entry: + return web.json_response({"ok": False, "error": "route not found"}, status=404) + page = route_with_segment_page(entry, offset, limit) + return web.json_response({ + "ok": True, + "route": route, + "segments": page["segmentFolders"], + "offset": page["segmentOffset"], + "limit": page["segmentLimit"], + "total": page["segmentCount"], + "nextOffset": page["segmentsNextOffset"], + "hasMore": page["segmentsHasMore"], + }) + except Exception as e: + return web.json_response({"ok": False, "error": str(e)}, status=500) + + async def api_dashcam_thumbnail(request: web.Request) -> web.StreamResponse: segment = request.match_info.get("segment", "") path = await asyncio.to_thread(ensure_thumbnail, segment) @@ -199,6 +271,7 @@ async def api_dashcam_upload_cancel(request: web.Request) -> web.Response: def register(app: web.Application) -> None: app.router.add_get("/api/dashcam/routes", api_dashcam_routes) + app.router.add_get("/api/dashcam/segments/{route}", api_dashcam_segments) app.router.add_get("/api/dashcam/thumbnail/{segment}", api_dashcam_thumbnail) app.router.add_get("/api/dashcam/preview/{segment}", api_dashcam_preview) app.router.add_get("/api/dashcam/video/{segment}", api_dashcam_video) diff --git a/selfdrive/carrot/web/css/pages/logs.css b/selfdrive/carrot/web/css/pages/logs.css index 49fe5e2e52..f5cadea460 100644 --- a/selfdrive/carrot/web/css/pages/logs.css +++ b/selfdrive/carrot/web/css/pages/logs.css @@ -392,6 +392,21 @@ padding-right: 2px; } +.dashcam-segment-more { + flex: 0 0 auto; + justify-content: center; + gap: 8px; + width: 100%; + min-height: 38px; + border-radius: 8px; +} + +.dashcam-segment-more span { + color: var(--md-on-surface-var); + font-size: 12px; + font-weight: 750; +} + .dashcam-segment-tile { flex: 0 0 auto; min-height: 70px; diff --git a/selfdrive/carrot/web/index.html b/selfdrive/carrot/web/index.html index de4802552a..120dc1ec65 100644 --- a/selfdrive/carrot/web/index.html +++ b/selfdrive/carrot/web/index.html @@ -75,7 +75,7 @@ - + @@ -608,9 +608,9 @@

Home

- - - + + + @@ -634,8 +634,8 @@

Home

- - + + diff --git a/selfdrive/carrot/web/js/pages/branch.js b/selfdrive/carrot/web/js/pages/branch.js index e363d0cfd8..3cca67c5de 100644 --- a/selfdrive/carrot/web/js/pages/branch.js +++ b/selfdrive/carrot/web/js/pages/branch.js @@ -374,6 +374,7 @@ const dashcamState = { selected: new Set(), refreshTimer: null, loadingMore: false, + loadingSegments: new Set(), scrollBusy: false, scrollTimer: null, renderFrame: 0, diff --git a/selfdrive/carrot/web/js/pages/logs.js b/selfdrive/carrot/web/js/pages/logs.js index 2fecaa6e62..c01f34c591 100644 --- a/selfdrive/carrot/web/js/pages/logs.js +++ b/selfdrive/carrot/web/js/pages/logs.js @@ -4,9 +4,12 @@ // + Screen Recording listing/playback. Tab switching between the two. const DASHCAM_UPLOAD_JOB_STORAGE_KEY = "carrot_dashcam_upload_job_id"; -const DASHCAM_PAGE_SIZE = 40; -const DASHCAM_LOAD_AHEAD_PX = 1200; -const DASHCAM_ROUTE_WINDOW_OVERSCAN = 10; +const DASHCAM_ROUTE_PAGE_MIN = 10; +const DASHCAM_ROUTE_PAGE_MAX = 40; +const DASHCAM_ROUTE_PAGE_VIEWPORTS = 3; +const DASHCAM_SEGMENT_PAGE_SIZE = 10; +const DASHCAM_LOAD_AHEAD_VIEWPORTS = 1.5; +const DASHCAM_ROUTE_WINDOW_OVERSCAN_VIEWPORTS = 1.25; const SCREENRECORD_PAGE_SIZE = 40; const SCREENRECORD_LOAD_AHEAD_PX = 720; const SCREENRECORD_WINDOW_OVERSCAN = 8; @@ -122,6 +125,9 @@ function screenrecordApiPath(kind, fileId) { function dashcamRoutesSignature(routes) { return (routes || []).map((entry) => [ entry.route || "", + entry.segmentCount || 0, + entry.segmentsNextOffset ?? "", + entry.segmentsHasMore ? "1" : "0", ...(entry.segmentFolders || []), ].join("|")).join("\n") + "|" + (typeof LANG !== "undefined" ? LANG : ""); } @@ -156,6 +162,16 @@ function dashcamRouteHeightFor(route) { return Math.max(120, fallback); } +function dashcamRoutePageSize(scroller = document.getElementById("dashcamRoutes")) { + const rowHeight = Math.max(120, dashcamRouteHeightFor("")); + const viewportHeight = Math.max(rowHeight, scroller?.clientHeight || window.innerHeight || rowHeight); + const visibleRows = Math.max(1, Math.ceil(viewportHeight / rowHeight)); + return Math.max( + DASHCAM_ROUTE_PAGE_MIN, + Math.min(DASHCAM_ROUTE_PAGE_MAX, visibleRows * DASHCAM_ROUTE_PAGE_VIEWPORTS) + ); +} + function dashcamRouteGap(host) { const styles = window.getComputedStyle?.(host); return Number.parseFloat(styles?.rowGap || styles?.gap || "0") || 0; @@ -166,7 +182,8 @@ function dashcamWindowFor(host, routes) { const count = list.length; const viewportHeight = Math.max(1, host?.clientHeight || dashcamDefaultRouteHeight() * 2); const scrollTop = Math.max(0, host?.scrollTop || 0); - const overscanPx = dashcamRouteHeightFor("") * DASHCAM_ROUTE_WINDOW_OVERSCAN; + const rowHeight = Math.max(120, dashcamRouteHeightFor("")); + const overscanPx = Math.max(rowHeight * 2, viewportHeight * DASHCAM_ROUTE_WINDOW_OVERSCAN_VIEWPORTS); const minTop = Math.max(0, scrollTop - overscanPx); const maxBottom = scrollTop + viewportHeight + overscanPx; const gap = dashcamRouteGap(host); @@ -190,7 +207,7 @@ function dashcamWindowFor(host, routes) { endHeight += dashcamRouteHeightFor(list[end]?.route) + (end > 0 ? gap : 0); end += 1; } - const minEnd = Math.min(count, Math.max(end + DASHCAM_ROUTE_WINDOW_OVERSCAN, start + 1)); + const minEnd = Math.min(count, Math.max(end + Math.ceil(overscanPx / rowHeight), start + 1)); while (end < minEnd) { endHeight += dashcamRouteHeightFor(list[end]?.route) + (end > 0 ? gap : 0); end += 1; @@ -214,7 +231,7 @@ function screenrecordShouldLoadMore(scroller) { function dashcamShouldLoadMore(scroller) { if (!scroller || !dashcamState.hasMore || dashcamState.loading || dashcamState.loadingMore) return false; const remaining = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight; - return remaining <= DASHCAM_LOAD_AHEAD_PX; + return remaining <= Math.max(360, scroller.clientHeight * DASHCAM_LOAD_AHEAD_VIEWPORTS); } function screenrecordWindowFor(host, count) { @@ -382,7 +399,7 @@ function dashcamSpacerNode(height, position) { function dashcamRouteRenderKey(entry) { const route = String(entry?.route || ""); const selected = dashcamSelectedForRoute(entry || { segmentFolders: [] }).join(","); - const segments = Array.isArray(entry?.segmentFolders) ? entry.segmentFolders.join(",") : ""; + const segments = dashcamSegmentsForRoute(entry).join(","); return [ isCompactLandscapeMode() ? "landscape" : "portrait", dashcamState.expanded.has(route) ? "expanded" : "collapsed", @@ -391,6 +408,10 @@ function dashcamRouteRenderKey(entry) { entry?.dateLabel || "", entry?.latestModifiedEpoch || "", entry?.latestModifiedLabel || "", + dashcamSegmentCountForRoute(entry), + entry?.segmentsNextOffset ?? "", + entry?.segmentsHasMore ? "more" : "done", + dashcamState.loadingSegments?.has(route) ? "loading" : "idle", segments, selected, ].join("|"); @@ -492,8 +513,73 @@ function maybeLoadMoreDashcamRoutes(scroller = document.getElementById("dashcamR loadDashcamRoutes({ silent: true, append: true }).catch(() => {}); } +function dashcamSegmentsForRoute(entry) { + return Array.isArray(entry?.segmentFolders) ? entry.segmentFolders : []; +} + +function dashcamSegmentCountForRoute(entry) { + const total = Number(entry?.segmentCount); + const loaded = dashcamSegmentsForRoute(entry).length; + return Number.isFinite(total) && total >= loaded ? total : loaded; +} + +function dashcamRouteHasMoreSegments(entry) { + return Boolean(entry?.segmentsHasMore) || dashcamSegmentsForRoute(entry).length < dashcamSegmentCountForRoute(entry); +} + +function dashcamSegmentNextOffset(entry) { + const next = Number(entry?.segmentsNextOffset); + if (Number.isFinite(next) && next >= 0) return next; + return dashcamSegmentsForRoute(entry).length; +} + +function mergeDashcamSegments(existing, incoming) { + const merged = []; + const seen = new Set(); + [...(existing || []), ...(incoming || [])].forEach((segment) => { + if (!segment || seen.has(segment)) return; + seen.add(segment); + merged.push(segment); + }); + return merged.sort((a, b) => dashcamSegmentIndex(a) - dashcamSegmentIndex(b)); +} + +function mergeDashcamRoutePage(entry, existing) { + if (!entry || !existing) return entry; + const route = String(entry.route || ""); + if (!route || route !== existing.route) return entry; + const incomingSegments = dashcamSegmentsForRoute(entry); + const existingSegments = dashcamSegmentsForRoute(existing); + if (existingSegments.length <= incomingSegments.length) return entry; + + const mergedSegments = mergeDashcamSegments(incomingSegments, existingSegments); + const total = Math.max(dashcamSegmentCountForRoute(entry), mergedSegments.length); + return { + ...entry, + segmentFolders: mergedSegments, + segmentCount: total, + segmentsNextOffset: mergedSegments.length < total + ? Math.max(dashcamSegmentNextOffset(entry), dashcamSegmentNextOffset(existing), mergedSegments.length) + : null, + segmentsHasMore: mergedSegments.length < total, + }; +} + +function maybeLoadVisibleDashcamSegments(scroller = document.getElementById("dashcamRoutes")) { + if (!scroller || !isLogsPageActive()) return; + const hostRect = scroller.getBoundingClientRect(); + scroller.querySelectorAll('[data-action="load-more-segments"]').forEach((button) => { + const route = button.dataset.route || ""; + if (!route || dashcamState.loadingSegments?.has(route)) return; + const rect = button.getBoundingClientRect(); + if (rect.top <= hostRect.bottom + 160 && rect.bottom >= hostRect.top - 40) { + loadDashcamSegments(route).catch(() => {}); + } + }); +} + function dashcamSelectedForRoute(entry) { - return (entry.segmentFolders || []).filter((segment) => dashcamState.selected.has(segment)); + return dashcamSegmentsForRoute(entry).filter((segment) => dashcamState.selected.has(segment)); } function dashcamRouteCardHtml(entry, index = 0, options = {}) { @@ -501,12 +587,19 @@ function dashcamRouteCardHtml(entry, index = 0, options = {}) { const animateIndex = Number.isFinite(options.animateIndex) ? options.animateIndex : index; const route = String(entry.route || ""); const renderKey = escapeHtml(dashcamRouteRenderKey(entry)); - const segments = Array.isArray(entry.segmentFolders) ? entry.segmentFolders : []; + const segments = dashcamSegmentsForRoute(entry); + const segmentCount = dashcamSegmentCountForRoute(entry); + const loadedCount = segments.length; + const hasMoreSegments = dashcamRouteHasMoreSegments(entry); + const loadingSegments = dashcamState.loadingSegments?.has(route); const expanded = dashcamState.expanded.has(route); const compactSegments = isCompactLandscapeMode(); const shouldRenderSegments = expanded || compactSegments; const selected = dashcamSelectedForRoute(entry); const allSelected = segments.length > 0 && selected.length === segments.length; + const selectLabel = allSelected + ? getUIText(hasMoreSegments ? "deselect_loaded" : "deselect_all", "Deselect all") + : getUIText(hasMoreSegments ? "select_loaded" : "select_all", "Select all"); const representative = segments[0] || ""; const routeAttr = escapeHtml(route); const title = escapeHtml(entry.title || dashcamRouteTitle(route)); @@ -518,7 +611,7 @@ function dashcamRouteCardHtml(entry, index = 0, options = {}) {
- ${escapeHtml(getUIText("segment_count", "{count} segments", { count: segments.length }))} + ${escapeHtml(getUIText("segment_count", "{count} segments", { count: segmentCount }))} ${latest}
`; }).join("") : ""; + const segmentMore = shouldRenderSegments && hasMoreSegments + ? `` + : ""; return `
${preview} @@ -583,10 +682,10 @@ function dashcamRouteCardHtml(entry, index = 0, options = {}) {
${escapeHtml(getUIText("selected_count", "{count} selected", { count: selected.length }))} - +
-
${segmentList}
+
${segmentList}${segmentMore}
`; @@ -634,6 +733,7 @@ function renderDashcamRoutes(options = {}) { requestAnimationFrame(() => { if (!isLogsPageActive()) return; if (measureDashcamRouteHeights(host) && !dashcamState.scrollBusy) scheduleDashcamWindowRender(); + maybeLoadVisibleDashcamSegments(host); }); } @@ -660,6 +760,7 @@ function renderDashcamRoute(route) { requestAnimationFrame(() => { if (!isLogsPageActive()) return; if (measureDashcamRouteHeights(host)) scheduleDashcamWindowRender(); + maybeLoadVisibleDashcamSegments(host); }); return true; } @@ -674,9 +775,10 @@ function updateDashcamRouteSelectionUi(route) { .find((node) => node.dataset.routeCard === route); if (!card) return false; - const segments = Array.isArray(entry.segmentFolders) ? entry.segmentFolders : []; + const segments = dashcamSegmentsForRoute(entry); const selected = dashcamSelectedForRoute(entry); const allSelected = segments.length > 0 && selected.length === segments.length; + const hasMoreSegments = dashcamRouteHasMoreSegments(entry); const countEl = card.querySelector(".dashcam-selection-count"); if (countEl) countEl.textContent = getUIText("selected_count", "{count} selected", { count: selected.length }); @@ -684,7 +786,9 @@ function updateDashcamRouteSelectionUi(route) { const selectBtn = card.querySelector('[data-action="select-route"]'); if (selectBtn) { selectBtn.dataset.selected = allSelected ? "1" : "0"; - selectBtn.textContent = allSelected ? getUIText("deselect_all", "Deselect all") : getUIText("select_all", "Select all"); + selectBtn.textContent = allSelected + ? getUIText(hasMoreSegments ? "deselect_loaded" : "deselect_all", "Deselect all") + : getUIText(hasMoreSegments ? "select_loaded" : "select_all", "Select all"); } const uploadBtn = card.querySelector('[data-action="upload-selected"]'); @@ -699,6 +803,35 @@ function updateDashcamRouteSelectionUi(route) { return true; } +async function loadDashcamSegments(route) { + if (!route || dashcamState.loadingSegments?.has(route)) return; + const entry = (dashcamState.routes || []).find((item) => item.route === route); + if (!entry || !dashcamRouteHasMoreSegments(entry)) return; + dashcamState.loadingSegments.add(route); + if (!renderDashcamRoute(route)) renderDashcamRoutes({ animate: false, preserve: true }); + + try { + const offset = dashcamSegmentNextOffset(entry); + const json = await getJson(`/api/dashcam/segments/${encodeURIComponent(route)}?offset=${offset}&limit=${DASHCAM_SEGMENT_PAGE_SIZE}`); + const current = (dashcamState.routes || []).find((item) => item.route === route); + if (!current) return; + const incoming = Array.isArray(json.segments) ? json.segments : []; + current.segmentFolders = mergeDashcamSegments(dashcamSegmentsForRoute(current), incoming); + current.segmentCount = Number.isFinite(Number(json.total)) ? Number(json.total) : dashcamSegmentCountForRoute(current); + current.segmentsNextOffset = json.nextOffset == null ? current.segmentFolders.length : Number(json.nextOffset) || current.segmentFolders.length; + current.segmentsHasMore = Boolean(json.hasMore); + dashcamState.signature = dashcamRoutesSignature(dashcamState.routes); + } catch (e) { + if (isLogsPageActive()) { + showAppToast(e.message || getUIText("dashcam_load_failed", "Failed to load dashcam list"), { tone: "error" }); + } + } finally { + dashcamState.loadingSegments.delete(route); + if (!renderDashcamRoute(route)) renderDashcamRoutes({ animate: false, preserve: true }); + requestAnimationFrame(() => maybeLoadVisibleDashcamSegments()); + } +} + async function loadDashcamRoutes({ silent = false, append = false } = {}) { if (append && (!dashcamState.hasMore || dashcamState.loading || dashcamState.loadingMore)) return; const seq = ++dashcamState.loadSeq; @@ -714,8 +847,9 @@ async function loadDashcamRoutes({ silent = false, append = false } = {}) { try { const offset = append ? (dashcamState.nextOffset || dashcamState.routes.length || 0) : 0; const currentCount = dashcamState.routes.length || 0; - const limit = append ? DASHCAM_PAGE_SIZE : Math.max(DASHCAM_PAGE_SIZE, currentCount || 0); - const json = await getJson(`/api/dashcam/routes?offset=${offset}&limit=${limit}`); + const routePageSize = dashcamRoutePageSize(); + const limit = append ? routePageSize : Math.max(routePageSize, currentCount || 0); + const json = await getJson(`/api/dashcam/routes?offset=${offset}&limit=${limit}&segment_limit=${DASHCAM_SEGMENT_PAGE_SIZE}`); if (seq !== dashcamState.loadSeq) { if (append) { dashcamState.loadingMore = false; @@ -730,7 +864,9 @@ async function loadDashcamRoutes({ silent = false, append = false } = {}) { return; } const incoming = Array.isArray(json.routes) ? json.routes : []; - const routes = append ? dashcamState.routes.concat(incoming) : incoming; + const existingRoutes = new Map((dashcamState.routes || []).map((entry) => [entry.route, entry])); + const nextIncoming = append ? incoming : incoming.map((entry) => mergeDashcamRoutePage(entry, existingRoutes.get(entry.route))); + const routes = append ? dashcamState.routes.concat(nextIncoming) : nextIncoming; const nextSignature = dashcamRoutesSignature(routes); if (silent && nextSignature === dashcamState.signature) { dashcamState.loading = false; @@ -742,7 +878,7 @@ async function loadDashcamRoutes({ silent = false, append = false } = {}) { return; } const validRoutes = new Set(routes.map((entry) => entry.route)); - const validSegments = new Set(routes.flatMap((entry) => entry.segmentFolders || [])); + const validSegments = new Set(routes.flatMap((entry) => dashcamSegmentsForRoute(entry))); dashcamState.expanded = new Set(Array.from(dashcamState.expanded).filter((route) => validRoutes.has(route))); dashcamState.selected = new Set(Array.from(dashcamState.selected).filter((segment) => validSegments.has(segment))); dashcamState.routeHeights = Object.fromEntries( @@ -758,7 +894,10 @@ async function loadDashcamRoutes({ silent = false, append = false } = {}) { setDashcamLoadingMoreUi(false); renderDashcamRoutes({ animate: !silent }); if (!silent && logsScrollTops.dashcam === 0) restoreLogsScrollTop("dashcam", { reset: true }); - requestAnimationFrame(() => maybeLoadMoreDashcamRoutes()); + requestAnimationFrame(() => { + maybeLoadMoreDashcamRoutes(); + maybeLoadVisibleDashcamSegments(); + }); } catch (e) { if (seq !== dashcamState.loadSeq) { if (append) { @@ -1498,7 +1637,12 @@ function bindLogsPage() { saveLogsScrollTop("dashcam"); if (dashcamWindowNeedsRender(routesHost)) scheduleDashcamWindowRender(); maybeLoadMoreDashcamRoutes(routesHost); + maybeLoadVisibleDashcamSegments(routesHost); }, { passive: true }); + routesHost.addEventListener("scroll", (ev) => { + if (!ev.target?.closest?.(".dashcam-segment-list")) return; + maybeLoadVisibleDashcamSegments(routesHost); + }, { passive: true, capture: true }); routesHost.addEventListener("click", (ev) => { const actionEl = ev.target?.closest?.("[data-action]"); if (!actionEl) return; @@ -1519,7 +1663,7 @@ function bindLogsPage() { const entry = dashcamState.routes.find((item) => item.route === route); if (!entry) return; const shouldClear = actionEl.dataset.selected === "1"; - for (const item of entry.segmentFolders || []) { + for (const item of dashcamSegmentsForRoute(entry)) { if (shouldClear) dashcamState.selected.delete(item); else dashcamState.selected.add(item); } @@ -1528,6 +1672,10 @@ function bindLogsPage() { const entry = dashcamState.routes.find((item) => item.route === route); const targets = dashcamSelectedForRoute(entry || { segmentFolders: [] }); uploadDashcamSegments(targets).catch(() => {}); + } else if (action === "load-more-segments") { + ev.preventDefault(); + ev.stopPropagation(); + loadDashcamSegments(route).catch(() => {}); } }); routesHost.addEventListener("change", (ev) => { diff --git a/selfdrive/carrot/web/js/translations/en.js b/selfdrive/carrot/web/js/translations/en.js index 1ccb968164..37a5b2d2af 100644 --- a/selfdrive/carrot/web/js/translations/en.js +++ b/selfdrive/carrot/web/js/translations/en.js @@ -345,8 +345,12 @@ window.CarrotTranslations.register("en", { selected_count: "{count} selected", select_all: "Select all", deselect_all: "Deselect all", + select_loaded: "Select loaded", + deselect_loaded: "Deselect loaded", upload_selected: "Upload selected", segment_count: "{count} segments", + load_more_segments: "Load more", + loaded_count: "{loaded}/{total}", segment_menu: "Segment menu", show_segments: "Show segments", collapse: "Collapse", diff --git a/selfdrive/carrot/web/js/translations/ko.js b/selfdrive/carrot/web/js/translations/ko.js index d1d72b3aa0..1c37e513de 100644 --- a/selfdrive/carrot/web/js/translations/ko.js +++ b/selfdrive/carrot/web/js/translations/ko.js @@ -343,8 +343,12 @@ window.CarrotTranslations.register("ko", { selected_count: "선택 {count}개", select_all: "전체 선택", deselect_all: "전체 해제", + select_loaded: "로드된 항목 선택", + deselect_loaded: "로드된 항목 해제", upload_selected: "선택 전송", segment_count: "세그먼트 {count}개", + load_more_segments: "더 불러오기", + loaded_count: "{loaded}/{total}", segment_menu: "세그먼트 메뉴", show_segments: "세그먼트 보기", collapse: "접기", diff --git a/selfdrive/carrot/web/js/translations/zh.js b/selfdrive/carrot/web/js/translations/zh.js index 0f49a21a34..53480ffc8d 100644 --- a/selfdrive/carrot/web/js/translations/zh.js +++ b/selfdrive/carrot/web/js/translations/zh.js @@ -343,8 +343,12 @@ window.CarrotTranslations.register("zh", { selected_count: "已选 {count} 个", select_all: "全选", deselect_all: "取消全选", + select_loaded: "选择已加载", + deselect_loaded: "取消已加载", upload_selected: "发送所选", segment_count: "{count} 个片段", + load_more_segments: "加载更多", + loaded_count: "{loaded}/{total}", segment_menu: "片段菜单", show_segments: "显示片段", collapse: "收起", From 95c0e15d2dd6dacadf235330b133a39edd378022 Mon Sep 17 00:00:00 2001 From: jominki354 Date: Mon, 11 May 2026 12:03:01 +0900 Subject: [PATCH 10/23] f --- selfdrive/carrot/web/css/components.css | 20 ++++++++++---------- selfdrive/carrot/web/css/tokens.css | 2 ++ selfdrive/carrot/web/index.html | 4 ++-- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/selfdrive/carrot/web/css/components.css b/selfdrive/carrot/web/css/components.css index e69adc7ab9..a34926094a 100644 --- a/selfdrive/carrot/web/css/components.css +++ b/selfdrive/carrot/web/css/components.css @@ -490,27 +490,27 @@ body[data-page="terminal"] .app-toast-host { .btn--filled, .smallBtn.btn--filled { - background: var(--md-primary); - border-color: color-mix(in srgb, var(--md-primary) 76%, var(--md-outline-var)); - color: var(--md-on-primary); + background: var(--md-primary-action); + border-color: color-mix(in srgb, var(--md-primary-action) 78%, var(--md-outline-var)); + color: var(--md-on-primary-action); } #appDialogConfirm.btn--filled { - background: var(--md-primary); - border-color: color-mix(in srgb, var(--md-primary) 76%, var(--md-outline-var)); - color: var(--md-on-primary); + background: var(--md-primary-action); + border-color: color-mix(in srgb, var(--md-primary-action) 78%, var(--md-outline-var)); + color: var(--md-on-primary-action); box-shadow: none; } #appDialogConfirm.btn--filled:hover, #appDialogConfirm.btn--filled:focus-visible { - background: color-mix(in srgb, var(--md-primary) 88%, #fff); - border-color: color-mix(in srgb, var(--md-primary) 82%, var(--md-outline-var)); - color: var(--md-on-primary); + background: color-mix(in srgb, var(--md-primary-action) 88%, #fff); + border-color: color-mix(in srgb, var(--md-primary-action) 84%, var(--md-outline-var)); + color: var(--md-on-primary-action); } #appDialogConfirm.btn--filled:active { - background: color-mix(in srgb, var(--md-primary) 82%, #fff); + background: color-mix(in srgb, var(--md-primary-action) 84%, #1f0b00); } .btn--danger { diff --git a/selfdrive/carrot/web/css/tokens.css b/selfdrive/carrot/web/css/tokens.css index faa2f4d6fe..728c0586bc 100644 --- a/selfdrive/carrot/web/css/tokens.css +++ b/selfdrive/carrot/web/css/tokens.css @@ -20,6 +20,8 @@ /* ── Primary — Carrot Dark Orange ── */ --md-primary: #ffb06d; --md-on-primary: #3a1800; + --md-primary-action: #ff8f45; + --md-on-primary-action: #1f0b00; --md-primary-state-soft: color-mix(in srgb, var(--md-primary) 14%, transparent); --md-primary-state: color-mix(in srgb, var(--md-primary) 18%, var(--md-surface-cont-h)); --md-primary-state-strong: color-mix(in srgb, var(--md-primary) 22%, var(--md-surface-cont-hh)); diff --git a/selfdrive/carrot/web/index.html b/selfdrive/carrot/web/index.html index 120dc1ec65..de215a1334 100644 --- a/selfdrive/carrot/web/index.html +++ b/selfdrive/carrot/web/index.html @@ -69,12 +69,12 @@ background: color-mix(in srgb, var(--md-primary) 14%, transparent) !important; } - + - + From f99a237db405ac871d988cb0d8e5ed422adc21cc Mon Sep 17 00:00:00 2001 From: jominki354 Date: Mon, 11 May 2026 12:05:51 +0900 Subject: [PATCH 11/23] f --- selfdrive/carrot/web/css/components.css | 20 ++++++++++---------- selfdrive/carrot/web/css/tokens.css | 4 ++-- selfdrive/carrot/web/index.html | 4 ++-- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/selfdrive/carrot/web/css/components.css b/selfdrive/carrot/web/css/components.css index a34926094a..0f8bad4b02 100644 --- a/selfdrive/carrot/web/css/components.css +++ b/selfdrive/carrot/web/css/components.css @@ -490,27 +490,27 @@ body[data-page="terminal"] .app-toast-host { .btn--filled, .smallBtn.btn--filled { - background: var(--md-primary-action); - border-color: color-mix(in srgb, var(--md-primary-action) 78%, var(--md-outline-var)); - color: var(--md-on-primary-action); + background: var(--md-action-filled); + border-color: color-mix(in srgb, var(--md-action-filled) 78%, var(--md-outline-var)); + color: var(--md-on-action-filled); } #appDialogConfirm.btn--filled { - background: var(--md-primary-action); - border-color: color-mix(in srgb, var(--md-primary-action) 78%, var(--md-outline-var)); - color: var(--md-on-primary-action); + background: var(--md-action-filled); + border-color: color-mix(in srgb, var(--md-action-filled) 78%, var(--md-outline-var)); + color: var(--md-on-action-filled); box-shadow: none; } #appDialogConfirm.btn--filled:hover, #appDialogConfirm.btn--filled:focus-visible { - background: color-mix(in srgb, var(--md-primary-action) 88%, #fff); - border-color: color-mix(in srgb, var(--md-primary-action) 84%, var(--md-outline-var)); - color: var(--md-on-primary-action); + background: color-mix(in srgb, var(--md-action-filled) 88%, #fff); + border-color: color-mix(in srgb, var(--md-action-filled) 84%, var(--md-outline-var)); + color: var(--md-on-action-filled); } #appDialogConfirm.btn--filled:active { - background: color-mix(in srgb, var(--md-primary-action) 84%, #1f0b00); + background: color-mix(in srgb, var(--md-action-filled) 84%, #001826); } .btn--danger { diff --git a/selfdrive/carrot/web/css/tokens.css b/selfdrive/carrot/web/css/tokens.css index 728c0586bc..9f751ffd5b 100644 --- a/selfdrive/carrot/web/css/tokens.css +++ b/selfdrive/carrot/web/css/tokens.css @@ -20,8 +20,8 @@ /* ── Primary — Carrot Dark Orange ── */ --md-primary: #ffb06d; --md-on-primary: #3a1800; - --md-primary-action: #ff8f45; - --md-on-primary-action: #1f0b00; + --md-action-filled: #7dd3fc; + --md-on-action-filled: #001826; --md-primary-state-soft: color-mix(in srgb, var(--md-primary) 14%, transparent); --md-primary-state: color-mix(in srgb, var(--md-primary) 18%, var(--md-surface-cont-h)); --md-primary-state-strong: color-mix(in srgb, var(--md-primary) 22%, var(--md-surface-cont-hh)); diff --git a/selfdrive/carrot/web/index.html b/selfdrive/carrot/web/index.html index de215a1334..5165abd3d5 100644 --- a/selfdrive/carrot/web/index.html +++ b/selfdrive/carrot/web/index.html @@ -69,12 +69,12 @@ background: color-mix(in srgb, var(--md-primary) 14%, transparent) !important; } - + - + From 8b97e9430fb6d64a1285e18f2846d75043458652 Mon Sep 17 00:00:00 2001 From: jominki354 Date: Mon, 11 May 2026 12:12:17 +0900 Subject: [PATCH 12/23] ff --- selfdrive/carrot/web/css/pages/logs.css | 23 ++++ selfdrive/carrot/web/index.html | 4 +- selfdrive/carrot/web/js/pages/logs.js | 167 ++++++++++++++++++------ 3 files changed, 155 insertions(+), 39 deletions(-) diff --git a/selfdrive/carrot/web/css/pages/logs.css b/selfdrive/carrot/web/css/pages/logs.css index f5cadea460..ae7880bf74 100644 --- a/selfdrive/carrot/web/css/pages/logs.css +++ b/selfdrive/carrot/web/css/pages/logs.css @@ -386,6 +386,10 @@ .dashcam-segment-list { max-height: min(40vh, 330px); overflow: auto; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; + touch-action: pan-y; + scrollbar-gutter: stable; display: flex; flex-direction: column; gap: 8px; @@ -427,6 +431,21 @@ background: color-mix(in srgb, var(--md-surface-cont-h) 84%, var(--md-primary)); } +.dashcam-segment-tile--append { + animation: dashcam-segment-append 180ms cubic-bezier(.2, 0, 0, 1) both; +} + +@keyframes dashcam-segment-append { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + .dashcam-segment-thumb { position: relative; width: 78px; @@ -587,6 +606,10 @@ transform: none; } + .dashcam-segment-tile--append { + animation: none; + } + .logs-loading-card::before, .logs-loading-row::before { animation: none; diff --git a/selfdrive/carrot/web/index.html b/selfdrive/carrot/web/index.html index 5165abd3d5..720a89d22a 100644 --- a/selfdrive/carrot/web/index.html +++ b/selfdrive/carrot/web/index.html @@ -75,7 +75,7 @@ - + @@ -635,7 +635,7 @@

Home

- + diff --git a/selfdrive/carrot/web/js/pages/logs.js b/selfdrive/carrot/web/js/pages/logs.js index c01f34c591..49d8642a83 100644 --- a/selfdrive/carrot/web/js/pages/logs.js +++ b/selfdrive/carrot/web/js/pages/logs.js @@ -578,10 +578,79 @@ function maybeLoadVisibleDashcamSegments(scroller = document.getElementById("das }); } +function dashcamScrollableSegmentList(target) { + const list = target?.closest?.(".dashcam-segment-list"); + if (!list) return null; + return list.scrollHeight > list.clientHeight + 1 ? list : null; +} + +function bindDashcamNestedScrollGuard(routesHost) { + if (!routesHost || routesHost.dataset.nestedScrollBound === "1") return; + routesHost.dataset.nestedScrollBound = "1"; + let activeList = null; + let lastY = 0; + + const clear = () => { + activeList = null; + lastY = 0; + }; + + routesHost.addEventListener("touchstart", (ev) => { + activeList = dashcamScrollableSegmentList(ev.target); + lastY = ev.touches?.[0]?.clientY || 0; + }, { passive: true }); + + routesHost.addEventListener("touchmove", (ev) => { + if (!activeList || ev.touches.length !== 1) return; + const y = ev.touches[0].clientY; + const deltaY = lastY - y; + lastY = y; + const atTop = activeList.scrollTop <= 0; + const atBottom = activeList.scrollTop + activeList.clientHeight >= activeList.scrollHeight - 1; + ev.stopPropagation(); + if ((deltaY < 0 && atTop) || (deltaY > 0 && atBottom)) { + ev.preventDefault(); + } + }, { passive: false }); + + routesHost.addEventListener("touchend", clear, { passive: true }); + routesHost.addEventListener("touchcancel", clear, { passive: true }); +} + function dashcamSelectedForRoute(entry) { return dashcamSegmentsForRoute(entry).filter((segment) => dashcamState.selected.has(segment)); } +function dashcamSegmentTileHtml(route, segment, segmentIndex, options = {}) { + const compactSegments = options.compact === true; + const animate = options.animate === true; + const routeAttr = escapeHtml(route); + const segAttr = escapeHtml(segment); + const checked = dashcamState.selected.has(segment) ? " checked" : ""; + const tileClass = [ + "dashcam-segment-tile", + compactSegments ? "dashcam-segment-tile--compact" : "", + animate ? "dashcam-segment-tile--append" : "", + ].filter(Boolean).join(" "); + const thumbClass = compactSegments ? "dashcam-segment-thumb dashcam-segment-thumb--compact" : "dashcam-segment-thumb"; + const checkClass = compactSegments ? "dashcam-segment-check dashcam-segment-check--compact" : "dashcam-segment-check"; + return `
+
+ + +
+
+
SEG ${dashcamSegmentIndex(segment)}
+
${segAttr}
+
+ +
`; +} + function dashcamRouteCardHtml(entry, index = 0, options = {}) { const animate = options.animate !== false; const animateIndex = Number.isFinite(options.animateIndex) ? options.animateIndex : index; @@ -625,40 +694,7 @@ function dashcamRouteCardHtml(entry, index = 0, options = {}) { ` : ""; const segmentList = shouldRenderSegments ? segments.map((segment, segmentIndex) => { - const segAttr = escapeHtml(segment); - const checked = dashcamState.selected.has(segment) ? " checked" : ""; - if (compactSegments) { - return `
-
- - -
-
-
SEG ${dashcamSegmentIndex(segment)}
-
${segAttr}
-
- -
`; - } - return `
-
- - -
-
-
SEG ${dashcamSegmentIndex(segment)}
-
${segAttr}
-
- -
`; + return dashcamSegmentTileHtml(route, segment, segmentIndex, { compact: compactSegments }); }).join("") : ""; const segmentMore = shouldRenderSegments && hasMoreSegments ? `` + const segmentLoader = shouldRenderSegments && hasMoreSegments + ? `` : ""; return `
@@ -721,7 +718,7 @@ function dashcamRouteCardHtml(entry, index = 0, options = {}) { -
${segmentList}${segmentMore}
+
${segmentList}${segmentLoader}
`; @@ -839,21 +836,20 @@ function updateDashcamRouteSelectionUi(route) { return true; } -function updateDashcamSegmentMoreUi(route, loading = false) { +function updateDashcamSegmentLoaderUi(route, loading = false) { const host = document.getElementById("dashcamRoutes"); const entry = (dashcamState.routes || []).find((item) => item.route === route); if (!host || !entry) return false; const card = Array.from(host.querySelectorAll("[data-route-card]")) .find((node) => node.dataset.routeCard === route); if (!card) return false; - const button = card.querySelector('[data-action="load-more-segments"]'); - if (!button) return false; - const loaded = dashcamSegmentsForRoute(entry).length; - const total = dashcamSegmentCountForRoute(entry); - button.disabled = Boolean(loading); - button.firstChild.textContent = loading ? getUIText("loading", "Loading...") : getUIText("load_more_segments", "Load more"); - const count = button.querySelector("span"); - if (count) count.textContent = getUIText("loaded_count", "{loaded}/{total}", { loaded, total }); + const loader = card.querySelector("[data-segment-loader]"); + if (!loader) return false; + if (!dashcamRouteHasMoreSegments(entry)) { + loader.remove(); + return true; + } + loader.classList.toggle("is-loading", Boolean(loading)); return true; } @@ -868,18 +864,20 @@ function appendDashcamSegmentsToRoute(route, newSegments, startIndex = 0) { if (!card || !list) return false; const scrollTop = list.scrollTop; + const wasScrollable = list.scrollHeight > list.clientHeight + 1; + const wasNearBottom = wasScrollable && (list.scrollHeight - list.scrollTop - list.clientHeight <= 48); const activeSegment = document.activeElement?.closest?.("[data-segment]")?.dataset.segment || ""; const compact = isCompactLandscapeMode(); const template = document.createElement("template"); template.innerHTML = newSegments .map((segment, offset) => dashcamSegmentTileHtml(route, segment, startIndex + offset, { compact, animate: true })) .join(""); - const moreButton = list.querySelector('[data-action="load-more-segments"]'); - list.insertBefore(template.content, moreButton || null); - list.scrollTop = scrollTop; + const loader = list.querySelector("[data-segment-loader]"); + list.insertBefore(template.content, loader || null); hydrateLogsLazyImages(list); updateDashcamRouteSelectionUi(route); - updateDashcamSegmentMoreUi(route, false); + updateDashcamSegmentLoaderUi(route, false); + list.scrollTop = wasNearBottom ? Math.max(0, list.scrollHeight - list.clientHeight) : scrollTop; if (activeSegment) { Array.from(card.querySelectorAll("[data-segment]")) .find((node) => node.dataset.segment === activeSegment) @@ -895,7 +893,7 @@ async function loadDashcamSegments(route) { if (!entry || !dashcamRouteHasMoreSegments(entry)) return; const previousCount = dashcamSegmentsForRoute(entry).length; dashcamState.loadingSegments.add(route); - if (!updateDashcamSegmentMoreUi(route, true) && !renderDashcamRoute(route)) { + if (!updateDashcamSegmentLoaderUi(route, true) && !renderDashcamRoute(route)) { renderDashcamRoutes({ animate: false, preserve: true }); } @@ -919,7 +917,7 @@ async function loadDashcamSegments(route) { } } finally { dashcamState.loadingSegments.delete(route); - if (!updateDashcamSegmentMoreUi(route, false) && !renderDashcamRoute(route)) renderDashcamRoutes({ animate: false, preserve: true }); + if (!updateDashcamSegmentLoaderUi(route, false) && !renderDashcamRoute(route)) renderDashcamRoutes({ animate: false, preserve: true }); requestAnimationFrame(() => maybeLoadVisibleDashcamSegments()); } } @@ -1022,7 +1020,10 @@ function markDashcamScrollBusy() { if (dashcamState.scrollTimer) window.clearTimeout(dashcamState.scrollTimer); dashcamState.scrollTimer = window.setTimeout(() => { dashcamState.scrollBusy = false; - if (isLogsPageActive() && logsActiveTab === "dashcam") scheduleDashcamWindowRender(); + if (isLogsPageActive() && logsActiveTab === "dashcam") { + const host = getLogsScroller("dashcam"); + if (dashcamWindowNeedsRender(host)) scheduleDashcamWindowRender(); + } }, 380); } @@ -1765,10 +1766,6 @@ function bindLogsPage() { const entry = dashcamState.routes.find((item) => item.route === route); const targets = dashcamSelectedForRoute(entry || { segmentFolders: [] }); uploadDashcamSegments(targets).catch(() => {}); - } else if (action === "load-more-segments") { - ev.preventDefault(); - ev.stopPropagation(); - loadDashcamSegments(route).catch(() => {}); } }); routesHost.addEventListener("change", (ev) => { diff --git a/selfdrive/carrot/web/js/translations/en.js b/selfdrive/carrot/web/js/translations/en.js index 37a5b2d2af..10eb9a51c1 100644 --- a/selfdrive/carrot/web/js/translations/en.js +++ b/selfdrive/carrot/web/js/translations/en.js @@ -349,8 +349,6 @@ window.CarrotTranslations.register("en", { deselect_loaded: "Deselect loaded", upload_selected: "Upload selected", segment_count: "{count} segments", - load_more_segments: "Load more", - loaded_count: "{loaded}/{total}", segment_menu: "Segment menu", show_segments: "Show segments", collapse: "Collapse", diff --git a/selfdrive/carrot/web/js/translations/ko.js b/selfdrive/carrot/web/js/translations/ko.js index 1c37e513de..2860e444b9 100644 --- a/selfdrive/carrot/web/js/translations/ko.js +++ b/selfdrive/carrot/web/js/translations/ko.js @@ -347,8 +347,6 @@ window.CarrotTranslations.register("ko", { deselect_loaded: "로드된 항목 해제", upload_selected: "선택 전송", segment_count: "세그먼트 {count}개", - load_more_segments: "더 불러오기", - loaded_count: "{loaded}/{total}", segment_menu: "세그먼트 메뉴", show_segments: "세그먼트 보기", collapse: "접기", diff --git a/selfdrive/carrot/web/js/translations/zh.js b/selfdrive/carrot/web/js/translations/zh.js index 53480ffc8d..89006c4eb4 100644 --- a/selfdrive/carrot/web/js/translations/zh.js +++ b/selfdrive/carrot/web/js/translations/zh.js @@ -347,8 +347,6 @@ window.CarrotTranslations.register("zh", { deselect_loaded: "取消已加载", upload_selected: "发送所选", segment_count: "{count} 个片段", - load_more_segments: "加载更多", - loaded_count: "{loaded}/{total}", segment_menu: "片段菜单", show_segments: "显示片段", collapse: "收起", From 679361de3cfb8c816bf03ecad21e22f271175d7c Mon Sep 17 00:00:00 2001 From: jominki354 Date: Mon, 11 May 2026 12:23:09 +0900 Subject: [PATCH 14/23] f --- selfdrive/carrot/web/css/pages/logs.css | 5 +++-- selfdrive/carrot/web/index.html | 4 ++-- selfdrive/carrot/web/js/pages/logs.js | 12 +++++++----- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/selfdrive/carrot/web/css/pages/logs.css b/selfdrive/carrot/web/css/pages/logs.css index 4e5de2d31e..e61dcaf536 100644 --- a/selfdrive/carrot/web/css/pages/logs.css +++ b/selfdrive/carrot/web/css/pages/logs.css @@ -304,7 +304,7 @@ .dashcam-route-main { min-width: 0; - padding: 12px; + padding: 12px 18px 12px 12px; } .dashcam-route-head { @@ -348,6 +348,7 @@ height: 34px; border-radius: 999px; flex: 0 0 auto; + margin-right: 2px; } .dashcam-segments { @@ -1196,7 +1197,7 @@ } .dashcam-route-main { - padding: 10px 12px 12px; + padding: 10px 18px 12px 12px; } .dashcam-route-head { diff --git a/selfdrive/carrot/web/index.html b/selfdrive/carrot/web/index.html index ddd5273be7..d275a10f6f 100644 --- a/selfdrive/carrot/web/index.html +++ b/selfdrive/carrot/web/index.html @@ -75,7 +75,7 @@ - + @@ -635,7 +635,7 @@

Home

- + diff --git a/selfdrive/carrot/web/js/pages/logs.js b/selfdrive/carrot/web/js/pages/logs.js index 43c568e30f..7677d381fb 100644 --- a/selfdrive/carrot/web/js/pages/logs.js +++ b/selfdrive/carrot/web/js/pages/logs.js @@ -877,7 +877,11 @@ function appendDashcamSegmentsToRoute(route, newSegments, startIndex = 0) { hydrateLogsLazyImages(list); updateDashcamRouteSelectionUi(route); updateDashcamSegmentLoaderUi(route, false); - list.scrollTop = wasNearBottom ? Math.max(0, list.scrollHeight - list.clientHeight) : scrollTop; + const nextTop = wasNearBottom ? Math.max(0, list.scrollHeight - list.clientHeight) : scrollTop; + list.scrollTop = nextTop; + requestAnimationFrame(() => { + if (list.isConnected) list.scrollTop = nextTop; + }); if (activeSegment) { Array.from(card.querySelectorAll("[data-segment]")) .find((node) => node.dataset.segment === activeSegment) @@ -893,9 +897,7 @@ async function loadDashcamSegments(route) { if (!entry || !dashcamRouteHasMoreSegments(entry)) return; const previousCount = dashcamSegmentsForRoute(entry).length; dashcamState.loadingSegments.add(route); - if (!updateDashcamSegmentLoaderUi(route, true) && !renderDashcamRoute(route)) { - renderDashcamRoutes({ animate: false, preserve: true }); - } + updateDashcamSegmentLoaderUi(route, true); try { const offset = dashcamSegmentNextOffset(entry); @@ -917,7 +919,7 @@ async function loadDashcamSegments(route) { } } finally { dashcamState.loadingSegments.delete(route); - if (!updateDashcamSegmentLoaderUi(route, false) && !renderDashcamRoute(route)) renderDashcamRoutes({ animate: false, preserve: true }); + updateDashcamSegmentLoaderUi(route, false); requestAnimationFrame(() => maybeLoadVisibleDashcamSegments()); } } From 6b6197f9355c448c7e1585bd23e9f0bef267d391 Mon Sep 17 00:00:00 2001 From: jominki354 Date: Mon, 11 May 2026 12:39:00 +0900 Subject: [PATCH 15/23] f --- selfdrive/carrot/web/css/pages/logs.css | 13 ++-- selfdrive/carrot/web/index.html | 6 +- selfdrive/carrot/web/js/pages/branch.js | 1 + selfdrive/carrot/web/js/pages/logs.js | 79 +++++++++++++++++++++---- 4 files changed, 76 insertions(+), 23 deletions(-) diff --git a/selfdrive/carrot/web/css/pages/logs.css b/selfdrive/carrot/web/css/pages/logs.css index e61dcaf536..8dbac8c041 100644 --- a/selfdrive/carrot/web/css/pages/logs.css +++ b/selfdrive/carrot/web/css/pages/logs.css @@ -184,6 +184,7 @@ min-height: 0; overflow: auto; overscroll-behavior: contain; + overflow-anchor: none; scrollbar-gutter: stable; padding: 6px var(--logs-content-inline) calc(var(--nav-bar-height) + 16px + env(safe-area-inset-bottom, 0px)); display: flex; @@ -211,8 +212,6 @@ border: 1px solid color-mix(in srgb, var(--md-outline-var) 50%, transparent); background: var(--md-surface-cont-h); color: var(--md-on-surface); - content-visibility: auto; - contain-intrinsic-size: auto 300px; } .dashcam-route-media { @@ -304,7 +303,7 @@ .dashcam-route-main { min-width: 0; - padding: 12px 18px 12px 12px; + padding: 12px 28px 12px 12px; } .dashcam-route-head { @@ -348,7 +347,7 @@ height: 34px; border-radius: 999px; flex: 0 0 auto; - margin-right: 2px; + margin-right: 8px; } .dashcam-segments { @@ -388,6 +387,7 @@ max-height: min(40vh, 330px); overflow: auto; overscroll-behavior: contain; + overflow-anchor: none; -webkit-overflow-scrolling: touch; touch-action: pan-y; scrollbar-gutter: stable; @@ -403,6 +403,7 @@ height: 16px; position: relative; overflow: hidden; + overflow-anchor: none; border-radius: 999px; background: transparent; } @@ -432,8 +433,6 @@ border: 1px solid color-mix(in srgb, var(--md-outline-var) 42%, transparent); background: var(--md-surface-cont); cursor: pointer; - content-visibility: auto; - contain-intrinsic-size: auto 70px; } .dashcam-segment-tile:hover { @@ -1197,7 +1196,7 @@ } .dashcam-route-main { - padding: 10px 18px 12px 12px; + padding: 10px 28px 12px 12px; } .dashcam-route-head { diff --git a/selfdrive/carrot/web/index.html b/selfdrive/carrot/web/index.html index d275a10f6f..e3a9644642 100644 --- a/selfdrive/carrot/web/index.html +++ b/selfdrive/carrot/web/index.html @@ -75,7 +75,7 @@ - + @@ -634,8 +634,8 @@

Home

- - + + diff --git a/selfdrive/carrot/web/js/pages/branch.js b/selfdrive/carrot/web/js/pages/branch.js index 3cca67c5de..cee781d693 100644 --- a/selfdrive/carrot/web/js/pages/branch.js +++ b/selfdrive/carrot/web/js/pages/branch.js @@ -375,6 +375,7 @@ const dashcamState = { refreshTimer: null, loadingMore: false, loadingSegments: new Set(), + segmentScrollTops: Object.create(null), scrollBusy: false, scrollTimer: null, renderFrame: 0, diff --git a/selfdrive/carrot/web/js/pages/logs.js b/selfdrive/carrot/web/js/pages/logs.js index 7677d381fb..259ab8679f 100644 --- a/selfdrive/carrot/web/js/pages/logs.js +++ b/selfdrive/carrot/web/js/pages/logs.js @@ -396,6 +396,38 @@ function dashcamSpacerNode(height, position) { return node; } +function dashcamSegmentListRoute(list) { + return list?.closest?.("[data-route-card]")?.dataset.routeCard || ""; +} + +function rememberDashcamSegmentScroll(list) { + const route = dashcamSegmentListRoute(list); + if (!route || !dashcamState.segmentScrollTops) return; + dashcamState.segmentScrollTops[route] = Math.max(0, list.scrollTop || 0); +} + +function rememberVisibleDashcamSegmentScrolls(host = document.getElementById("dashcamRoutes")) { + if (!host) return; + host.querySelectorAll(".dashcam-segment-list").forEach((list) => rememberDashcamSegmentScroll(list)); +} + +function restoreDashcamSegmentScroll(list) { + const route = dashcamSegmentListRoute(list); + if (!route || !dashcamState.segmentScrollTops) return; + const nextTop = Number(dashcamState.segmentScrollTops[route]); + if (!Number.isFinite(nextTop) || nextTop <= 0) return; + list.scrollTop = nextTop; +} + +function restoreVisibleDashcamSegmentScrolls(host = document.getElementById("dashcamRoutes")) { + if (!host) return; + host.querySelectorAll(".dashcam-segment-list").forEach((list) => restoreDashcamSegmentScroll(list)); + requestAnimationFrame(() => { + if (!host.isConnected) return; + host.querySelectorAll(".dashcam-segment-list").forEach((list) => restoreDashcamSegmentScroll(list)); + }); +} + function dashcamRouteRenderKey(entry) { const route = String(entry?.route || ""); const selected = dashcamSelectedForRoute(entry || { segmentFolders: [] }).join(","); @@ -437,6 +469,7 @@ function dashcamRouteNode(entry, index, existingCards, options = {}) { } function patchDashcamWindow(host, routes, view, options = {}) { + rememberVisibleDashcamSegmentScrolls(host); const existingCards = new Map( Array.from(host.querySelectorAll("[data-route-card]")) .map((node) => [node.dataset.routeCard || "", node]) @@ -453,6 +486,7 @@ function patchDashcamWindow(host, routes, view, options = {}) { if (bottomSpacer) frag.appendChild(bottomSpacer); unobserveLogsLazyImages(host); host.replaceChildren(frag); + restoreVisibleDashcamSegmentScrolls(host); } function measureDashcamRouteHeights(host) { @@ -598,10 +632,13 @@ function bindDashcamNestedScrollGuard(routesHost) { routesHost.addEventListener("touchstart", (ev) => { activeList = dashcamScrollableSegmentList(ev.target); lastY = ev.touches?.[0]?.clientY || 0; + if (activeList) markDashcamScrollBusy({ renderOnIdle: false }); }, { passive: true }); routesHost.addEventListener("touchmove", (ev) => { if (!activeList || ev.touches.length !== 1) return; + markDashcamScrollBusy({ renderOnIdle: false }); + rememberDashcamSegmentScroll(activeList); const y = ev.touches[0].clientY; const deltaY = lastY - y; lastY = y; @@ -613,8 +650,14 @@ function bindDashcamNestedScrollGuard(routesHost) { } }, { passive: false }); - routesHost.addEventListener("touchend", clear, { passive: true }); - routesHost.addEventListener("touchcancel", clear, { passive: true }); + routesHost.addEventListener("touchend", () => { + if (activeList) rememberDashcamSegmentScroll(activeList); + clear(); + }, { passive: true }); + routesHost.addEventListener("touchcancel", () => { + if (activeList) rememberDashcamSegmentScroll(activeList); + clear(); + }, { passive: true }); } function dashcamSelectedForRoute(entry) { @@ -866,7 +909,8 @@ function appendDashcamSegmentsToRoute(route, newSegments, startIndex = 0) { const scrollTop = list.scrollTop; const wasScrollable = list.scrollHeight > list.clientHeight + 1; const wasNearBottom = wasScrollable && (list.scrollHeight - list.scrollTop - list.clientHeight <= 48); - const activeSegment = document.activeElement?.closest?.("[data-segment]")?.dataset.segment || ""; + rememberDashcamSegmentScroll(list); + markDashcamScrollBusy({ renderOnIdle: false }); const compact = isCompactLandscapeMode(); const template = document.createElement("template"); template.innerHTML = newSegments @@ -879,14 +923,12 @@ function appendDashcamSegmentsToRoute(route, newSegments, startIndex = 0) { updateDashcamSegmentLoaderUi(route, false); const nextTop = wasNearBottom ? Math.max(0, list.scrollHeight - list.clientHeight) : scrollTop; list.scrollTop = nextTop; + rememberDashcamSegmentScroll(list); requestAnimationFrame(() => { - if (list.isConnected) list.scrollTop = nextTop; + if (!list.isConnected) return; + list.scrollTop = nextTop; + rememberDashcamSegmentScroll(list); }); - if (activeSegment) { - Array.from(card.querySelectorAll("[data-segment]")) - .find((node) => node.dataset.segment === activeSegment) - ?.focus?.({ preventScroll: true }); - } card.dataset.renderKey = dashcamRouteRenderKey(entry); return true; } @@ -897,6 +939,7 @@ async function loadDashcamSegments(route) { if (!entry || !dashcamRouteHasMoreSegments(entry)) return; const previousCount = dashcamSegmentsForRoute(entry).length; dashcamState.loadingSegments.add(route); + markDashcamScrollBusy({ renderOnIdle: false }); updateDashcamSegmentLoaderUi(route, true); try { @@ -976,6 +1019,9 @@ async function loadDashcamRoutes({ silent = false, append = false } = {}) { dashcamState.routeHeights = Object.fromEntries( Object.entries(dashcamState.routeHeights || {}).filter(([route]) => validRoutes.has(route)) ); + dashcamState.segmentScrollTops = Object.fromEntries( + Object.entries(dashcamState.segmentScrollTops || {}).filter(([route]) => validRoutes.has(route)) + ); dashcamState.routes = routes; dashcamState.signature = nextSignature; dashcamState.total = Number.isFinite(Number(json.total)) ? Number(json.total) : routes.length; @@ -1013,16 +1059,19 @@ function startDashcamAutoRefresh() { dashcamState.refreshTimer = window.setInterval(() => { if (CURRENT_PAGE !== "logs" || dashcamState.scrollBusy) return; if (logsActiveTab === "screen") loadScreenrecordVideos({ silent: true }).catch(() => {}); - else if (!dashcamState.loading && !dashcamState.loadingMore) loadDashcamRoutes({ silent: true }).catch(() => {}); + else if (!dashcamState.loading && !dashcamState.loadingMore && !dashcamState.loadingSegments?.size) { + loadDashcamRoutes({ silent: true }).catch(() => {}); + } }, 10000); } -function markDashcamScrollBusy() { +function markDashcamScrollBusy(options = {}) { + const renderOnIdle = options.renderOnIdle !== false; dashcamState.scrollBusy = true; if (dashcamState.scrollTimer) window.clearTimeout(dashcamState.scrollTimer); dashcamState.scrollTimer = window.setTimeout(() => { dashcamState.scrollBusy = false; - if (isLogsPageActive() && logsActiveTab === "dashcam") { + if (renderOnIdle && isLogsPageActive() && logsActiveTab === "dashcam") { const host = getLogsScroller("dashcam"); if (dashcamWindowNeedsRender(host)) scheduleDashcamWindowRender(); } @@ -1655,6 +1704,7 @@ function handleLogsPageChange(event) { screenrecordState.loadSeq += 1; dashcamState.loading = false; dashcamState.loadingMore = false; + dashcamState.loadingSegments?.clear?.(); setDashcamLoadingMoreUi(false); screenrecordState.loading = false; dashcamState.scrollBusy = false; @@ -1736,7 +1786,10 @@ function bindLogsPage() { maybeLoadVisibleDashcamSegments(routesHost); }, { passive: true }); routesHost.addEventListener("scroll", (ev) => { - if (!ev.target?.closest?.(".dashcam-segment-list")) return; + const segmentList = ev.target?.closest?.(".dashcam-segment-list"); + if (!segmentList) return; + markDashcamScrollBusy({ renderOnIdle: false }); + rememberDashcamSegmentScroll(segmentList); maybeLoadVisibleDashcamSegments(routesHost); }, { passive: true, capture: true }); routesHost.addEventListener("click", (ev) => { From 6267770335a89706b99858b99ef01616912332b4 Mon Sep 17 00:00:00 2001 From: jominki354 Date: Mon, 11 May 2026 12:52:42 +0900 Subject: [PATCH 16/23] f --- selfdrive/carrot/web/css/pages/logs.css | 27 +++-- selfdrive/carrot/web/js/pages/logs.js | 128 +++++++++++++----------- 2 files changed, 89 insertions(+), 66 deletions(-) diff --git a/selfdrive/carrot/web/css/pages/logs.css b/selfdrive/carrot/web/css/pages/logs.css index 8dbac8c041..deed008a7e 100644 --- a/selfdrive/carrot/web/css/pages/logs.css +++ b/selfdrive/carrot/web/css/pages/logs.css @@ -400,25 +400,33 @@ .dashcam-segment-loader { flex: 0 0 auto; width: 100%; - height: 16px; + /* Fixed height in both states — prevents layout shift / "shake" when the + loader toggles on near the bottom of the list. */ + height: 22px; position: relative; overflow: hidden; overflow-anchor: none; border-radius: 999px; background: transparent; + transition: background-color 160ms ease-out; } .dashcam-segment-loader.is-loading { - height: 22px; background: color-mix(in srgb, var(--md-outline-var) 18%, transparent); } -.dashcam-segment-loader.is-loading::before { +.dashcam-segment-loader::before { content: ""; position: absolute; inset: 8px 20%; border-radius: inherit; background: var(--md-primary); + opacity: 0; + transition: opacity 160ms ease-out; +} + +.dashcam-segment-loader.is-loading::before { + opacity: 1; animation: dashcam-segment-loader 820ms ease-in-out infinite; } @@ -433,11 +441,17 @@ border: 1px solid color-mix(in srgb, var(--md-outline-var) 42%, transparent); background: var(--md-surface-cont); cursor: pointer; + /* Skip layout/paint for offscreen tiles so long lists scroll smoothly. + contain-intrinsic-size reserves space so virtual scroll height stays stable. */ + content-visibility: auto; + contain-intrinsic-size: 70px 100%; } -.dashcam-segment-tile:hover { - border-color: color-mix(in srgb, var(--md-primary) 44%, var(--md-outline-var)); - background: color-mix(in srgb, var(--md-surface-cont-h) 84%, var(--md-primary)); +@media (hover: hover) { + .dashcam-segment-tile:hover { + border-color: color-mix(in srgb, var(--md-primary) 44%, var(--md-outline-var)); + background: color-mix(in srgb, var(--md-surface-cont-h) 84%, var(--md-primary)); + } } .dashcam-segment-tile--append { @@ -1305,6 +1319,7 @@ min-height: 46px; gap: 8px; padding: 5px 8px 5px 5px; + contain-intrinsic-size: 46px 100%; } .dashcam-segment-thumb--compact { diff --git a/selfdrive/carrot/web/js/pages/logs.js b/selfdrive/carrot/web/js/pages/logs.js index 259ab8679f..86b2b90fd2 100644 --- a/selfdrive/carrot/web/js/pages/logs.js +++ b/selfdrive/carrot/web/js/pages/logs.js @@ -485,10 +485,19 @@ function patchDashcamWindow(host, routes, view, options = {}) { }); if (bottomSpacer) frag.appendChild(bottomSpacer); unobserveLogsLazyImages(host); + unobserveDashcamSegmentLoaders(host); host.replaceChildren(frag); restoreVisibleDashcamSegmentScrolls(host); } +function unobserveDashcamSegmentLoaders(host) { + if (!dashcamSegmentLoaderObserver || !host) return; + host.querySelectorAll?.("[data-segment-loader]").forEach((loader) => { + dashcamSegmentLoaderObserver.unobserve(loader); + delete loader.dataset.observed; + }); +} + function measureDashcamRouteHeights(host) { if (!host) return false; const gap = dashcamRouteGap(host); @@ -599,8 +608,38 @@ function mergeDashcamRoutePage(entry, existing) { }; } +let dashcamSegmentLoaderObserver = null; +function ensureDashcamSegmentLoaderObserver(scroller) { + if (!scroller || !("IntersectionObserver" in window)) return null; + if (dashcamSegmentLoaderObserver && dashcamSegmentLoaderObserver._root === scroller) { + return dashcamSegmentLoaderObserver; + } + if (dashcamSegmentLoaderObserver) dashcamSegmentLoaderObserver.disconnect(); + dashcamSegmentLoaderObserver = new IntersectionObserver((entries) => { + if (!isLogsPageActive()) return; + entries.forEach((entry) => { + if (!entry.isIntersecting) return; + const route = entry.target.dataset?.route || ""; + if (!route || dashcamState.loadingSegments?.has(route)) return; + loadDashcamSegments(route).catch(() => {}); + }); + }, { root: scroller, rootMargin: "240px 0px", threshold: 0.01 }); + dashcamSegmentLoaderObserver._root = scroller; + return dashcamSegmentLoaderObserver; +} + function maybeLoadVisibleDashcamSegments(scroller = document.getElementById("dashcamRoutes")) { if (!scroller || !isLogsPageActive()) return; + const observer = ensureDashcamSegmentLoaderObserver(scroller); + if (observer) { + scroller.querySelectorAll("[data-segment-loader]").forEach((loader) => { + if (loader.dataset.observed === "1") return; + loader.dataset.observed = "1"; + observer.observe(loader); + }); + return; + } + // Fallback for environments without IntersectionObserver const hostRect = scroller.getBoundingClientRect(); scroller.querySelectorAll("[data-segment-loader]").forEach((loader) => { const route = loader.dataset.route || ""; @@ -612,52 +651,19 @@ function maybeLoadVisibleDashcamSegments(scroller = document.getElementById("das }); } -function dashcamScrollableSegmentList(target) { - const list = target?.closest?.(".dashcam-segment-list"); - if (!list) return null; - return list.scrollHeight > list.clientHeight + 1 ? list : null; -} - -function bindDashcamNestedScrollGuard(routesHost) { - if (!routesHost || routesHost.dataset.nestedScrollBound === "1") return; - routesHost.dataset.nestedScrollBound = "1"; - let activeList = null; - let lastY = 0; - - const clear = () => { - activeList = null; - lastY = 0; - }; - - routesHost.addEventListener("touchstart", (ev) => { - activeList = dashcamScrollableSegmentList(ev.target); - lastY = ev.touches?.[0]?.clientY || 0; - if (activeList) markDashcamScrollBusy({ renderOnIdle: false }); - }, { passive: true }); - - routesHost.addEventListener("touchmove", (ev) => { - if (!activeList || ev.touches.length !== 1) return; - markDashcamScrollBusy({ renderOnIdle: false }); - rememberDashcamSegmentScroll(activeList); - const y = ev.touches[0].clientY; - const deltaY = lastY - y; - lastY = y; - const atTop = activeList.scrollTop <= 0; - const atBottom = activeList.scrollTop + activeList.clientHeight >= activeList.scrollHeight - 1; - ev.stopPropagation(); - if ((deltaY < 0 && atTop) || (deltaY > 0 && atBottom)) { - ev.preventDefault(); - } - }, { passive: false }); - - routesHost.addEventListener("touchend", () => { - if (activeList) rememberDashcamSegmentScroll(activeList); - clear(); - }, { passive: true }); - routesHost.addEventListener("touchcancel", () => { - if (activeList) rememberDashcamSegmentScroll(activeList); - clear(); - }, { passive: true }); +let segmentListPersistFrame = 0; +const segmentListPersistQueue = new Set(); +function scheduleSegmentListScrollPersist(list) { + if (!list) return; + segmentListPersistQueue.add(list); + if (segmentListPersistFrame) return; + segmentListPersistFrame = requestAnimationFrame(() => { + segmentListPersistFrame = 0; + segmentListPersistQueue.forEach((node) => { + if (node.isConnected) rememberDashcamSegmentScroll(node); + }); + segmentListPersistQueue.clear(); + }); } function dashcamSelectedForRoute(entry) { @@ -909,26 +915,32 @@ function appendDashcamSegmentsToRoute(route, newSegments, startIndex = 0) { const scrollTop = list.scrollTop; const wasScrollable = list.scrollHeight > list.clientHeight + 1; const wasNearBottom = wasScrollable && (list.scrollHeight - list.scrollTop - list.clientHeight <= 48); + const isScrolling = Boolean(dashcamState.scrollBusy); rememberDashcamSegmentScroll(list); - markDashcamScrollBusy({ renderOnIdle: false }); const compact = isCompactLandscapeMode(); const template = document.createElement("template"); + // Don't animate tile entry while the user is actively scrolling — the + // simultaneous animation + scrollTop adjustment is what causes "shake". + const animate = !isScrolling; template.innerHTML = newSegments - .map((segment, offset) => dashcamSegmentTileHtml(route, segment, startIndex + offset, { compact, animate: true })) + .map((segment, offset) => dashcamSegmentTileHtml(route, segment, startIndex + offset, { compact, animate })) .join(""); const loader = list.querySelector("[data-segment-loader]"); list.insertBefore(template.content, loader || null); hydrateLogsLazyImages(list); updateDashcamRouteSelectionUi(route); updateDashcamSegmentLoaderUi(route, false); - const nextTop = wasNearBottom ? Math.max(0, list.scrollHeight - list.clientHeight) : scrollTop; - list.scrollTop = nextTop; - rememberDashcamSegmentScroll(list); - requestAnimationFrame(() => { - if (!list.isConnected) return; + // Pin to bottom only when the user wasn't actively scrolling — otherwise + // setting scrollTop fights inertia and produces a visible jump/shake. + if (wasNearBottom && !isScrolling) { + const nextTop = Math.max(0, list.scrollHeight - list.clientHeight); list.scrollTop = nextTop; rememberDashcamSegmentScroll(list); - }); + } else { + // Preserve current position; browser will keep inertia smooth. + list.scrollTop = scrollTop; + rememberDashcamSegmentScroll(list); + } card.dataset.renderKey = dashcamRouteRenderKey(entry); return true; } @@ -1777,20 +1789,16 @@ function bindLogsPage() { if (routesHost && routesHost.dataset.bound !== "1") { routesHost.dataset.bound = "1"; - bindDashcamNestedScrollGuard(routesHost); routesHost.addEventListener("scroll", () => { markDashcamScrollBusy(); saveLogsScrollTop("dashcam"); if (dashcamWindowNeedsRender(routesHost)) scheduleDashcamWindowRender(); maybeLoadMoreDashcamRoutes(routesHost); - maybeLoadVisibleDashcamSegments(routesHost); }, { passive: true }); routesHost.addEventListener("scroll", (ev) => { const segmentList = ev.target?.closest?.(".dashcam-segment-list"); - if (!segmentList) return; - markDashcamScrollBusy({ renderOnIdle: false }); - rememberDashcamSegmentScroll(segmentList); - maybeLoadVisibleDashcamSegments(routesHost); + if (!segmentList || segmentList === routesHost) return; + scheduleSegmentListScrollPersist(segmentList); }, { passive: true, capture: true }); routesHost.addEventListener("click", (ev) => { const actionEl = ev.target?.closest?.("[data-action]"); From 6af25d23546c79a00dc96d3f0ff7c257a979adcf Mon Sep 17 00:00:00 2001 From: jominki354 Date: Mon, 11 May 2026 12:58:33 +0900 Subject: [PATCH 17/23] fr --- selfdrive/carrot/web/css/pages/logs.css | 26 +++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/selfdrive/carrot/web/css/pages/logs.css b/selfdrive/carrot/web/css/pages/logs.css index deed008a7e..3aae1eb436 100644 --- a/selfdrive/carrot/web/css/pages/logs.css +++ b/selfdrive/carrot/web/css/pages/logs.css @@ -390,11 +390,33 @@ overflow-anchor: none; -webkit-overflow-scrolling: touch; touch-action: pan-y; - scrollbar-gutter: stable; + /* Symmetric breathing room on both sides; no gutter reservation so the + visual layout is identical regardless of whether the scrollbar shows. */ + padding-inline: 4px; + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--md-outline-var) 50%, transparent) transparent; display: flex; flex-direction: column; gap: 8px; - padding-right: 2px; +} + +/* Thin, semi-transparent overlay-style scrollbar — does not push content. */ +.dashcam-segment-list::-webkit-scrollbar { + width: 4px; + height: 4px; +} + +.dashcam-segment-list::-webkit-scrollbar-track { + background: transparent; +} + +.dashcam-segment-list::-webkit-scrollbar-thumb { + border-radius: 999px; + background: color-mix(in srgb, var(--md-outline-var) 40%, transparent); +} + +.dashcam-segment-list::-webkit-scrollbar-thumb:hover { + background: color-mix(in srgb, var(--md-outline-var) 70%, transparent); } .dashcam-segment-loader { From b678c1c2e91f85a977230266b6dd0ea32fa57966 Mon Sep 17 00:00:00 2001 From: jominki354 Date: Mon, 11 May 2026 13:00:13 +0900 Subject: [PATCH 18/23] f --- selfdrive/carrot/web/css/pages/logs.css | 26 +++++-------------------- 1 file changed, 5 insertions(+), 21 deletions(-) diff --git a/selfdrive/carrot/web/css/pages/logs.css b/selfdrive/carrot/web/css/pages/logs.css index 3aae1eb436..f3e58a92c4 100644 --- a/selfdrive/carrot/web/css/pages/logs.css +++ b/selfdrive/carrot/web/css/pages/logs.css @@ -390,33 +390,17 @@ overflow-anchor: none; -webkit-overflow-scrolling: touch; touch-action: pan-y; - /* Symmetric breathing room on both sides; no gutter reservation so the - visual layout is identical regardless of whether the scrollbar shows. */ - padding-inline: 4px; - scrollbar-width: thin; - scrollbar-color: color-mix(in srgb, var(--md-outline-var) 50%, transparent) transparent; + /* Hide the scrollbar entirely so the content sits flush with the rest of + the card on both sides. Scrolling still works via touch/wheel/keyboard. + This is the standard pattern for touch-first list views. */ + scrollbar-width: none; display: flex; flex-direction: column; gap: 8px; } -/* Thin, semi-transparent overlay-style scrollbar — does not push content. */ .dashcam-segment-list::-webkit-scrollbar { - width: 4px; - height: 4px; -} - -.dashcam-segment-list::-webkit-scrollbar-track { - background: transparent; -} - -.dashcam-segment-list::-webkit-scrollbar-thumb { - border-radius: 999px; - background: color-mix(in srgb, var(--md-outline-var) 40%, transparent); -} - -.dashcam-segment-list::-webkit-scrollbar-thumb:hover { - background: color-mix(in srgb, var(--md-outline-var) 70%, transparent); + display: none; } .dashcam-segment-loader { From d7f37dced4bb2971f8bcca3e05cd7cf7c47f05cc Mon Sep 17 00:00:00 2001 From: jominki354 Date: Mon, 11 May 2026 13:02:23 +0900 Subject: [PATCH 19/23] ff --- selfdrive/carrot/web/css/pages/logs.css | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/selfdrive/carrot/web/css/pages/logs.css b/selfdrive/carrot/web/css/pages/logs.css index f3e58a92c4..c32bfce041 100644 --- a/selfdrive/carrot/web/css/pages/logs.css +++ b/selfdrive/carrot/web/css/pages/logs.css @@ -303,7 +303,9 @@ .dashcam-route-main { min-width: 0; - padding: 12px 28px 12px 12px; + /* Symmetric inline padding so all children (title, selection row, + segment list) align flush on both sides of the card. */ + padding: 12px 14px; } .dashcam-route-head { @@ -1216,7 +1218,7 @@ } .dashcam-route-main { - padding: 10px 28px 12px 12px; + padding: 10px 14px 12px; } .dashcam-route-head { From 291547d4347f785964742d60be4e7875d67dda52 Mon Sep 17 00:00:00 2001 From: jominki354 Date: Mon, 11 May 2026 13:06:54 +0900 Subject: [PATCH 20/23] ff --- selfdrive/carrot/web/css/pages/settings.css | 52 ++++++++++++++++++++- selfdrive/carrot/web/js/pages/setting.js | 24 +++++++++- 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/selfdrive/carrot/web/css/pages/settings.css b/selfdrive/carrot/web/css/pages/settings.css index 043e185bdb..36c00cf8c9 100644 --- a/selfdrive/carrot/web/css/pages/settings.css +++ b/selfdrive/carrot/web/css/pages/settings.css @@ -169,10 +169,14 @@ align-items: flex-end; gap: 10px; opacity: 0; - transform: translateY(8px) scale(0.98); + transform: translateY(8px) scale(0.96); transform-origin: right bottom; - transition: opacity 0.16s ease, transform 0.16s ease; + /* Exit (close) — Material emphasized accelerate */ + transition: + opacity 0.16s cubic-bezier(0.3, 0, 0.8, 0.15), + transform 0.18s cubic-bezier(0.3, 0, 0.8, 0.15); pointer-events: none; + will-change: opacity, transform; } .setting-fab-actions[hidden] { @@ -184,6 +188,10 @@ transform: translateY(0) scale(1); pointer-events: auto; visibility: visible; + /* Enter (open) — Material emphasized decelerate */ + transition: + opacity 0.22s cubic-bezier(0.2, 0, 0, 1), + transform 0.26s cubic-bezier(0.2, 0, 0, 1); } .setting-fab-action { @@ -205,8 +213,31 @@ box-shadow: 0 12px 28px rgba(0, 0, 0, 0.26); cursor: pointer; -webkit-tap-highlight-color: transparent; + /* Closed state — items collapse toward the FAB */ + opacity: 0; + transform: translateY(6px) scale(0.96); + transform-origin: right center; + transition: + opacity 0.14s cubic-bezier(0.3, 0, 0.8, 0.15), + transform 0.14s cubic-bezier(0.3, 0, 0.8, 0.15); } +.setting-fab-menu.is-open .setting-fab-action { + opacity: 1; + transform: translateY(0) scale(1); + transition: + opacity 0.22s cubic-bezier(0.2, 0, 0, 1), + transform 0.26s cubic-bezier(0.2, 0, 0, 1); +} + +/* Stagger items from the FAB outward (last child is closest to the FAB, + so it animates first). Supports up to 5 items. */ +.setting-fab-menu.is-open .setting-fab-action:nth-last-child(1) { transition-delay: 0ms; } +.setting-fab-menu.is-open .setting-fab-action:nth-last-child(2) { transition-delay: 40ms; } +.setting-fab-menu.is-open .setting-fab-action:nth-last-child(3) { transition-delay: 80ms; } +.setting-fab-menu.is-open .setting-fab-action:nth-last-child(4) { transition-delay: 120ms; } +.setting-fab-menu.is-open .setting-fab-action:nth-last-child(5) { transition-delay: 160ms; } + .setting-fab-action svg { width: 20px; height: 20px; @@ -254,6 +285,23 @@ .fab--setting-menu svg { width: 26px; height: 26px; + transition: transform 0.24s cubic-bezier(0.2, 0, 0, 1); +} + +/* Rotate the "+" into an "x" when the menu is open */ +.fab--setting-menu[aria-expanded="true"] svg { + transform: rotate(135deg); +} + +@media (prefers-reduced-motion: reduce) { + .setting-fab-actions, + .setting-fab-action, + .fab--setting-menu svg { + transition: none !important; + } + .setting-fab-menu.is-open .setting-fab-action { + transition-delay: 0ms !important; + } } .setting-search-panel { diff --git a/selfdrive/carrot/web/js/pages/setting.js b/selfdrive/carrot/web/js/pages/setting.js index d4e994fb88..54ef9e4edd 100644 --- a/selfdrive/carrot/web/js/pages/setting.js +++ b/selfdrive/carrot/web/js/pages/setting.js @@ -1135,11 +1135,33 @@ function appendSettingProfileHeader(profile, container) { container.appendChild(panel); } +let settingFabMenuCloseTimer = null; +const SETTING_FAB_MENU_CLOSE_MS = 240; + function syncSettingFabMenuState() { + if (settingFabActions) { + if (settingFabMenuCloseTimer) { + window.clearTimeout(settingFabMenuCloseTimer); + settingFabMenuCloseTimer = null; + } + if (settingFabMenuOpen && settingFabActions.hidden) { + // Make the element renderable in its closed state first, then let the + // next style recalc apply the open class so the transition plays. + settingFabActions.hidden = false; + void settingFabActions.offsetWidth; // commit closed-state baseline + } + } if (settingFabMenu) settingFabMenu.classList.toggle("is-open", settingFabMenuOpen); if (settingFabActions) { - settingFabActions.hidden = !settingFabMenuOpen; settingFabActions.setAttribute("aria-hidden", settingFabMenuOpen ? "false" : "true"); + if (!settingFabMenuOpen && !settingFabActions.hidden) { + // Defer [hidden] until the close transition finishes — otherwise + // `display: none` snaps it away with no animation. + settingFabMenuCloseTimer = window.setTimeout(() => { + settingFabMenuCloseTimer = null; + if (!settingFabMenuOpen) settingFabActions.hidden = true; + }, SETTING_FAB_MENU_CLOSE_MS); + } } if (btnSettingSearch) { btnSettingSearch.classList.toggle("active", settingFabMenuOpen || Boolean(settingSearchPanel && !settingSearchPanel.hidden)); From 920aaf555fe9c1095dba76ed87f9e88029d6ee06 Mon Sep 17 00:00:00 2001 From: jominki354 Date: Mon, 11 May 2026 13:25:54 +0900 Subject: [PATCH 21/23] arch --- selfdrive/carrot/README.md | 70 +- .../carrot/web/css/pages/settings/base.css | 305 +++++++ .../{settings.css => settings/device.css} | 846 ----------------- .../carrot/web/css/pages/settings/panels.css | 541 +++++++++++ selfdrive/carrot/web/css/pages/tools/base.css | 491 ++++++++++ .../css/pages/{tools.css => tools/main.css} | 861 ------------------ selfdrive/carrot/web/css/pages/tools/qr.css | 370 ++++++++ selfdrive/carrot/web/index.html | 26 +- selfdrive/carrot/web/js/pages/branch.js | 49 +- .../web/js/pages/{logs.js => logs/dashcam.js} | 683 +------------- .../carrot/web/js/pages/logs/screenrecord.js | 247 +++++ selfdrive/carrot/web/js/pages/logs/shared.js | 436 +++++++++ .../web/js/{ => realtime}/app_realtime.js | 0 .../web/js/{ => realtime}/home_drive.js | 0 .../carrot/web/js/{ => realtime}/hud_card.js | 0 .../carrot/web/js/{ => realtime}/raw_capnp.js | 0 .../web/js/{ => realtime}/raw_capnp_worker.js | 2 +- .../web/js/{ => realtime}/vision_raw.js | 2 +- .../web/js/{ => realtime}/vision_rtc.js | 0 .../web/js/{ => realtime}/vision_state.js | 0 20 files changed, 2495 insertions(+), 2434 deletions(-) create mode 100644 selfdrive/carrot/web/css/pages/settings/base.css rename selfdrive/carrot/web/css/pages/{settings.css => settings/device.css} (59%) create mode 100644 selfdrive/carrot/web/css/pages/settings/panels.css create mode 100644 selfdrive/carrot/web/css/pages/tools/base.css rename selfdrive/carrot/web/css/pages/{tools.css => tools/main.css} (59%) create mode 100644 selfdrive/carrot/web/css/pages/tools/qr.css rename selfdrive/carrot/web/js/pages/{logs.js => logs/dashcam.js} (65%) create mode 100644 selfdrive/carrot/web/js/pages/logs/screenrecord.js create mode 100644 selfdrive/carrot/web/js/pages/logs/shared.js rename selfdrive/carrot/web/js/{ => realtime}/app_realtime.js (100%) rename selfdrive/carrot/web/js/{ => realtime}/home_drive.js (100%) rename selfdrive/carrot/web/js/{ => realtime}/hud_card.js (100%) rename selfdrive/carrot/web/js/{ => realtime}/raw_capnp.js (100%) rename selfdrive/carrot/web/js/{ => realtime}/raw_capnp_worker.js (95%) rename selfdrive/carrot/web/js/{ => realtime}/vision_raw.js (99%) rename selfdrive/carrot/web/js/{ => realtime}/vision_rtc.js (100%) rename selfdrive/carrot/web/js/{ => realtime}/vision_state.js (100%) diff --git a/selfdrive/carrot/README.md b/selfdrive/carrot/README.md index de2193342d..232bc5a3e1 100644 --- a/selfdrive/carrot/README.md +++ b/selfdrive/carrot/README.md @@ -11,6 +11,7 @@ Anyone can modify. Refer to the structure below. ``` server/ +├── __init__.py ├── app.py composition root: middleware, lifecycle, make_app() ├── config.py constants (paths, URLs, tmux session, etc.) ├── live_runtime/ cereal SubMaster broker for /api/live_runtime @@ -30,6 +31,7 @@ server/ │ ├── time_sync.py browser → system time sync │ ├── device_info.py focused calibration + network helpers for Device tab │ ├── setting_favorites.py CarrotPilot setting favorites state +│ ├── setting_profiles.py CarrotPilot setting profile CRUD + import/export │ ├── web_settings.py device/server-backed Web Settings state │ └── tmux.py tmux session helpers └── features/ HTTP entry points (one feature per file/folder) @@ -39,6 +41,7 @@ server/ ├── settings.py /api/settings ├── params.py /api/params_*, /download/params_backup.json ├── setting_favorites.py /api/setting_favorites + ├── setting_profiles.py /api/setting_profiles, profile import/export ├── web_settings.py /api/web_settings ├── ssh_keys.py /api/ssh_keys ├── cars.py /api/cars @@ -83,12 +86,20 @@ web/ │ ├── layout.css page container, swipe, headings, sections │ ├── components.css dialog, toast, buttons, setting items, transitions │ ├── responsive.css desktop + mobile media queries (loads last) +│ ├── vendor/ +│ │ └── plyr.css Plyr video player styles │ └── pages/ │ ├── logs.css Logs/Dashcam page │ ├── drive.css WebRTC video + Carrot stage -│ ├── settings.css Settings page styles (includes device tab) │ ├── terminal.css Terminal page styles -│ └── tools.css Tools page styles +│ ├── settings/ Settings page styles, split for readability +│ │ ├── base.css page base, car entry, FAB menu (open/close anim) +│ │ ├── panels.css search panel, group list, profile sections, toolbar +│ │ └── device.css Device tab, settings-diff dialog, subnav, responsive +│ └── tools/ Tools page styles, split by feature +│ ├── base.css page base, meta/lang, Web Settings dialog +│ ├── qr.css QR code dialog +│ └── main.css groups, progress, notifications/console, responsive └── js/ ├── app.js bootstrap: popstate, initial showPage() ├── shared/ cross-page modules @@ -97,6 +108,7 @@ web/ │ ├── utils.js escapeHtml, clamp, copyToClipboard, quick link │ ├── i18n.js bootstrapped LANG, getUIText, renderUIText, setWebLanguage │ ├── api.js bulkGet, setParam, postJson, getJson, waitMs + │ ├── setting_diff.js setting-diff dialog helpers (used by settings + tools) │ ├── activity.js cross-page activity badges + beforeunload guard │ └── ui/ │ ├── dialog.js appAlert/Confirm/Prompt + toast @@ -112,17 +124,28 @@ web/ │ ├── setting_device_actions.js Device action/dialog handlers │ ├── setting_device.js Device tab coordinator and state │ ├── tools_web_settings.js server-backed Web Settings dialog + │ ├── tools_notifications.js Tools-tab notification preview/composer + │ ├── tools_settings_qr.js Settings QR import/export │ ├── tools.js tools page + initToolsPage + action runners │ ├── branch.js branch picker modal + Branch page - │ ├── logs.js Dashcam + Screen Recording lists - │ └── terminal.js tmux WebSocket client + │ ├── logs/ Logs page, split by tab + │ │ ├── shared.js tab state, scroll persistence, lazy-image observer, + │ │ │ video player, bind/init + │ │ ├── dashcam.js Dashcam tab: virtual route+segment list, upload subsystem + │ │ └── screenrecord.js Screen Recording tab: virtual list, lazy thumbs + │ ├── terminal.js tmux WebSocket client + │ └── vision_background.js static background for non-realtime pages ├── translations/ ko/en/zh/ja/fr + registry.js - └── realtime - app_realtime.js live runtime/raw stream wiring + HUD payload bridge - home_drive.js Carrot Vision renderer and overlay canvas - hud_card.js adaptive driving HUD card - raw_capnp.js raw capnp decoders for HUD/overlay state - raw_capnp_worker.js worker entry for raw capnp decoding + ├── realtime/ realtime stream stack (loaded together) + │ ├── hud_card.js adaptive driving HUD card + │ ├── raw_capnp.js raw capnp decoders for HUD/overlay state + │ ├── raw_capnp_worker.js worker entry for raw capnp decoding + │ ├── vision_state.js shared vision/HUD state + │ ├── vision_rtc.js WebRTC vision stream client + │ ├── vision_raw.js raw WebSocket vision client + decoder worker bridge + │ ├── app_realtime.js live runtime/raw stream wiring + HUD payload bridge + │ └── home_drive.js Carrot Vision renderer and overlay canvas + └── vendor/ third-party libraries (Plyr, jsQR, qrcode-generator) ``` ### Settings page tab structure @@ -137,8 +160,27 @@ The Setting page has two top-level tabs: Device tab adapts to hardware via `DeviceType` param (`tici`/`mici`/`tizi`). Load order (set in `index.html`): -`tokens → layout_tokens → hud_card → base → layout → pages/* → components → responsive` -then JS: -`translations → hud_card → shared/* → shared/ui/* → pages/* → realtime/* → app.js` -CSS files merge byte-identical with the previous single `app.css` if concatenated in the order above. JS scripts share the same global realm — top-level `let`/`const` are visible across files. +CSS: +``` +tokens → layout_tokens → hud_card → base → layout → components + → pages/logs → pages/terminal + → pages/settings/base → pages/settings/panels → pages/settings/device + → pages/tools/base → pages/tools/qr → pages/tools/main + → pages/drive → responsive → vendor/plyr +``` + +JS: +``` +vendor/* → translations → shared/* → shared/ui/* → pages/* → pages/logs/* → realtime/* → app +``` + +CSS files merge byte-identical with the previous single `settings.css` and `tools.css` if concatenated in the order above. JS scripts share the same global realm — top-level `let`/`const` are visible across files (so the logs split files all see the shared `logsActiveTab`, `dashcamState`, `screenrecordState`, etc.). + +### Recovery server (standalone) + +``` +recovery/ +├── __init__.py +└── server.py port 6999, minimal self-contained recovery UI +``` diff --git a/selfdrive/carrot/web/css/pages/settings/base.css b/selfdrive/carrot/web/css/pages/settings/base.css new file mode 100644 index 0000000000..de73beb8ec --- /dev/null +++ b/selfdrive/carrot/web/css/pages/settings/base.css @@ -0,0 +1,305 @@ +/* Settings page layout. + Keep spacing tokens in layout_tokens.css; this file only applies them to + the setting page's concrete screens and controls. */ + +.page--setting { + --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 { + border-color: color-mix(in srgb, #8fdc9b 62%, var(--md-outline-var)); + background: color-mix(in srgb, #8fdc9b 14%, var(--md-surface-cont-h)); + 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; + justify-content: space-between; + gap: 10px; + min-height: 46px; + padding: 9px 12px; + border: 1px solid color-mix(in srgb, var(--md-outline-var) 46%, transparent); + border-radius: 8px; + background: var(--md-surface-cont-h); + cursor: pointer; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.035); + transition: background 0.14s ease, border-color 0.14s ease, color 0.14s ease, transform 0.12s ease; +} + +.setting-car-entry:hover, +.setting-car-entry:focus-visible { + background: color-mix(in srgb, var(--md-surface-cont-h) 88%, var(--md-primary)); + border-color: color-mix(in srgb, var(--md-primary) 38%, var(--md-outline-var)); + outline: none; +} + +.setting-car-entry:active { + transform: translateY(1px); +} + +.setting-car-entry.is-attention { + border-color: color-mix(in srgb, var(--md-primary) 72%, var(--md-outline-var)); + box-shadow: + 0 0 0 2px color-mix(in srgb, var(--md-primary) 18%, transparent), + inset 0 1px 0 rgba(255, 255, 255, 0.05); + animation: setting-car-attention 1.05s ease-in-out 0s 3; +} + +@keyframes setting-car-attention { + 0%, 100% { transform: translateY(0); } + 45% { transform: translateY(-2px); } +} + +.setting-car-entry__text { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.setting-car-entry__eyebrow { + color: color-mix(in srgb, var(--md-primary) 88%, #fff); + font-size: 10px; + font-weight: 800; + letter-spacing: 0.03em; + opacity: 0.84; +} + +.setting-car-entry__value { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--md-on-surface); + font-size: 15px; + font-weight: 850; + line-height: 1.14; +} + +.setting-car-entry__chevron { + width: 8px; + height: 8px; + flex: 0 0 auto; + border-top: 2px solid color-mix(in srgb, var(--md-primary) 78%, var(--md-on-surface)); + border-right: 2px solid color-mix(in srgb, var(--md-primary) 78%, var(--md-on-surface)); + transform: rotate(45deg); + opacity: 0.82; +} + +.setting-car-entry:not(.is-empty) .setting-car-entry__eyebrow { + display: none; +} + +.setting-car-entry.is-empty { + justify-content: center; + min-height: 48px; + padding: 10px 14px; + text-align: center; +} + +.setting-car-entry.is-empty .setting-car-entry__text { + align-items: center; + gap: 0; +} + +.setting-car-entry.is-empty .setting-car-entry__eyebrow { + font-size: 15px; + font-weight: 800; + letter-spacing: 0; + text-transform: none; + color: var(--md-on-surface); + opacity: 0.96; +} + +.setting-car-entry.is-empty .setting-car-entry__value, +.setting-car-entry.is-empty .setting-car-entry__chevron { + display: none; +} + +.setting-search-backdrop { + position: fixed; + inset: 0; + z-index: 170; + border: 0; + background: color-mix(in srgb, var(--md-surface) 38%, transparent); + backdrop-filter: blur(6px) saturate(108%); + -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.96); + transform-origin: right bottom; + /* Exit (close) — Material emphasized accelerate */ + transition: + opacity 0.16s cubic-bezier(0.3, 0, 0.8, 0.15), + transform 0.18s cubic-bezier(0.3, 0, 0.8, 0.15); + pointer-events: none; + will-change: opacity, transform; +} + +.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; + /* Enter (open) — Material emphasized decelerate */ + transition: + opacity 0.22s cubic-bezier(0.2, 0, 0, 1), + transform 0.26s cubic-bezier(0.2, 0, 0, 1); +} + +.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; + /* Closed state — items collapse toward the FAB */ + opacity: 0; + transform: translateY(6px) scale(0.96); + transform-origin: right center; + transition: + opacity 0.14s cubic-bezier(0.3, 0, 0.8, 0.15), + transform 0.14s cubic-bezier(0.3, 0, 0.8, 0.15); +} + +.setting-fab-menu.is-open .setting-fab-action { + opacity: 1; + transform: translateY(0) scale(1); + transition: + opacity 0.22s cubic-bezier(0.2, 0, 0, 1), + transform 0.26s cubic-bezier(0.2, 0, 0, 1); +} + +/* Stagger items from the FAB outward (last child is closest to the FAB, + so it animates first). Supports up to 5 items. */ +.setting-fab-menu.is-open .setting-fab-action:nth-last-child(1) { transition-delay: 0ms; } +.setting-fab-menu.is-open .setting-fab-action:nth-last-child(2) { transition-delay: 40ms; } +.setting-fab-menu.is-open .setting-fab-action:nth-last-child(3) { transition-delay: 80ms; } +.setting-fab-menu.is-open .setting-fab-action:nth-last-child(4) { transition-delay: 120ms; } +.setting-fab-menu.is-open .setting-fab-action:nth-last-child(5) { transition-delay: 160ms; } + +.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; + transition: transform 0.24s cubic-bezier(0.2, 0, 0, 1); +} + +/* Rotate the "+" into an "x" when the menu is open */ +.fab--setting-menu[aria-expanded="true"] svg { + transform: rotate(135deg); +} + +@media (prefers-reduced-motion: reduce) { + .setting-fab-actions, + .setting-fab-action, + .fab--setting-menu svg { + transition: none !important; + } + .setting-fab-menu.is-open .setting-fab-action { + transition-delay: 0ms !important; + } +} diff --git a/selfdrive/carrot/web/css/pages/settings.css b/selfdrive/carrot/web/css/pages/settings/device.css similarity index 59% rename from selfdrive/carrot/web/css/pages/settings.css rename to selfdrive/carrot/web/css/pages/settings/device.css index 36c00cf8c9..0806d0b567 100644 --- a/selfdrive/carrot/web/css/pages/settings.css +++ b/selfdrive/carrot/web/css/pages/settings/device.css @@ -1,849 +1,3 @@ -/* Settings page layout. - Keep spacing tokens in layout_tokens.css; this file only applies them to - the setting page's concrete screens and controls. */ - -.page--setting { - --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 { - border-color: color-mix(in srgb, #8fdc9b 62%, var(--md-outline-var)); - background: color-mix(in srgb, #8fdc9b 14%, var(--md-surface-cont-h)); - 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; - justify-content: space-between; - gap: 10px; - min-height: 46px; - padding: 9px 12px; - border: 1px solid color-mix(in srgb, var(--md-outline-var) 46%, transparent); - border-radius: 8px; - background: var(--md-surface-cont-h); - cursor: pointer; - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.035); - transition: background 0.14s ease, border-color 0.14s ease, color 0.14s ease, transform 0.12s ease; -} - -.setting-car-entry:hover, -.setting-car-entry:focus-visible { - background: color-mix(in srgb, var(--md-surface-cont-h) 88%, var(--md-primary)); - border-color: color-mix(in srgb, var(--md-primary) 38%, var(--md-outline-var)); - outline: none; -} - -.setting-car-entry:active { - transform: translateY(1px); -} - -.setting-car-entry.is-attention { - border-color: color-mix(in srgb, var(--md-primary) 72%, var(--md-outline-var)); - box-shadow: - 0 0 0 2px color-mix(in srgb, var(--md-primary) 18%, transparent), - inset 0 1px 0 rgba(255, 255, 255, 0.05); - animation: setting-car-attention 1.05s ease-in-out 0s 3; -} - -@keyframes setting-car-attention { - 0%, 100% { transform: translateY(0); } - 45% { transform: translateY(-2px); } -} - -.setting-car-entry__text { - min-width: 0; - display: flex; - flex-direction: column; - gap: 2px; -} - -.setting-car-entry__eyebrow { - color: color-mix(in srgb, var(--md-primary) 88%, #fff); - font-size: 10px; - font-weight: 800; - letter-spacing: 0.03em; - opacity: 0.84; -} - -.setting-car-entry__value { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - color: var(--md-on-surface); - font-size: 15px; - font-weight: 850; - line-height: 1.14; -} - -.setting-car-entry__chevron { - width: 8px; - height: 8px; - flex: 0 0 auto; - border-top: 2px solid color-mix(in srgb, var(--md-primary) 78%, var(--md-on-surface)); - border-right: 2px solid color-mix(in srgb, var(--md-primary) 78%, var(--md-on-surface)); - transform: rotate(45deg); - opacity: 0.82; -} - -.setting-car-entry:not(.is-empty) .setting-car-entry__eyebrow { - display: none; -} - -.setting-car-entry.is-empty { - justify-content: center; - min-height: 48px; - padding: 10px 14px; - text-align: center; -} - -.setting-car-entry.is-empty .setting-car-entry__text { - align-items: center; - gap: 0; -} - -.setting-car-entry.is-empty .setting-car-entry__eyebrow { - font-size: 15px; - font-weight: 800; - letter-spacing: 0; - text-transform: none; - color: var(--md-on-surface); - opacity: 0.96; -} - -.setting-car-entry.is-empty .setting-car-entry__value, -.setting-car-entry.is-empty .setting-car-entry__chevron { - display: none; -} - -.setting-search-backdrop { - position: fixed; - inset: 0; - z-index: 170; - border: 0; - background: color-mix(in srgb, var(--md-surface) 38%, transparent); - backdrop-filter: blur(6px) saturate(108%); - -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.96); - transform-origin: right bottom; - /* Exit (close) — Material emphasized accelerate */ - transition: - opacity 0.16s cubic-bezier(0.3, 0, 0.8, 0.15), - transform 0.18s cubic-bezier(0.3, 0, 0.8, 0.15); - pointer-events: none; - will-change: opacity, transform; -} - -.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; - /* Enter (open) — Material emphasized decelerate */ - transition: - opacity 0.22s cubic-bezier(0.2, 0, 0, 1), - transform 0.26s cubic-bezier(0.2, 0, 0, 1); -} - -.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; - /* Closed state — items collapse toward the FAB */ - opacity: 0; - transform: translateY(6px) scale(0.96); - transform-origin: right center; - transition: - opacity 0.14s cubic-bezier(0.3, 0, 0.8, 0.15), - transform 0.14s cubic-bezier(0.3, 0, 0.8, 0.15); -} - -.setting-fab-menu.is-open .setting-fab-action { - opacity: 1; - transform: translateY(0) scale(1); - transition: - opacity 0.22s cubic-bezier(0.2, 0, 0, 1), - transform 0.26s cubic-bezier(0.2, 0, 0, 1); -} - -/* Stagger items from the FAB outward (last child is closest to the FAB, - so it animates first). Supports up to 5 items. */ -.setting-fab-menu.is-open .setting-fab-action:nth-last-child(1) { transition-delay: 0ms; } -.setting-fab-menu.is-open .setting-fab-action:nth-last-child(2) { transition-delay: 40ms; } -.setting-fab-menu.is-open .setting-fab-action:nth-last-child(3) { transition-delay: 80ms; } -.setting-fab-menu.is-open .setting-fab-action:nth-last-child(4) { transition-delay: 120ms; } -.setting-fab-menu.is-open .setting-fab-action:nth-last-child(5) { transition-delay: 160ms; } - -.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; - transition: transform 0.24s cubic-bezier(0.2, 0, 0, 1); -} - -/* Rotate the "+" into an "x" when the menu is open */ -.fab--setting-menu[aria-expanded="true"] svg { - transform: rotate(135deg); -} - -@media (prefers-reduced-motion: reduce) { - .setting-fab-actions, - .setting-fab-action, - .fab--setting-menu svg { - transition: none !important; - } - .setting-fab-menu.is-open .setting-fab-action { - transition-delay: 0ms !important; - } -} - -.setting-search-panel { - --setting-search-form-width: clamp(320px, 42vw, 520px); - --setting-search-results-width: min(72vw, 760px); - --setting-search-results-max-height: min(58dvh, 520px); - position: fixed; - inset: 0; - z-index: 171; - display: flex; - flex-direction: column; - align-items: center; - justify-content: flex-start; - gap: 14px; - padding: calc(18px + env(safe-area-inset-top, 0px)) var(--sp-lg) calc(var(--nav-bar-height) + 18px + env(safe-area-inset-bottom, 0px)); - pointer-events: none; -} - -.setting-search-panel::before { - content: none; -} - -.setting-search-form { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - gap: 0; - width: min(var(--setting-search-form-width), calc(100vw - 40px)); - min-height: clamp(56px, 7vh, 62px); - padding-left: 20px; - border: 1px solid color-mix(in srgb, var(--md-primary) 26%, var(--md-stroke-soft)); - border-radius: 999px; - background: color-mix(in srgb, var(--md-surface-cont) 76%, transparent); - backdrop-filter: blur(10px) saturate(112%); - -webkit-backdrop-filter: blur(10px) saturate(112%); - box-shadow: 0 18px 40px rgba(0, 0, 0, 0.26); - pointer-events: auto; - position: relative; - z-index: 1; - flex: 0 0 auto; -} - -.setting-search-backdrop[hidden], -.setting-search-panel[hidden] { - display: none !important; -} - -.setting-search-form:focus-within { - border-color: color-mix(in srgb, var(--md-primary) 46%, var(--md-stroke-strong)); -} - -.setting-search-form__input { - min-width: 0; - min-height: 56px; - border: 0; - background: transparent; - color: var(--md-on-surface); - font-size: var(--fs-body-md); - outline: none; -} - -.setting-search-form__submit { - width: 56px; - height: 100%; - border: 0; - border-left: 1px solid color-mix(in srgb, var(--md-stroke-soft) 42%, transparent); - border-radius: 0 999px 999px 0; - background: transparent; - color: var(--md-primary); - display: inline-flex; - align-items: center; - justify-content: center; - cursor: pointer; -} - -.setting-search-form__submit:active { - background: color-mix(in srgb, var(--md-primary) 12%, transparent); -} - -.setting-search-form__submit svg { - width: 20px; - height: 20px; -} - -.setting-search-results { - display: flex; - flex-direction: column; - gap: 0; - width: min(var(--setting-search-results-width), calc(100vw - 40px)); - padding: 0 4px; - max-height: var(--setting-search-results-max-height); - flex: 0 1 var(--setting-search-results-max-height); - overflow: auto; - overscroll-behavior: contain; - pointer-events: auto; - position: relative; - z-index: 1; -} - -.setting-search-results:empty { - 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; - flex-direction: column; - gap: var(--setting-menu-list-gap); -} - -.page--setting #groupList .groupBtn, -.page--setting #deviceGroupList .groupBtn { - display: flex; - align-items: center; - min-height: var(--setting-menu-item-min-height); - margin-bottom: 0; - padding: 0 var(--setting-menu-item-padding-inline); - font-size: 14px; - line-height: 1.18; -} - -.page--setting #groupList .groupBtn--favorites { - border-color: color-mix(in srgb, #7dd3fc 34%, var(--md-outline-var)); - background: color-mix(in srgb, #7dd3fc 7%, var(--md-surface-cont-h)); -} - -.page--setting #groupList .groupBtn--favorites.active { - border-color: color-mix(in srgb, #7dd3fc 64%, var(--md-outline-var)); - 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: var(--md-primary-state); - 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); - will-change: grid-template-rows, opacity, transform; -} - -.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-profile-section.is-collapsing .setting-profile-section__body { - pointer-events: none; - animation: setting-profile-section-fold 0.24s cubic-bezier(.2, 0, 0, 1) both; -} - -.setting-profile-section.is-expanding .setting-profile-section__body { - animation: setting-profile-section-fold 0.24s cubic-bezier(.2, 0, 0, 1) reverse both; -} - -.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); - } -} - -@keyframes setting-profile-section-fold { - from { - grid-template-rows: 1fr; - opacity: 1; - transform: translateY(0); - } - to { - grid-template-rows: 0fr; - opacity: 0; - transform: translateY(-4px); - } -} - -@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, - .setting-profile-section.is-collapsing .setting-profile-section__body, - .setting-profile-section.is-expanding .setting-profile-section__body { - animation: none; - } - - .setting-profile-section__header, - .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); diff --git a/selfdrive/carrot/web/css/pages/settings/panels.css b/selfdrive/carrot/web/css/pages/settings/panels.css new file mode 100644 index 0000000000..1c32188c57 --- /dev/null +++ b/selfdrive/carrot/web/css/pages/settings/panels.css @@ -0,0 +1,541 @@ + +.setting-search-panel { + --setting-search-form-width: clamp(320px, 42vw, 520px); + --setting-search-results-width: min(72vw, 760px); + --setting-search-results-max-height: min(58dvh, 520px); + position: fixed; + inset: 0; + z-index: 171; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + gap: 14px; + padding: calc(18px + env(safe-area-inset-top, 0px)) var(--sp-lg) calc(var(--nav-bar-height) + 18px + env(safe-area-inset-bottom, 0px)); + pointer-events: none; +} + +.setting-search-panel::before { + content: none; +} + +.setting-search-form { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 0; + width: min(var(--setting-search-form-width), calc(100vw - 40px)); + min-height: clamp(56px, 7vh, 62px); + padding-left: 20px; + border: 1px solid color-mix(in srgb, var(--md-primary) 26%, var(--md-stroke-soft)); + border-radius: 999px; + background: color-mix(in srgb, var(--md-surface-cont) 76%, transparent); + backdrop-filter: blur(10px) saturate(112%); + -webkit-backdrop-filter: blur(10px) saturate(112%); + box-shadow: 0 18px 40px rgba(0, 0, 0, 0.26); + pointer-events: auto; + position: relative; + z-index: 1; + flex: 0 0 auto; +} + +.setting-search-backdrop[hidden], +.setting-search-panel[hidden] { + display: none !important; +} + +.setting-search-form:focus-within { + border-color: color-mix(in srgb, var(--md-primary) 46%, var(--md-stroke-strong)); +} + +.setting-search-form__input { + min-width: 0; + min-height: 56px; + border: 0; + background: transparent; + color: var(--md-on-surface); + font-size: var(--fs-body-md); + outline: none; +} + +.setting-search-form__submit { + width: 56px; + height: 100%; + border: 0; + border-left: 1px solid color-mix(in srgb, var(--md-stroke-soft) 42%, transparent); + border-radius: 0 999px 999px 0; + background: transparent; + color: var(--md-primary); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.setting-search-form__submit:active { + background: color-mix(in srgb, var(--md-primary) 12%, transparent); +} + +.setting-search-form__submit svg { + width: 20px; + height: 20px; +} + +.setting-search-results { + display: flex; + flex-direction: column; + gap: 0; + width: min(var(--setting-search-results-width), calc(100vw - 40px)); + padding: 0 4px; + max-height: var(--setting-search-results-max-height); + flex: 0 1 var(--setting-search-results-max-height); + overflow: auto; + overscroll-behavior: contain; + pointer-events: auto; + position: relative; + z-index: 1; +} + +.setting-search-results:empty { + 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; + flex-direction: column; + gap: var(--setting-menu-list-gap); +} + +.page--setting #groupList .groupBtn, +.page--setting #deviceGroupList .groupBtn { + display: flex; + align-items: center; + min-height: var(--setting-menu-item-min-height); + margin-bottom: 0; + padding: 0 var(--setting-menu-item-padding-inline); + font-size: 14px; + line-height: 1.18; +} + +.page--setting #groupList .groupBtn--favorites { + border-color: color-mix(in srgb, #7dd3fc 34%, var(--md-outline-var)); + background: color-mix(in srgb, #7dd3fc 7%, var(--md-surface-cont-h)); +} + +.page--setting #groupList .groupBtn--favorites.active { + border-color: color-mix(in srgb, #7dd3fc 64%, var(--md-outline-var)); + 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: var(--md-primary-state); + 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); + will-change: grid-template-rows, opacity, transform; +} + +.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-profile-section.is-collapsing .setting-profile-section__body { + pointer-events: none; + animation: setting-profile-section-fold 0.24s cubic-bezier(.2, 0, 0, 1) both; +} + +.setting-profile-section.is-expanding .setting-profile-section__body { + animation: setting-profile-section-fold 0.24s cubic-bezier(.2, 0, 0, 1) reverse both; +} + +.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); + } +} + +@keyframes setting-profile-section-fold { + from { + grid-template-rows: 1fr; + opacity: 1; + transform: translateY(0); + } + to { + grid-template-rows: 0fr; + opacity: 0; + transform: translateY(-4px); + } +} + +@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, + .setting-profile-section.is-collapsing .setting-profile-section__body, + .setting-profile-section.is-expanding .setting-profile-section__body { + animation: none; + } + + .setting-profile-section__header, + .setting-profile-section__chevron, + .setting-toolbar-action { + transition: none; + } +} + diff --git a/selfdrive/carrot/web/css/pages/tools/base.css b/selfdrive/carrot/web/css/pages/tools/base.css new file mode 100644 index 0000000000..212c05d057 --- /dev/null +++ b/selfdrive/carrot/web/css/pages/tools/base.css @@ -0,0 +1,491 @@ +/* Tools page layout. + Keep spacing tokens in layout_tokens.css; this module owns only tools page + placement, console geometry, and tools-specific responsive behavior. */ + +.page--tools { + --tools-page-height: var(--page-content-height, calc(var(--app-vv-height, 100dvh) - var(--nav-bar-height) - env(safe-area-inset-bottom, 0px))); + --tools-motion-standard: cubic-bezier(0.4, 0, 0.2, 1); + --tools-motion-decelerate: cubic-bezier(0, 0, 0.2, 1); + --tools-detail-duration: 320ms; + --tools-console-form-height: 40px; + --tools-console-log-font-size: 12px; + --tools-console-log-line-height: 1.45; + --tools-console-peek-lines: 2; + --tools-console-attention-lines: 5; + --tools-console-expanded-lines: 9; + --tools-console-visible-lines: var(--tools-console-peek-lines); + --tools-console-handle-height: 18px; + --tools-console-log-pad-top: 10px; + --tools-console-log-pad-bottom: 14px; + --tools-console-log-height: calc( + (var(--tools-console-log-font-size) * var(--tools-console-log-line-height) * var(--tools-console-visible-lines)) + + var(--tools-console-handle-height) + + var(--tools-console-log-pad-top) + + var(--tools-console-log-pad-bottom) + ); + --tools-console-height: var(--tools-console-log-height); + --tools-console-inline: var(--sp-lg); + --tools-console-bg: #080b10; + --tools-console-surface: #0c131b; + --tools-console-current: #d9f1ff; + --tools-console-history: #7f95a7; + --tools-console-divider: color-mix(in srgb, var(--md-outline-var) 24%, transparent); + --tools-log-scroll-gap: 6px; + --tools-detail-scroll-gap: 8px; + display: flex; + flex-direction: column; + position: relative; + min-height: var(--tools-page-height); + height: var(--tools-page-height); + max-height: var(--tools-page-height); + box-sizing: border-box; + padding-block: var(--tools-page-gutter-block); + padding-inline: var(--tools-page-gutter-inline); + background: var(--tools-console-bg); + overflow: visible; +} + +.page--tools.tools-log-attention { + --tools-console-visible-lines: var(--tools-console-attention-lines); +} + +.page--tools.tools-log-expanded { + --tools-console-visible-lines: var(--tools-console-expanded-lines); +} + +#toolsMeta { + height: 30px; + min-height: 30px; + max-height: 30px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 16px; + overflow: visible; + line-height: 1.2; + position: relative; + z-index: 90; +} + +.tools-meta__status { + min-width: 0; + height: 100%; + display: flex; + align-items: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; + touch-action: none; + -webkit-mask-image: none; + mask-image: none; +} + +.tools-meta__status:focus-visible { + outline: 2px solid color-mix(in srgb, var(--md-primary) 72%, transparent); + outline-offset: 2px; + border-radius: 6px; +} + +.tools-meta__statusTrack { + display: inline-block; + min-width: 0; + max-width: none; + overflow: visible; + text-overflow: clip; + white-space: nowrap; + will-change: transform; +} + +.tools-meta__status.is-overflowing .tools-meta__statusTrack { + animation: toolsMetaFlow var(--tools-meta-scroll-duration, 3.8s) cubic-bezier(.35, 0, .65, 1) .2s infinite alternate; +} + +@keyframes toolsMetaFlow { + from { + transform: translateX(0); + } + to { + transform: translateX(calc(-1 * var(--tools-meta-scroll-distance, 32px))); + } +} + +.tools-meta__actions { + display: inline-flex; + align-items: center; + gap: 10px; + min-width: 0; + height: 100%; +} + +.tools-meta__info { + display: none; +} + +.tools-meta__infoBtn { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + min-height: 24px; + padding: 3px 8px; + font-size: 11px; + font-weight: 700; + white-space: nowrap; +} + +.tools-lang-menu { + height: 100%; +} + +.tools-lang-menu__button { + height: 26px; + min-height: 26px; + padding: 2px 7px; + font-size: 13px; + white-space: nowrap; +} + +.tools-lang-menu__globe { + color: var(--md-on-surface-var); + font-size: 15px; + line-height: 1; +} + +.tools-lang-menu__globe { + width: 16px; + height: 16px; + display: inline-flex; +} + +.tools-lang-menu__globe svg { + width: 16px; + height: 16px; + display: block; +} + +.tools-lang-menu__label { + font-weight: 600; +} + +.tools-lang-menu__panel { + position: fixed; + top: var(--tools-lang-menu-panel-top, 48px); + right: var(--tools-lang-menu-panel-right, 12px); + z-index: 2500; + width: min(148px, calc(100vw - 24px)); + max-height: calc(100dvh - var(--tools-lang-menu-panel-top, 48px) - 12px); + overflow: auto; +} + +.tools-lang-menu__panel::before { + content: none; +} + +.tools-lang-menu__current { + padding: 6px 0 8px; + color: var(--md-on-surface-var); + font-size: var(--fs-body-sm); + line-height: 1.25; +} + +.tools-lang-menu__divider { + height: 1px; + margin-bottom: 4px; + background: color-mix(in srgb, var(--md-outline-var) 42%, transparent); +} + +.tools-lang-menu__item { + margin-bottom: 2px; +} + +.tools-lang-menu__item[aria-checked="true"] { + font-weight: 800; + color: var(--md-primary); + background: color-mix(in srgb, var(--md-primary) 12%, transparent); +} + +.tools-lang-menu__openMark { + flex: 0 0 auto; + color: var(--md-on-surface-var); + width: 16px; + height: 16px; + display: inline-flex; +} + +.tools-lang-menu__openMark svg { + width: 16px; + height: 16px; + display: block; +} + +.tools-meta-iconBtn { + appearance: none; + -webkit-appearance: none; + width: 26px; + min-width: 26px; + height: 26px; + min-height: 26px; + padding: 0; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--md-on-surface-var); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.tools-meta-iconBtn:hover, +.tools-meta-iconBtn:focus-visible { + background: color-mix(in srgb, var(--md-on-surface) 6%, transparent); + color: var(--md-on-surface); +} + +.tools-meta-iconBtn svg { + width: 17px; + height: 17px; + display: block; +} + +.app-dialog--web-settings .app-dialog__sheet { + --web-settings-content-max: min(52dvh, 360px); + width: min(calc(100vw - 32px), 620px); + max-height: min(calc(100dvh - 32px), 560px); + padding: 16px; + overflow: hidden; +} + +.app-dialog--web-settings .app-dialog__head { + margin-bottom: 14px; +} + +.app-dialog--web-settings .app-dialog__body { + flex: 0 1 auto; + min-height: 0; + max-height: var(--web-settings-content-max); + white-space: normal; + overflow: hidden; +} + +.app-dialog--web-settings .app-dialog__actions { + margin-top: 14px; +} + +.web-settings-dialog { + display: grid; + grid-template-columns: 116px minmax(0, 1fr); + align-items: start; + gap: 16px; + min-width: 0; + min-height: 0; + height: auto; + max-height: var(--web-settings-content-max); + width: 100%; +} + +.web-settings-nav { + min-width: 0; + min-height: 0; + max-height: 100%; + display: flex; + flex-direction: column; + gap: 6px; + overflow: auto; + overscroll-behavior: contain; + padding-right: 2px; +} + +.web-settings-nav__item { + appearance: none; + -webkit-appearance: none; + width: 100%; + min-height: 38px; + padding: 8px 10px; + border: 1px solid color-mix(in srgb, var(--md-outline-var) 58%, transparent); + border-radius: 6px; + background: color-mix(in srgb, var(--md-surface-cont) 58%, transparent); + color: var(--md-on-surface-var); + font-size: 13px; + font-weight: 800; + line-height: 1.2; + text-align: left; + cursor: pointer; + transition: background 0.14s ease, border-color 0.14s ease, color 0.14s ease; +} + +.web-settings-nav__item:hover, +.web-settings-nav__item:focus-visible { + border-color: color-mix(in srgb, var(--md-primary) 38%, var(--md-outline-var)); + background: color-mix(in srgb, var(--md-on-surface) 5%, var(--md-surface-cont)); + color: var(--md-on-surface); +} + +.web-settings-nav__item.is-active { + border-color: color-mix(in srgb, var(--md-primary) 56%, var(--md-outline-var)); + background: var(--md-primary-state); + color: var(--md-primary); +} + +.web-settings-panels, +.web-settings-group { + min-width: 0; + min-height: 0; +} + +.web-settings-panels { + max-height: var(--web-settings-content-max); + overflow: hidden; +} + +.web-settings-group { + max-height: var(--web-settings-content-max); + display: flex; + flex-direction: column; +} + +.web-settings-group[hidden] { + display: none !important; +} + +.web-settings-group__title { + margin-bottom: 8px; + color: var(--md-on-surface); + font-size: 14px; + font-weight: 800; + line-height: 1.2; + text-align: left; +} + +.web-settings-group__body { + display: grid; + gap: 8px; + min-height: 0; + max-height: calc(var(--web-settings-content-max) - 28px); + overflow: auto; + overscroll-behavior: contain; + padding-right: 2px; +} + +.web-settings-row, +.web-settings-empty { + min-height: 58px; + padding: 12px 14px; + border: 1px solid color-mix(in srgb, var(--md-outline-var) 62%, transparent); + border-radius: 6px; + background: color-mix(in srgb, var(--md-surface-cont) 72%, transparent); +} + +.web-settings-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 12px; +} + +.web-settings-empty { + display: flex; + flex-direction: column; + justify-content: center; +} + +.web-settings-row--toggle { + cursor: pointer; +} + +.web-settings-row--select { + cursor: default; +} + +.web-settings-row__copy { + min-width: 0; + display: flex; + flex-direction: column; + gap: 3px; +} + +.web-settings-row__title { + color: var(--md-on-surface); + font-size: 14px; + font-weight: 750; + line-height: 1.25; +} + +.web-settings-row__desc, +.web-settings-empty { + color: var(--md-on-surface-var); + font-size: 13px; + line-height: 1.35; +} + +.web-settings-switch { + position: relative; + width: 44px; + height: 28px; + flex: 0 0 44px; +} + +.web-settings-switch__input { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.web-settings-switch__slider { + position: absolute; + inset: 0; + border: 1px solid color-mix(in srgb, var(--md-outline-var) 52%, transparent); + border-radius: 999px; + background: var(--md-surface-cont-h); + transition: background 0.14s ease, border-color 0.14s ease; +} + +.web-settings-switch__slider::after { + content: ""; + position: absolute; + top: 4px; + left: 4px; + width: 18px; + height: 18px; + border-radius: 999px; + background: var(--md-on-surface-var); + transition: transform 0.16s cubic-bezier(0.4, 0, 0.2, 1), background 0.14s ease; +} + +.web-settings-switch__input:checked + .web-settings-switch__slider { + border-color: color-mix(in srgb, var(--md-primary) 76%, var(--md-outline-var)); + background: color-mix(in srgb, var(--md-primary) 82%, var(--md-surface-cont-h)); +} + +.web-settings-switch__input:checked + .web-settings-switch__slider::after { + transform: translateX(16px); + background: var(--md-on-primary); +} + +.web-settings-select { + width: min(150px, 38vw); + min-width: 112px; + min-height: 34px; + padding: 0 34px 0 11px; + border: 1px solid color-mix(in srgb, var(--md-outline-var) 68%, transparent); + border-radius: 6px; + background: color-mix(in srgb, var(--md-surface-cont-h) 84%, transparent); + color: var(--md-on-surface); + font-size: 13px; + font-weight: 700; + line-height: 1.2; +} + +.app-dialog--tools-qr .app-dialog__sheet { + width: min(calc(100vw - 32px), 520px); + max-height: min(calc(100dvh - 32px), 720px); + padding: 16px; + overflow: hidden; +} + +.app-dialog--tools-qr .app-dialog__body { + min-height: 0; + white-space: normal; + overflow: auto; +} diff --git a/selfdrive/carrot/web/css/pages/tools.css b/selfdrive/carrot/web/css/pages/tools/main.css similarity index 59% rename from selfdrive/carrot/web/css/pages/tools.css rename to selfdrive/carrot/web/css/pages/tools/main.css index a55befb02f..ad6fbbbed0 100644 --- a/selfdrive/carrot/web/css/pages/tools.css +++ b/selfdrive/carrot/web/css/pages/tools/main.css @@ -1,864 +1,3 @@ -/* Tools page layout. - Keep spacing tokens in layout_tokens.css; this module owns only tools page - placement, console geometry, and tools-specific responsive behavior. */ - -.page--tools { - --tools-page-height: var(--page-content-height, calc(var(--app-vv-height, 100dvh) - var(--nav-bar-height) - env(safe-area-inset-bottom, 0px))); - --tools-motion-standard: cubic-bezier(0.4, 0, 0.2, 1); - --tools-motion-decelerate: cubic-bezier(0, 0, 0.2, 1); - --tools-detail-duration: 320ms; - --tools-console-form-height: 40px; - --tools-console-log-font-size: 12px; - --tools-console-log-line-height: 1.45; - --tools-console-peek-lines: 2; - --tools-console-attention-lines: 5; - --tools-console-expanded-lines: 9; - --tools-console-visible-lines: var(--tools-console-peek-lines); - --tools-console-handle-height: 18px; - --tools-console-log-pad-top: 10px; - --tools-console-log-pad-bottom: 14px; - --tools-console-log-height: calc( - (var(--tools-console-log-font-size) * var(--tools-console-log-line-height) * var(--tools-console-visible-lines)) + - var(--tools-console-handle-height) + - var(--tools-console-log-pad-top) + - var(--tools-console-log-pad-bottom) - ); - --tools-console-height: var(--tools-console-log-height); - --tools-console-inline: var(--sp-lg); - --tools-console-bg: #080b10; - --tools-console-surface: #0c131b; - --tools-console-current: #d9f1ff; - --tools-console-history: #7f95a7; - --tools-console-divider: color-mix(in srgb, var(--md-outline-var) 24%, transparent); - --tools-log-scroll-gap: 6px; - --tools-detail-scroll-gap: 8px; - display: flex; - flex-direction: column; - position: relative; - min-height: var(--tools-page-height); - height: var(--tools-page-height); - max-height: var(--tools-page-height); - box-sizing: border-box; - padding-block: var(--tools-page-gutter-block); - padding-inline: var(--tools-page-gutter-inline); - background: var(--tools-console-bg); - overflow: visible; -} - -.page--tools.tools-log-attention { - --tools-console-visible-lines: var(--tools-console-attention-lines); -} - -.page--tools.tools-log-expanded { - --tools-console-visible-lines: var(--tools-console-expanded-lines); -} - -#toolsMeta { - height: 30px; - min-height: 30px; - max-height: 30px; - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - gap: 16px; - overflow: visible; - line-height: 1.2; - position: relative; - z-index: 90; -} - -.tools-meta__status { - min-width: 0; - height: 100%; - display: flex; - align-items: center; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - cursor: pointer; - touch-action: none; - -webkit-mask-image: none; - mask-image: none; -} - -.tools-meta__status:focus-visible { - outline: 2px solid color-mix(in srgb, var(--md-primary) 72%, transparent); - outline-offset: 2px; - border-radius: 6px; -} - -.tools-meta__statusTrack { - display: inline-block; - min-width: 0; - max-width: none; - overflow: visible; - text-overflow: clip; - white-space: nowrap; - will-change: transform; -} - -.tools-meta__status.is-overflowing .tools-meta__statusTrack { - animation: toolsMetaFlow var(--tools-meta-scroll-duration, 3.8s) cubic-bezier(.35, 0, .65, 1) .2s infinite alternate; -} - -@keyframes toolsMetaFlow { - from { - transform: translateX(0); - } - to { - transform: translateX(calc(-1 * var(--tools-meta-scroll-distance, 32px))); - } -} - -.tools-meta__actions { - display: inline-flex; - align-items: center; - gap: 10px; - min-width: 0; - height: 100%; -} - -.tools-meta__info { - display: none; -} - -.tools-meta__infoBtn { - display: inline-flex; - align-items: center; - justify-content: center; - flex: 0 0 auto; - min-height: 24px; - padding: 3px 8px; - font-size: 11px; - font-weight: 700; - white-space: nowrap; -} - -.tools-lang-menu { - height: 100%; -} - -.tools-lang-menu__button { - height: 26px; - min-height: 26px; - padding: 2px 7px; - font-size: 13px; - white-space: nowrap; -} - -.tools-lang-menu__globe { - color: var(--md-on-surface-var); - font-size: 15px; - line-height: 1; -} - -.tools-lang-menu__globe { - width: 16px; - height: 16px; - display: inline-flex; -} - -.tools-lang-menu__globe svg { - width: 16px; - height: 16px; - display: block; -} - -.tools-lang-menu__label { - font-weight: 600; -} - -.tools-lang-menu__panel { - position: fixed; - top: var(--tools-lang-menu-panel-top, 48px); - right: var(--tools-lang-menu-panel-right, 12px); - z-index: 2500; - width: min(148px, calc(100vw - 24px)); - max-height: calc(100dvh - var(--tools-lang-menu-panel-top, 48px) - 12px); - overflow: auto; -} - -.tools-lang-menu__panel::before { - content: none; -} - -.tools-lang-menu__current { - padding: 6px 0 8px; - color: var(--md-on-surface-var); - font-size: var(--fs-body-sm); - line-height: 1.25; -} - -.tools-lang-menu__divider { - height: 1px; - margin-bottom: 4px; - background: color-mix(in srgb, var(--md-outline-var) 42%, transparent); -} - -.tools-lang-menu__item { - margin-bottom: 2px; -} - -.tools-lang-menu__item[aria-checked="true"] { - font-weight: 800; - color: var(--md-primary); - background: color-mix(in srgb, var(--md-primary) 12%, transparent); -} - -.tools-lang-menu__openMark { - flex: 0 0 auto; - color: var(--md-on-surface-var); - width: 16px; - height: 16px; - display: inline-flex; -} - -.tools-lang-menu__openMark svg { - width: 16px; - height: 16px; - display: block; -} - -.tools-meta-iconBtn { - appearance: none; - -webkit-appearance: none; - width: 26px; - min-width: 26px; - height: 26px; - min-height: 26px; - padding: 0; - border: 0; - border-radius: 6px; - background: transparent; - color: var(--md-on-surface-var); - display: inline-flex; - align-items: center; - justify-content: center; - cursor: pointer; -} - -.tools-meta-iconBtn:hover, -.tools-meta-iconBtn:focus-visible { - background: color-mix(in srgb, var(--md-on-surface) 6%, transparent); - color: var(--md-on-surface); -} - -.tools-meta-iconBtn svg { - width: 17px; - height: 17px; - display: block; -} - -.app-dialog--web-settings .app-dialog__sheet { - --web-settings-content-max: min(52dvh, 360px); - width: min(calc(100vw - 32px), 620px); - max-height: min(calc(100dvh - 32px), 560px); - padding: 16px; - overflow: hidden; -} - -.app-dialog--web-settings .app-dialog__head { - margin-bottom: 14px; -} - -.app-dialog--web-settings .app-dialog__body { - flex: 0 1 auto; - min-height: 0; - max-height: var(--web-settings-content-max); - white-space: normal; - overflow: hidden; -} - -.app-dialog--web-settings .app-dialog__actions { - margin-top: 14px; -} - -.web-settings-dialog { - display: grid; - grid-template-columns: 116px minmax(0, 1fr); - align-items: start; - gap: 16px; - min-width: 0; - min-height: 0; - height: auto; - max-height: var(--web-settings-content-max); - width: 100%; -} - -.web-settings-nav { - min-width: 0; - min-height: 0; - max-height: 100%; - display: flex; - flex-direction: column; - gap: 6px; - overflow: auto; - overscroll-behavior: contain; - padding-right: 2px; -} - -.web-settings-nav__item { - appearance: none; - -webkit-appearance: none; - width: 100%; - min-height: 38px; - padding: 8px 10px; - border: 1px solid color-mix(in srgb, var(--md-outline-var) 58%, transparent); - border-radius: 6px; - background: color-mix(in srgb, var(--md-surface-cont) 58%, transparent); - color: var(--md-on-surface-var); - font-size: 13px; - font-weight: 800; - line-height: 1.2; - text-align: left; - cursor: pointer; - transition: background 0.14s ease, border-color 0.14s ease, color 0.14s ease; -} - -.web-settings-nav__item:hover, -.web-settings-nav__item:focus-visible { - border-color: color-mix(in srgb, var(--md-primary) 38%, var(--md-outline-var)); - background: color-mix(in srgb, var(--md-on-surface) 5%, var(--md-surface-cont)); - color: var(--md-on-surface); -} - -.web-settings-nav__item.is-active { - border-color: color-mix(in srgb, var(--md-primary) 56%, var(--md-outline-var)); - background: var(--md-primary-state); - color: var(--md-primary); -} - -.web-settings-panels, -.web-settings-group { - min-width: 0; - min-height: 0; -} - -.web-settings-panels { - max-height: var(--web-settings-content-max); - overflow: hidden; -} - -.web-settings-group { - max-height: var(--web-settings-content-max); - display: flex; - flex-direction: column; -} - -.web-settings-group[hidden] { - display: none !important; -} - -.web-settings-group__title { - margin-bottom: 8px; - color: var(--md-on-surface); - font-size: 14px; - font-weight: 800; - line-height: 1.2; - text-align: left; -} - -.web-settings-group__body { - display: grid; - gap: 8px; - min-height: 0; - max-height: calc(var(--web-settings-content-max) - 28px); - overflow: auto; - overscroll-behavior: contain; - padding-right: 2px; -} - -.web-settings-row, -.web-settings-empty { - min-height: 58px; - padding: 12px 14px; - border: 1px solid color-mix(in srgb, var(--md-outline-var) 62%, transparent); - border-radius: 6px; - background: color-mix(in srgb, var(--md-surface-cont) 72%, transparent); -} - -.web-settings-row { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - gap: 12px; -} - -.web-settings-empty { - display: flex; - flex-direction: column; - justify-content: center; -} - -.web-settings-row--toggle { - cursor: pointer; -} - -.web-settings-row--select { - cursor: default; -} - -.web-settings-row__copy { - min-width: 0; - display: flex; - flex-direction: column; - gap: 3px; -} - -.web-settings-row__title { - color: var(--md-on-surface); - font-size: 14px; - font-weight: 750; - line-height: 1.25; -} - -.web-settings-row__desc, -.web-settings-empty { - color: var(--md-on-surface-var); - font-size: 13px; - line-height: 1.35; -} - -.web-settings-switch { - position: relative; - width: 44px; - height: 28px; - flex: 0 0 44px; -} - -.web-settings-switch__input { - position: absolute; - opacity: 0; - pointer-events: none; -} - -.web-settings-switch__slider { - position: absolute; - inset: 0; - border: 1px solid color-mix(in srgb, var(--md-outline-var) 52%, transparent); - border-radius: 999px; - background: var(--md-surface-cont-h); - transition: background 0.14s ease, border-color 0.14s ease; -} - -.web-settings-switch__slider::after { - content: ""; - position: absolute; - top: 4px; - left: 4px; - width: 18px; - height: 18px; - border-radius: 999px; - background: var(--md-on-surface-var); - transition: transform 0.16s cubic-bezier(0.4, 0, 0.2, 1), background 0.14s ease; -} - -.web-settings-switch__input:checked + .web-settings-switch__slider { - border-color: color-mix(in srgb, var(--md-primary) 76%, var(--md-outline-var)); - background: color-mix(in srgb, var(--md-primary) 82%, var(--md-surface-cont-h)); -} - -.web-settings-switch__input:checked + .web-settings-switch__slider::after { - transform: translateX(16px); - background: var(--md-on-primary); -} - -.web-settings-select { - width: min(150px, 38vw); - min-width: 112px; - min-height: 34px; - padding: 0 34px 0 11px; - border: 1px solid color-mix(in srgb, var(--md-outline-var) 68%, transparent); - border-radius: 6px; - background: color-mix(in srgb, var(--md-surface-cont-h) 84%, transparent); - color: var(--md-on-surface); - font-size: 13px; - font-weight: 700; - line-height: 1.2; -} - -.app-dialog--tools-qr .app-dialog__sheet { - width: min(calc(100vw - 32px), 520px); - max-height: min(calc(100dvh - 32px), 720px); - padding: 16px; - overflow: hidden; -} - -.app-dialog--tools-qr .app-dialog__body { - min-height: 0; - white-space: normal; - overflow: auto; -} - -.tools-qr-backup, -.tools-qr-restore { - min-width: 0; - display: grid; - gap: 14px; -} - -.tools-qr-code { - display: flex; - justify-content: center; - padding: 12px; - border-radius: 8px; - background: #f8fafc; -} - -.tools-qr-code svg { - display: block; - width: min(100%, 300px); - height: auto; -} - -.tools-qr-stats, -.tools-qr-actions, -.tools-qr-summary { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px; -} - -.tools-qr-restore .tools-qr-actions { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); - gap: 8px; - padding: 4px; - border: 1px solid color-mix(in srgb, var(--md-outline-var) 42%, transparent); - border-radius: 10px; - background: color-mix(in srgb, var(--md-surface-cont-h) 70%, #000); -} - -.tools-qr-action-btn { - width: 100%; - min-height: 52px; - padding-inline: 10px; - border-radius: 8px; - border-color: transparent; - background: transparent; - justify-content: center; - font-size: 14px; - font-weight: 850; -} - -.tools-qr-action-btn:hover, -.tools-qr-action-btn:focus-visible { - border-color: color-mix(in srgb, var(--md-primary) 34%, transparent); - background: color-mix(in srgb, var(--md-primary) 10%, transparent); -} - -.tools-qr-action-btn--camera.is-disabled, -.tools-qr-action-btn--camera:disabled { - cursor: not-allowed; - opacity: 0.56; - color: color-mix(in srgb, var(--md-on-surface-var) 82%, transparent); - background: color-mix(in srgb, var(--md-on-surface) 5%, transparent); -} - -.tools-qr-action-btn--camera.is-disabled::before, -.tools-qr-action-btn--camera:disabled::before { - content: "X"; - width: 18px; - height: 18px; - display: inline-flex; - align-items: center; - justify-content: center; - margin-right: 7px; - border-radius: 50%; - border: 1px solid currentColor; - font-size: 11px; - font-weight: 900; - line-height: 1; -} - -.tools-qr-stats { - color: var(--md-on-surface-var); - font-size: 12px; - line-height: 1.35; -} - -.tools-qr-camera { - position: relative; - overflow: hidden; - border: 1px solid color-mix(in srgb, var(--md-outline-var) 46%, transparent); - border-radius: 8px; - background: #05080d; - transition: border-color 160ms ease, box-shadow 160ms ease; -} - -.tools-qr-camera video { - display: block; - width: 100%; - min-height: 260px; - max-height: min(54dvh, 420px); - aspect-ratio: 1 / 1; - object-fit: cover; -} - -.tools-qr-camera__overlay { - position: absolute; - inset: 0; - display: grid; - place-items: center; - pointer-events: none; -} - -.tools-qr-camera__guide { - position: relative; - width: 74%; - aspect-ratio: 1 / 1; - border: 1px solid color-mix(in srgb, var(--md-outline-var) 78%, transparent); - border-radius: 10px; - box-shadow: 0 0 0 999px rgb(0 0 0 / 42%); - transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease; -} - -.tools-qr-camera__corner { - position: absolute; - width: 30px; - height: 30px; - color: color-mix(in srgb, var(--md-primary) 82%, #ffffff); - border-color: currentColor; - transition: color 160ms ease, filter 160ms ease; -} - -.tools-qr-camera__corner--tl { - top: -2px; - left: -2px; - border-top: 3px solid; - border-left: 3px solid; - border-top-left-radius: 10px; -} - -.tools-qr-camera__corner--tr { - top: -2px; - right: -2px; - border-top: 3px solid; - border-right: 3px solid; - border-top-right-radius: 10px; -} - -.tools-qr-camera__corner--bl { - bottom: -2px; - left: -2px; - border-bottom: 3px solid; - border-left: 3px solid; - border-bottom-left-radius: 10px; -} - -.tools-qr-camera__corner--br { - right: -2px; - bottom: -2px; - border-right: 3px solid; - border-bottom: 3px solid; - border-bottom-right-radius: 10px; -} - -.tools-qr-camera[data-scan-state="detected"] { - border-color: color-mix(in srgb, var(--md-primary) 82%, transparent); - box-shadow: 0 0 0 1px color-mix(in srgb, var(--md-primary) 24%, transparent); -} - -.tools-qr-camera[data-scan-state="detected"] .tools-qr-camera__guide { - border-color: color-mix(in srgb, var(--md-primary) 80%, #ffffff); - box-shadow: 0 0 0 999px rgb(0 0 0 / 34%), 0 0 0 2px color-mix(in srgb, var(--md-primary) 28%, transparent); -} - -.tools-qr-camera[data-scan-state="aligned"], -.tools-qr-camera[data-scan-state="locked"] { - border-color: color-mix(in srgb, #4fd18b 82%, transparent); - box-shadow: 0 0 0 1px color-mix(in srgb, #4fd18b 34%, transparent); -} - -.tools-qr-camera[data-scan-state="aligned"] .tools-qr-camera__guide, -.tools-qr-camera[data-scan-state="locked"] .tools-qr-camera__guide { - border-color: #4fd18b; - box-shadow: 0 0 0 999px rgb(0 0 0 / 28%), 0 0 0 2px color-mix(in srgb, #4fd18b 38%, transparent); - transform: scale(1.015); -} - -.tools-qr-camera[data-scan-state="aligned"] .tools-qr-camera__corner, -.tools-qr-camera[data-scan-state="locked"] .tools-qr-camera__corner { - color: #4fd18b; - filter: drop-shadow(0 0 7px color-mix(in srgb, #4fd18b 68%, transparent)); -} - -.tools-qr-camera[data-scan-state="locked"] .tools-qr-camera__guide { - animation: toolsQrLockPulse 260ms ease-out; -} - -@keyframes toolsQrLockPulse { - 0% { - transform: scale(1.015); - } - 55% { - transform: scale(1.045); - } - 100% { - transform: scale(1.015); - } -} - -.tools-qr-status, -.tools-qr-empty, -.tools-qr-more { - color: var(--md-on-surface-var); - font-size: 13px; - line-height: 1.4; -} - -.tools-qr-status { - min-height: 24px; - display: flex; - align-items: center; - color: color-mix(in srgb, var(--md-on-surface) 86%, var(--md-on-surface-var)); - font-weight: 750; -} - -.tools-qr-chip { - min-height: 28px; - display: inline-flex; - align-items: center; - gap: 6px; - padding: 3px 0; - border-radius: 0; - background: transparent; - color: var(--md-on-surface-var); - font-size: 12px; - line-height: 1.2; -} - -.tools-qr-chip strong { - color: var(--md-on-surface); -} - -.tools-qr-summary { - justify-content: flex-start; - gap: 14px; - padding: 2px 0 4px; - border-bottom: 1px solid color-mix(in srgb, var(--md-outline-var) 28%, transparent); -} - -.tools-qr-diff { - min-height: 0; -} - -.tools-qr-diff__list { - max-height: min(36dvh, 300px); - overflow: auto; - display: grid; - gap: 0; - margin-top: 2px; - padding-right: 4px; - overscroll-behavior: contain; -} - -.tools-qr-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); -} - -.tools-qr-diff__row:first-child { - padding-top: 4px; -} - -.tools-qr-diff__head { - min-width: 0; - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 10px; -} - -.tools-qr-diff__key { - min-width: 0; - overflow: hidden; - color: var(--md-on-surface); - font-size: 13px; - font-weight: 800; - line-height: 1.3; - text-overflow: ellipsis; - white-space: nowrap; -} - -.tools-qr-diff__status { - flex: 0 0 auto; - padding: 2px 0; - border-radius: 999px; - background: transparent; - color: color-mix(in srgb, #8fdc9b 82%, var(--md-on-surface)); - font-size: 11px; - font-weight: 800; - line-height: 1.1; -} - -.tools-qr-diff__compare { - min-width: 0; - display: grid; - grid-template-columns: minmax(0, 1fr) 20px minmax(0, 1fr); - align-items: start; - gap: 10px; -} - -.tools-qr-diff__value { - min-width: 0; - display: grid; - align-content: start; - gap: 4px; - padding: 0; - border: 0; - background: transparent; -} - -.tools-qr-diff__value span { - min-width: 0; - color: var(--md-on-surface-var); - font-size: 12px; - font-weight: 800; - line-height: 1.2; -} - -.tools-qr-diff__value code { - min-width: 0; - overflow-wrap: anywhere; - color: var(--md-on-surface); - font-family: var(--font-mono); - font-size: 14px; - line-height: 1.4; - white-space: pre-wrap; -} - -.tools-qr-diff__value--old { - color: color-mix(in srgb, #ff8a80 74%, var(--md-on-surface)); -} - -.tools-qr-diff__value--new { - color: color-mix(in srgb, #8fdc9b 78%, var(--md-on-surface)); -} - -.tools-qr-diff__value--old code { - color: color-mix(in srgb, #ff8a80 76%, var(--md-on-surface)); -} - -.tools-qr-diff__value--new code { - color: color-mix(in srgb, #8fdc9b 82%, var(--md-on-surface)); -} - -.tools-qr-diff__arrow { - width: 20px; - min-width: 20px; - display: flex; - align-items: center; - justify-content: center; - color: var(--md-on-surface-var); - font-size: 15px; - font-weight: 900; -} - @media (max-width: 640px), (orientation: portrait) { .app-dialog--web-settings .app-dialog__sheet { --web-settings-content-max: min(58dvh, 440px); diff --git a/selfdrive/carrot/web/css/pages/tools/qr.css b/selfdrive/carrot/web/css/pages/tools/qr.css new file mode 100644 index 0000000000..7559f72f4b --- /dev/null +++ b/selfdrive/carrot/web/css/pages/tools/qr.css @@ -0,0 +1,370 @@ + +.tools-qr-backup, +.tools-qr-restore { + min-width: 0; + display: grid; + gap: 14px; +} + +.tools-qr-code { + display: flex; + justify-content: center; + padding: 12px; + border-radius: 8px; + background: #f8fafc; +} + +.tools-qr-code svg { + display: block; + width: min(100%, 300px); + height: auto; +} + +.tools-qr-stats, +.tools-qr-actions, +.tools-qr-summary { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.tools-qr-restore .tools-qr-actions { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 8px; + padding: 4px; + border: 1px solid color-mix(in srgb, var(--md-outline-var) 42%, transparent); + border-radius: 10px; + background: color-mix(in srgb, var(--md-surface-cont-h) 70%, #000); +} + +.tools-qr-action-btn { + width: 100%; + min-height: 52px; + padding-inline: 10px; + border-radius: 8px; + border-color: transparent; + background: transparent; + justify-content: center; + font-size: 14px; + font-weight: 850; +} + +.tools-qr-action-btn:hover, +.tools-qr-action-btn:focus-visible { + border-color: color-mix(in srgb, var(--md-primary) 34%, transparent); + background: color-mix(in srgb, var(--md-primary) 10%, transparent); +} + +.tools-qr-action-btn--camera.is-disabled, +.tools-qr-action-btn--camera:disabled { + cursor: not-allowed; + opacity: 0.56; + color: color-mix(in srgb, var(--md-on-surface-var) 82%, transparent); + background: color-mix(in srgb, var(--md-on-surface) 5%, transparent); +} + +.tools-qr-action-btn--camera.is-disabled::before, +.tools-qr-action-btn--camera:disabled::before { + content: "X"; + width: 18px; + height: 18px; + display: inline-flex; + align-items: center; + justify-content: center; + margin-right: 7px; + border-radius: 50%; + border: 1px solid currentColor; + font-size: 11px; + font-weight: 900; + line-height: 1; +} + +.tools-qr-stats { + color: var(--md-on-surface-var); + font-size: 12px; + line-height: 1.35; +} + +.tools-qr-camera { + position: relative; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--md-outline-var) 46%, transparent); + border-radius: 8px; + background: #05080d; + transition: border-color 160ms ease, box-shadow 160ms ease; +} + +.tools-qr-camera video { + display: block; + width: 100%; + min-height: 260px; + max-height: min(54dvh, 420px); + aspect-ratio: 1 / 1; + object-fit: cover; +} + +.tools-qr-camera__overlay { + position: absolute; + inset: 0; + display: grid; + place-items: center; + pointer-events: none; +} + +.tools-qr-camera__guide { + position: relative; + width: 74%; + aspect-ratio: 1 / 1; + border: 1px solid color-mix(in srgb, var(--md-outline-var) 78%, transparent); + border-radius: 10px; + box-shadow: 0 0 0 999px rgb(0 0 0 / 42%); + transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease; +} + +.tools-qr-camera__corner { + position: absolute; + width: 30px; + height: 30px; + color: color-mix(in srgb, var(--md-primary) 82%, #ffffff); + border-color: currentColor; + transition: color 160ms ease, filter 160ms ease; +} + +.tools-qr-camera__corner--tl { + top: -2px; + left: -2px; + border-top: 3px solid; + border-left: 3px solid; + border-top-left-radius: 10px; +} + +.tools-qr-camera__corner--tr { + top: -2px; + right: -2px; + border-top: 3px solid; + border-right: 3px solid; + border-top-right-radius: 10px; +} + +.tools-qr-camera__corner--bl { + bottom: -2px; + left: -2px; + border-bottom: 3px solid; + border-left: 3px solid; + border-bottom-left-radius: 10px; +} + +.tools-qr-camera__corner--br { + right: -2px; + bottom: -2px; + border-right: 3px solid; + border-bottom: 3px solid; + border-bottom-right-radius: 10px; +} + +.tools-qr-camera[data-scan-state="detected"] { + border-color: color-mix(in srgb, var(--md-primary) 82%, transparent); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--md-primary) 24%, transparent); +} + +.tools-qr-camera[data-scan-state="detected"] .tools-qr-camera__guide { + border-color: color-mix(in srgb, var(--md-primary) 80%, #ffffff); + box-shadow: 0 0 0 999px rgb(0 0 0 / 34%), 0 0 0 2px color-mix(in srgb, var(--md-primary) 28%, transparent); +} + +.tools-qr-camera[data-scan-state="aligned"], +.tools-qr-camera[data-scan-state="locked"] { + border-color: color-mix(in srgb, #4fd18b 82%, transparent); + box-shadow: 0 0 0 1px color-mix(in srgb, #4fd18b 34%, transparent); +} + +.tools-qr-camera[data-scan-state="aligned"] .tools-qr-camera__guide, +.tools-qr-camera[data-scan-state="locked"] .tools-qr-camera__guide { + border-color: #4fd18b; + box-shadow: 0 0 0 999px rgb(0 0 0 / 28%), 0 0 0 2px color-mix(in srgb, #4fd18b 38%, transparent); + transform: scale(1.015); +} + +.tools-qr-camera[data-scan-state="aligned"] .tools-qr-camera__corner, +.tools-qr-camera[data-scan-state="locked"] .tools-qr-camera__corner { + color: #4fd18b; + filter: drop-shadow(0 0 7px color-mix(in srgb, #4fd18b 68%, transparent)); +} + +.tools-qr-camera[data-scan-state="locked"] .tools-qr-camera__guide { + animation: toolsQrLockPulse 260ms ease-out; +} + +@keyframes toolsQrLockPulse { + 0% { + transform: scale(1.015); + } + 55% { + transform: scale(1.045); + } + 100% { + transform: scale(1.015); + } +} + +.tools-qr-status, +.tools-qr-empty, +.tools-qr-more { + color: var(--md-on-surface-var); + font-size: 13px; + line-height: 1.4; +} + +.tools-qr-status { + min-height: 24px; + display: flex; + align-items: center; + color: color-mix(in srgb, var(--md-on-surface) 86%, var(--md-on-surface-var)); + font-weight: 750; +} + +.tools-qr-chip { + min-height: 28px; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 0; + border-radius: 0; + background: transparent; + color: var(--md-on-surface-var); + font-size: 12px; + line-height: 1.2; +} + +.tools-qr-chip strong { + color: var(--md-on-surface); +} + +.tools-qr-summary { + justify-content: flex-start; + gap: 14px; + padding: 2px 0 4px; + border-bottom: 1px solid color-mix(in srgb, var(--md-outline-var) 28%, transparent); +} + +.tools-qr-diff { + min-height: 0; +} + +.tools-qr-diff__list { + max-height: min(36dvh, 300px); + overflow: auto; + display: grid; + gap: 0; + margin-top: 2px; + padding-right: 4px; + overscroll-behavior: contain; +} + +.tools-qr-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); +} + +.tools-qr-diff__row:first-child { + padding-top: 4px; +} + +.tools-qr-diff__head { + min-width: 0; + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; +} + +.tools-qr-diff__key { + min-width: 0; + overflow: hidden; + color: var(--md-on-surface); + font-size: 13px; + font-weight: 800; + line-height: 1.3; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tools-qr-diff__status { + flex: 0 0 auto; + padding: 2px 0; + border-radius: 999px; + background: transparent; + color: color-mix(in srgb, #8fdc9b 82%, var(--md-on-surface)); + font-size: 11px; + font-weight: 800; + line-height: 1.1; +} + +.tools-qr-diff__compare { + min-width: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) 20px minmax(0, 1fr); + align-items: start; + gap: 10px; +} + +.tools-qr-diff__value { + min-width: 0; + display: grid; + align-content: start; + gap: 4px; + padding: 0; + border: 0; + background: transparent; +} + +.tools-qr-diff__value span { + min-width: 0; + color: var(--md-on-surface-var); + font-size: 12px; + font-weight: 800; + line-height: 1.2; +} + +.tools-qr-diff__value code { + min-width: 0; + overflow-wrap: anywhere; + color: var(--md-on-surface); + font-family: var(--font-mono); + font-size: 14px; + line-height: 1.4; + white-space: pre-wrap; +} + +.tools-qr-diff__value--old { + color: color-mix(in srgb, #ff8a80 74%, var(--md-on-surface)); +} + +.tools-qr-diff__value--new { + color: color-mix(in srgb, #8fdc9b 78%, var(--md-on-surface)); +} + +.tools-qr-diff__value--old code { + color: color-mix(in srgb, #ff8a80 76%, var(--md-on-surface)); +} + +.tools-qr-diff__value--new code { + color: color-mix(in srgb, #8fdc9b 82%, var(--md-on-surface)); +} + +.tools-qr-diff__arrow { + width: 20px; + min-width: 20px; + display: flex; + align-items: center; + justify-content: center; + color: var(--md-on-surface-var); + font-size: 15px; + font-weight: 900; +} + diff --git a/selfdrive/carrot/web/index.html b/selfdrive/carrot/web/index.html index e3a9644642..113ca2c654 100644 --- a/selfdrive/carrot/web/index.html +++ b/selfdrive/carrot/web/index.html @@ -77,8 +77,12 @@ - - + + + + + + @@ -611,7 +615,7 @@

Home

- + @@ -635,15 +639,17 @@

Home

- + + + - - - - - + + + + + - + diff --git a/selfdrive/carrot/web/js/pages/branch.js b/selfdrive/carrot/web/js/pages/branch.js index cee781d693..0df27a77ff 100644 --- a/selfdrive/carrot/web/js/pages/branch.js +++ b/selfdrive/carrot/web/js/pages/branch.js @@ -365,52 +365,5 @@ async function onSelectBranch(item) { } } -/* ---------- Logs / Dashcam ---------- */ -const dashcamState = { - initialized: false, - loading: false, - routes: [], - expanded: new Set(), - selected: new Set(), - refreshTimer: null, - loadingMore: false, - loadingSegments: new Set(), - segmentScrollTops: Object.create(null), - scrollBusy: false, - scrollTimer: null, - renderFrame: 0, - loadSeq: 0, - layoutBound: false, - layoutTimer: null, - landscape: null, - layoutKey: "", - total: 0, - nextOffset: 0, - hasMore: false, - routeHeight: 300, - routeHeights: Object.create(null), - windowStart: 0, - windowEnd: 0, - signature: "", -}; - -const screenrecordState = { - initialized: false, - loading: false, - loadingMore: false, - videos: [], - loadSeq: 0, - signature: "", - total: 0, - nextOffset: 0, - hasMore: false, - rowHeight: 80, - windowStart: 0, - windowEnd: 0, - renderFrame: 0, -}; - -let logsActiveTab = "dashcam"; -const logsScrollTops = { dashcam: 0, screen: 0 }; -let logsLazyImageObserver = null; +// Logs page state and helpers moved to js/pages/logs/{shared,dashcam,screenrecord}.js diff --git a/selfdrive/carrot/web/js/pages/logs.js b/selfdrive/carrot/web/js/pages/logs/dashcam.js similarity index 65% rename from selfdrive/carrot/web/js/pages/logs.js rename to selfdrive/carrot/web/js/pages/logs/dashcam.js index 86b2b90fd2..bd7fcbb389 100644 --- a/selfdrive/carrot/web/js/pages/logs.js +++ b/selfdrive/carrot/web/js/pages/logs/dashcam.js @@ -1,7 +1,8 @@ "use strict"; -// Logs page — Dashcam (route+segment listing, FFmpeg thumb/preview, FTP upload) -// + Screen Recording listing/playback. Tab switching between the two. +// Logs page — Dashcam tab. +// Route + segment virtual listing, FFmpeg thumb/preview lazy load, +// segment selection, FTP upload (with cancel/resume), segment menu, player. const DASHCAM_UPLOAD_JOB_STORAGE_KEY = "carrot_dashcam_upload_job_id"; const DASHCAM_ROUTE_PAGE_MIN = 10; @@ -10,47 +11,36 @@ const DASHCAM_ROUTE_PAGE_VIEWPORTS = 3; const DASHCAM_SEGMENT_PAGE_SIZE = 10; const DASHCAM_LOAD_AHEAD_VIEWPORTS = 1.5; const DASHCAM_ROUTE_WINDOW_OVERSCAN_VIEWPORTS = 1.25; -const SCREENRECORD_PAGE_SIZE = 40; -const SCREENRECORD_LOAD_AHEAD_PX = 720; -const SCREENRECORD_WINDOW_OVERSCAN = 8; let dashcamUploadActiveJobId = null; let dashcamUploadResumePromise = null; -function isLogsPageActive() { - return CURRENT_PAGE === "logs"; -} - -function getLogsScroller(tab = logsActiveTab) { - return document.getElementById(tab === "screen" ? "screenrecordVideos" : "dashcamRoutes"); -} - -function saveLogsScrollTop(tab = logsActiveTab) { - const scroller = getLogsScroller(tab); - if (!scroller) return; - logsScrollTops[tab === "screen" ? "screen" : "dashcam"] = scroller.scrollTop || 0; -} - -function restoreLogsScrollTop(tab = logsActiveTab, options = {}) { - const scroller = getLogsScroller(tab); - if (!scroller) return; - const key = tab === "screen" ? "screen" : "dashcam"; - const nextTop = options.reset === true ? 0 : (logsScrollTops[key] || 0); - if (CURRENT_PAGE === "logs") { - window.scrollTo(0, 0); - document.documentElement.scrollTop = 0; - document.body.scrollTop = 0; - } - requestAnimationFrame(() => { - if (!isLogsPageActive()) return; - scroller.scrollTop = nextTop; - requestAnimationFrame(() => { - if (!isLogsPageActive()) return; - scroller.scrollTop = nextTop; - if (key === "dashcam" && typeof scheduleDashcamWindowRender === "function") scheduleDashcamWindowRender(); - if (key === "screen" && typeof scheduleScreenrecordWindowRender === "function") scheduleScreenrecordWindowRender(); - }); - }); -} +const dashcamState = { + initialized: false, + loading: false, + routes: [], + expanded: new Set(), + selected: new Set(), + refreshTimer: null, + loadingMore: false, + loadingSegments: new Set(), + segmentScrollTops: Object.create(null), + scrollBusy: false, + scrollTimer: null, + renderFrame: 0, + loadSeq: 0, + layoutBound: false, + layoutTimer: null, + landscape: null, + layoutKey: "", + total: 0, + nextOffset: 0, + hasMore: false, + routeHeight: 300, + routeHeights: Object.create(null), + windowStart: 0, + windowEnd: 0, + signature: "", +}; function dashcamSegmentIndex(segment) { const parts = String(segment || "").split("--"); @@ -66,29 +56,6 @@ function dashcamApiPath(kind, segment) { return `/api/dashcam/${kind}/${encodeURIComponent(segment)}`; } -function formatRelativeEpoch(epochSeconds) { - const epoch = Number(epochSeconds || 0); - if (!Number.isFinite(epoch) || epoch <= 0) return ""; - const delta = Math.max(0, Math.floor(Date.now() / 1000) - Math.floor(epoch)); - if (delta < 60) return getUIText("just_now", "just now"); - if (delta < 3600) return getUIText("minutes_ago", "{count} min ago", { count: Math.floor(delta / 60) }); - if (delta < 86400) return getUIText("hours_ago", "{count} hr ago", { count: Math.floor(delta / 3600) }); - return getUIText("days_ago", "{count} days ago", { count: Math.floor(delta / 86400) }); -} - -function localizeRelativeLabel(label) { - const text = String(label || "").trim(); - if (!text) return ""; - if (/^(방금\s*전|just\s*now)$/i.test(text)) return getUIText("just_now", "just now"); - const minuteMatch = text.match(/^(\d+)\s*(?:분\s*전|min(?:ute)?s?\s*ago)$/i); - if (minuteMatch) return getUIText("minutes_ago", "{count} min ago", { count: minuteMatch[1] }); - const hourMatch = text.match(/^(\d+)\s*(?:시간\s*전|hr?s?\s*ago|hour?s?\s*ago)$/i); - if (hourMatch) return getUIText("hours_ago", "{count} hr ago", { count: hourMatch[1] }); - const dayMatch = text.match(/^(\d+)\s*(?:일\s*전|day?s?\s*ago)$/i); - if (dayMatch) return getUIText("days_ago", "{count} days ago", { count: dayMatch[1] }); - return text; -} - function setDashcamStatus(message, tone = "") { const status = document.getElementById("dashcamStatus"); if (!status) return; @@ -102,26 +69,6 @@ function setDashcamMeta(message) { if (meta) meta.textContent = message; } -function setScreenrecordStatus(message, tone = "") { - const status = document.getElementById("screenrecordStatus"); - if (!status) return; - status.textContent = message || ""; - status.hidden = !message; - status.classList.toggle("is-error", tone === "error"); -} - -function formatLogBytes(bytes) { - const n = Number(bytes) || 0; - if (n < 1024) return `${n} B`; - if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; - if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`; - return `${(n / (1024 * 1024 * 1024)).toFixed(1)} GB`; -} - -function screenrecordApiPath(kind, fileId) { - return `/api/screenrecord/${kind}/${encodeURIComponent(fileId)}`; -} - function dashcamRoutesSignature(routes) { return (routes || []).map((entry) => [ entry.route || "", @@ -132,15 +79,6 @@ function dashcamRoutesSignature(routes) { ].join("|")).join("\n") + "|" + (typeof LANG !== "undefined" ? LANG : ""); } -function screenrecordVideosSignature(videos) { - return (videos || []).map((video) => [ - video.id || "", - video.name || "", - video.modifiedLabel || "", - video.size || 0, - ].join("|")).join("\n") + "|" + (typeof LANG !== "undefined" ? LANG : ""); -} - function dashcamDefaultRouteHeight() { return isCompactLandscapeMode() ? 210 : 310; } @@ -222,158 +160,12 @@ function dashcamWindowFor(host, routes) { return { start, end, topHeight, bottomHeight }; } -function screenrecordShouldLoadMore(scroller) { - if (!scroller || !screenrecordState.hasMore || screenrecordState.loading || screenrecordState.loadingMore) return false; - const remaining = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight; - return remaining <= SCREENRECORD_LOAD_AHEAD_PX; -} - function dashcamShouldLoadMore(scroller) { if (!scroller || !dashcamState.hasMore || dashcamState.loading || dashcamState.loadingMore) return false; const remaining = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight; return remaining <= Math.max(360, scroller.clientHeight * DASHCAM_LOAD_AHEAD_VIEWPORTS); } -function screenrecordWindowFor(host, count) { - const rowHeight = Math.max(48, Number(screenrecordState.rowHeight) || 80); - const viewportHeight = Math.max(1, host?.clientHeight || rowHeight * 8); - const scrollTop = Math.max(0, host?.scrollTop || 0); - const visibleRows = Math.ceil(viewportHeight / rowHeight); - const start = Math.max(0, Math.floor(scrollTop / rowHeight) - SCREENRECORD_WINDOW_OVERSCAN); - const end = Math.min(count, start + visibleRows + (SCREENRECORD_WINDOW_OVERSCAN * 2)); - return { start, end, rowHeight }; -} - -function screenrecordMeasureRowHeight(host) { - const row = host?.querySelector?.(".screenrecord-row"); - if (!row) return; - const styles = window.getComputedStyle?.(host); - const gap = Number.parseFloat(styles?.rowGap || styles?.gap || "0") || 0; - const nextHeight = Math.max(48, row.getBoundingClientRect().height + gap); - if (Math.abs(nextHeight - screenrecordState.rowHeight) < 1) return; - screenrecordState.rowHeight = nextHeight; -} - -function screenrecordSpacerNode(height, position) { - if (height <= 0) return null; - const node = document.createElement("div"); - node.className = "screenrecord-virtual-spacer"; - node.dataset.spacer = position; - node.style.height = `${Math.round(height)}px`; - return node; -} - -function screenrecordRowNode(video, index, existingRows) { - const id = String(video?.id || ""); - const existing = id ? existingRows.get(id) : null; - if (existing) { - existing.style.setProperty("--i", String(index)); - existing.classList.remove("ui-stagger-item"); - return existing; - } - const template = document.createElement("template"); - template.innerHTML = screenrecordVideoRowHtml(video, index); - return template.content.firstElementChild; -} - -function patchScreenrecordWindow(host, videos, view) { - const existingRows = new Map( - Array.from(host.querySelectorAll(".screenrecord-row")) - .map((node) => [node.dataset.id || "", node]) - .filter(([id]) => Boolean(id)) - ); - const frag = document.createDocumentFragment(); - const topSpacer = screenrecordSpacerNode(view.start * view.rowHeight, "top"); - const bottomSpacer = screenrecordSpacerNode((videos.length - view.end) * view.rowHeight, "bottom"); - if (topSpacer) frag.appendChild(topSpacer); - videos.slice(view.start, view.end).forEach((video, offset) => { - const row = screenrecordRowNode(video, view.start + offset, existingRows); - if (row) frag.appendChild(row); - }); - if (bottomSpacer) frag.appendChild(bottomSpacer); - unobserveLogsLazyImages(host); - host.replaceChildren(frag); -} - -function setScreenrecordLoadingMoreUi(active) { - const host = document.getElementById("screenrecordVideos"); - if (!host) return; - host.classList.toggle("is-loading-more", Boolean(active)); -} - -function scheduleScreenrecordWindowRender() { - if (screenrecordState.renderFrame) return; - screenrecordState.renderFrame = requestAnimationFrame(() => { - screenrecordState.renderFrame = 0; - renderScreenrecordVideos({ preserve: true }); - }); -} - -function loadLogsLazyImage(img) { - if (!img) return; - const src = img.dataset?.src || ""; - if (!src) return; - img.src = src; - img.removeAttribute("data-src"); -} - -function hydrateLogsLazyImages(root) { - if (!isLogsPageActive()) return; - const scope = root || document; - const images = Array.from(scope.querySelectorAll?.("img[data-src]") || []); - if (!images.length) return; - - if (!("IntersectionObserver" in window)) { - images.forEach(loadLogsLazyImage); - return; - } - - if (!logsLazyImageObserver) { - logsLazyImageObserver = new IntersectionObserver((entries) => { - entries.forEach((entry) => { - if (!entry.isIntersecting) return; - logsLazyImageObserver.unobserve(entry.target); - loadLogsLazyImage(entry.target); - }); - }, { root: null, rootMargin: "720px 0px", threshold: 0.01 }); - } - - images.forEach((img) => logsLazyImageObserver.observe(img)); -} - -function disconnectLogsLazyImages() { - if (!logsLazyImageObserver) return; - logsLazyImageObserver.disconnect(); - logsLazyImageObserver = null; -} - -function unobserveLogsLazyImages(root) { - if (!logsLazyImageObserver || !root) return; - root.querySelectorAll?.("img[data-src]").forEach((img) => { - logsLazyImageObserver.unobserve(img); - }); -} - -function logsLoadingSkeletonHtml(type = "dashcam") { - const count = type === "screen" ? 6 : 4; - const itemClass = type === "screen" ? "logs-loading-row" : "logs-loading-card"; - return ``; -} - -function logsEmptyStateHtml(type = "dashcam") { - const isScreen = type === "screen"; - const title = isScreen - ? getUIText("screenrecord_empty_title", "No screen recordings") - : getUIText("dashcam_empty_title", "No dashcam records"); - - return ` -
-
${escapeHtml(title)}
-
`; -} - function cancelDashcamRouteRender() { if (dashcamState.renderFrame) { window.cancelAnimationFrame(dashcamState.renderFrame); @@ -1090,90 +882,6 @@ function markDashcamScrollBusy(options = {}) { }, 380); } -function openLogsVideoPlayer(title, src, options = {}) { - const overlay = document.createElement("div"); - const kind = String(options.kind || "video").replace(/[^a-z0-9_-]/gi, ""); - overlay.className = `dashcam-player-overlay dashcam-player-overlay--${kind}`; - overlay.innerHTML = ``; - const videoEl = overlay.querySelector("video"); - const toastEl = overlay.querySelector(".dashcam-player-toast"); - const downloadUrl = src + (src.includes("?") ? "&" : "?") + "download=1"; - let toastTimer = null; - let suppressToasts = true; - const showToast = (text) => { - if (!toastEl || suppressToasts || !text) return; - toastEl.textContent = text; - toastEl.classList.add("is-visible"); - if (toastTimer) window.clearTimeout(toastTimer); - toastTimer = window.setTimeout(() => toastEl.classList.remove("is-visible"), 850); - }; - let player = null; - const close = () => { - if (toastTimer) window.clearTimeout(toastTimer); - try { player?.destroy?.(); } catch {} - overlay.remove(); - }; - overlay.addEventListener("click", (ev) => { - if (ev.target === overlay) close(); - }); - overlay.querySelector(".dashcam-player-close")?.addEventListener("click", close); - document.body.appendChild(overlay); - requestAnimationFrame(() => { - overlay.classList.add("is-open"); - try { - player = new Plyr(videoEl, { - controls: ["play-large","rewind","play","fast-forward","progress","current-time","fullscreen","download"], - hideControls: false, - seekTime: 5, - keyboard: { focused: true, global: false }, - fullscreen: { enabled: true, fallback: true, iosNative: true }, - urls: { download: downloadUrl }, - }); - player.source = { - type: "video", - title: title || "Video", - sources: [{ src, type: "video/mp4" }], - }; - player.once("ready", () => { - const container = player.elements?.container || overlay; - const bindBtn = (sel, label) => { - container.querySelectorAll(sel).forEach((btn) => btn.addEventListener("click", () => showToast(label))); - }; - bindBtn('[data-plyr="rewind"]', `⏪ ${getUIText("rewind_5", "5s")}`); - bindBtn('[data-plyr="fast-forward"]', `${getUIText("forward_5", "5s")} ⏩`); - bindBtn('[data-plyr="download"]', `⤓ ${getUIText("download", "Download")}`); - container.addEventListener("keydown", (ev) => { - if (ev.key === "ArrowLeft") showToast(`⏪ ${getUIText("rewind_5", "5s")}`); - else if (ev.key === "ArrowRight") showToast(`${getUIText("forward_5", "5s")} ⏩`); - }); - player.on("play", () => showToast(`▶ ${getUIText("play", "Play")}`)); - player.on("pause", () => showToast(`⏸ ${getUIText("pause", "Pause")}`)); - player.on("ended", () => showToast(getUIText("ended", "End"))); - player.on("ratechange", () => showToast(`⚡ ${player.speed}x`)); - player.on("enterfullscreen", () => showToast(`⛶ ${getUIText("fullscreen", "Fullscreen")}`)); - player.on("exitfullscreen", () => showToast(getUIText("fullscreen_exit", "Exit fullscreen"))); - videoEl.addEventListener("enterpictureinpicture", () => showToast("⊞ PiP")); - videoEl.addEventListener("leavepictureinpicture", () => showToast(`⊟ ${getUIText("pip_exit", "Exit PiP")}`)); - window.setTimeout(() => { suppressToasts = false; }, 350); - }); - } catch (err) { - videoEl.controls = true; - videoEl.src = src; - } - }); -} - function openDashcamPlayer(route, segment) { openLogsVideoPlayer( `${dashcamRouteTitle(route)} · Segment ${dashcamSegmentIndex(segment)}`, @@ -1182,11 +890,6 @@ function openDashcamPlayer(route, segment) { ); } -function openScreenrecordPlayer(id, name) { - if (!id) return; - openLogsVideoPlayer(name || getUIText("logs_screenrecord", "Screen Record"), screenrecordApiPath("video", id), { kind: "screenrecord" }); -} - function dashcamUploadStats(items) { const list = Array.isArray(items) ? items : []; return list.reduce((stats, item) => { @@ -1560,329 +1263,3 @@ async function showDashcamSegmentMenu(route, segment) { window.open(dashcamApiPath(`download/${encodeURIComponent(segment)}`, kind), "_blank", "noopener"); } } - -function screenrecordVideoRowHtml(video, index = 0) { - const id = escapeHtml(video.id || ""); - const name = escapeHtml(video.name || "-"); - const date = escapeHtml(formatRelativeEpoch(video.modifiedEpoch) || localizeRelativeLabel(video.modifiedLabel || video.relativeModifiedLabel) || "-"); - const size = escapeHtml(formatLogBytes(video.size)); - const ext = escapeHtml((video.ext || "video").toUpperCase()); - return `
- -
-
${name}
-
- ${date} - ${size} - ${ext} -
-
- -
`; -} - -function renderScreenrecordVideos(options = {}) { - const host = document.getElementById("screenrecordVideos"); - if (!host) return; - if (!isLogsPageActive()) return; - const preserve = options.preserve === true; - const videos = screenrecordState.videos || []; - if (screenrecordState.loading && !videos.length) { - setScreenrecordStatus(""); - host.innerHTML = logsLoadingSkeletonHtml("screen"); - host.dataset.signature = ""; - host.dataset.renderCount = "0"; - return; - } - if (!videos.length) { - host.innerHTML = logsEmptyStateHtml("screen"); - host.dataset.signature = ""; - host.dataset.renderCount = "0"; - setScreenrecordStatus(""); - return; - } - setScreenrecordStatus(""); - const view = screenrecordWindowFor(host, videos.length); - const nextSignature = `${screenrecordState.signature || screenrecordVideosSignature(videos)}|${view.start}:${view.end}|${screenrecordState.loadingMore ? "more" : ""}`; - if (preserve && host.dataset.signature === nextSignature) { - hydrateLogsLazyImages(host); - return; - } - patchScreenrecordWindow(host, videos, view); - host.dataset.signature = nextSignature; - host.dataset.renderCount = String(view.end - view.start); - screenrecordState.windowStart = view.start; - screenrecordState.windowEnd = view.end; - setScreenrecordLoadingMoreUi(screenrecordState.loadingMore); - hydrateLogsLazyImages(host); - requestAnimationFrame(() => screenrecordMeasureRowHeight(host)); -} - -async function loadScreenrecordVideos({ silent = false, append = false } = {}) { - if (append && (!screenrecordState.hasMore || screenrecordState.loading || screenrecordState.loadingMore)) return; - const seq = ++screenrecordState.loadSeq; - if (append) { - screenrecordState.loadingMore = true; - setScreenrecordLoadingMoreUi(true); - } else if (!silent) { - screenrecordState.loading = true; - screenrecordState.loadingMore = false; - setScreenrecordLoadingMoreUi(false); - renderScreenrecordVideos(); - } - try { - const offset = append ? (screenrecordState.nextOffset || screenrecordState.videos.length || 0) : 0; - const limit = append ? SCREENRECORD_PAGE_SIZE : Math.max(SCREENRECORD_PAGE_SIZE, screenrecordState.videos.length || 0); - const json = await getJson(`/api/screenrecord/videos?offset=${offset}&limit=${limit}`); - if (seq !== screenrecordState.loadSeq) return; - if (!isLogsPageActive()) { - screenrecordState.loading = false; - screenrecordState.loadingMore = false; - setScreenrecordLoadingMoreUi(false); - return; - } - const incoming = Array.isArray(json.videos) ? json.videos : []; - const videos = append ? screenrecordState.videos.concat(incoming) : incoming; - const nextSignature = screenrecordVideosSignature(videos); - if (silent && nextSignature === screenrecordState.signature) { - screenrecordState.loading = false; - screenrecordState.loadingMore = false; - setScreenrecordLoadingMoreUi(false); - return; - } - screenrecordState.videos = videos; - screenrecordState.signature = nextSignature; - screenrecordState.total = Number.isFinite(Number(json.total)) ? Number(json.total) : videos.length; - screenrecordState.nextOffset = json.nextOffset == null ? videos.length : Number(json.nextOffset) || videos.length; - screenrecordState.hasMore = Boolean(json.hasMore); - screenrecordState.loading = false; - screenrecordState.loadingMore = false; - setScreenrecordLoadingMoreUi(false); - renderScreenrecordVideos({ animate: !silent }); - if (!silent && logsScrollTops.screen === 0) restoreLogsScrollTop("screen", { reset: true }); - } catch (e) { - if (seq !== screenrecordState.loadSeq) return; - screenrecordState.loading = false; - screenrecordState.loadingMore = false; - setScreenrecordLoadingMoreUi(false); - if (!silent && isLogsPageActive()) { - setScreenrecordStatus(`${getUIText("screenrecord_load_failed", "Failed to load screen recordings")}: ${e.message || e}`, "error"); - showAppToast(e.message || getUIText("screenrecord_load_failed", "Failed to load screen recordings"), { tone: "error" }); - } - } -} - -function activateLogsTab(tab, options = {}) { - const nextTab = tab === "screen" ? "screen" : "dashcam"; - const shouldLoad = options.load !== false; - if (nextTab !== logsActiveTab) saveLogsScrollTop(logsActiveTab); - logsActiveTab = nextTab; - const dashTab = document.getElementById("logsTabDashcam"); - const screenTab = document.getElementById("logsTabScreen"); - const dashPanel = document.getElementById("logsDashcamPanel"); - const screenPanel = document.getElementById("logsScreenPanel"); - - dashTab?.classList.toggle("is-active", logsActiveTab === "dashcam"); - screenTab?.classList.toggle("is-active", logsActiveTab === "screen"); - dashTab?.setAttribute("aria-selected", logsActiveTab === "dashcam" ? "true" : "false"); - screenTab?.setAttribute("aria-selected", logsActiveTab === "screen" ? "true" : "false"); - if (dashPanel) dashPanel.hidden = logsActiveTab !== "dashcam"; - if (screenPanel) screenPanel.hidden = logsActiveTab !== "screen"; - - if (shouldLoad) { - if (logsActiveTab === "screen" && !screenrecordState.initialized) { - screenrecordState.initialized = true; - loadScreenrecordVideos().catch(() => {}); - } else if (logsActiveTab === "screen") { - renderScreenrecordVideos(); - loadScreenrecordVideos({ silent: true }).catch(() => {}); - } else if (dashcamState.initialized) { - loadDashcamRoutes({ silent: true }).catch(() => {}); - } - } - if (options.restoreScroll !== false) restoreLogsScrollTop(logsActiveTab); -} - -function handleLogsPageChange(event) { - const page = event?.detail?.page || ""; - if (page === "logs") return; - saveLogsScrollTop(logsActiveTab); - cancelDashcamRouteRender(); - dashcamState.loadSeq += 1; - screenrecordState.loadSeq += 1; - dashcamState.loading = false; - dashcamState.loadingMore = false; - dashcamState.loadingSegments?.clear?.(); - setDashcamLoadingMoreUi(false); - screenrecordState.loading = false; - dashcamState.scrollBusy = false; - if (dashcamState.scrollTimer) { - window.clearTimeout(dashcamState.scrollTimer); - dashcamState.scrollTimer = null; - } - if (dashcamState.layoutTimer) { - window.clearTimeout(dashcamState.layoutTimer); - dashcamState.layoutTimer = null; - } - disconnectLogsLazyImages(); -} - -function bindLogsPage() { - const dashTab = document.getElementById("logsTabDashcam"); - const screenTab = document.getElementById("logsTabScreen"); - const routesHost = document.getElementById("dashcamRoutes"); - const screenHost = document.getElementById("screenrecordVideos"); - - if (!dashcamState.layoutBound) { - dashcamState.layoutBound = true; - dashcamState.landscape = isCompactLandscapeMode(); - dashcamState.layoutKey = dashcamLayoutKey(); - window.addEventListener("carrot:pagechange", handleLogsPageChange); - window.addEventListener("carrot:languagechange", () => { - dashcamState.signature = ""; - screenrecordState.signature = ""; - dashcamState.routeHeights = Object.create(null); - const dashcamHost = document.getElementById("dashcamRoutes"); - if (dashcamHost) dashcamHost.dataset.signature = ""; - const screenHost = document.getElementById("screenrecordVideos"); - if (screenHost) screenHost.dataset.signature = ""; - - if (isLogsPageActive()) { - renderDashcamRoutes({ animate: false }); - if (typeof renderScreenrecordVideos === "function") renderScreenrecordVideos({ animate: false }); - } - }); - window.addEventListener("resize", () => { - if (CURRENT_PAGE !== "logs") return; - if (dashcamState.layoutTimer) window.clearTimeout(dashcamState.layoutTimer); - dashcamState.layoutTimer = window.setTimeout(() => { - dashcamState.layoutTimer = null; - if (!isLogsPageActive()) return; - const nextLandscape = isCompactLandscapeMode(); - const nextLayoutKey = dashcamLayoutKey(); - if (dashcamState.layoutKey === nextLayoutKey) return; - dashcamState.landscape = nextLandscape; - dashcamState.layoutKey = nextLayoutKey; - dashcamState.routeHeights = Object.create(null); - dashcamState.routeHeight = dashcamDefaultRouteHeight(); - const dashcamHost = document.getElementById("dashcamRoutes"); - if (dashcamHost) dashcamHost.dataset.signature = ""; - renderDashcamRoutes({ animate: false }); - if (typeof renderScreenrecordVideos === "function") renderScreenrecordVideos({ preserve: true, animate: false }); - }, 120); - }, { passive: true }); - } - - if (dashTab && dashTab.dataset.bound !== "1") { - dashTab.dataset.bound = "1"; - dashTab.addEventListener("click", () => activateLogsTab("dashcam")); - } - - if (screenTab && screenTab.dataset.bound !== "1") { - screenTab.dataset.bound = "1"; - screenTab.addEventListener("click", () => activateLogsTab("screen")); - } - - if (routesHost && routesHost.dataset.bound !== "1") { - routesHost.dataset.bound = "1"; - routesHost.addEventListener("scroll", () => { - markDashcamScrollBusy(); - saveLogsScrollTop("dashcam"); - if (dashcamWindowNeedsRender(routesHost)) scheduleDashcamWindowRender(); - maybeLoadMoreDashcamRoutes(routesHost); - }, { passive: true }); - routesHost.addEventListener("scroll", (ev) => { - const segmentList = ev.target?.closest?.(".dashcam-segment-list"); - if (!segmentList || segmentList === routesHost) return; - scheduleSegmentListScrollPersist(segmentList); - }, { passive: true, capture: true }); - routesHost.addEventListener("click", (ev) => { - const actionEl = ev.target?.closest?.("[data-action]"); - if (!actionEl) return; - const action = actionEl.dataset.action; - const route = actionEl.dataset.route || ""; - const segment = actionEl.dataset.segment || ""; - if (action === "toggle-route") { - if (dashcamState.expanded.has(route)) dashcamState.expanded.delete(route); - else dashcamState.expanded.add(route); - if (route && dashcamState.routeHeights) delete dashcamState.routeHeights[route]; - if (!renderDashcamRoute(route)) renderDashcamRoutes({ animate: false }); - } else if (action === "play") { - openDashcamPlayer(route, segment); - } else if (action === "segment-menu") { - ev.stopPropagation(); - showDashcamSegmentMenu(route, segment).catch(() => {}); - } else if (action === "select-route") { - const entry = dashcamState.routes.find((item) => item.route === route); - if (!entry) return; - const shouldClear = actionEl.dataset.selected === "1"; - for (const item of dashcamSegmentsForRoute(entry)) { - if (shouldClear) dashcamState.selected.delete(item); - else dashcamState.selected.add(item); - } - if (!updateDashcamRouteSelectionUi(route)) renderDashcamRoutes({ animate: false }); - } else if (action === "upload-selected") { - const entry = dashcamState.routes.find((item) => item.route === route); - const targets = dashcamSelectedForRoute(entry || { segmentFolders: [] }); - uploadDashcamSegments(targets).catch(() => {}); - } - }); - routesHost.addEventListener("change", (ev) => { - const input = ev.target; - if (!input?.matches?.('input[data-action="select-segment"]')) return; - const segment = input.dataset.segment || ""; - if (input.checked) dashcamState.selected.add(segment); - else dashcamState.selected.delete(segment); - const route = input.closest("[data-route-card]")?.dataset.routeCard || ""; - if (!updateDashcamRouteSelectionUi(route)) renderDashcamRoutes({ animate: false }); - }); - } - - if (screenHost && screenHost.dataset.bound !== "1") { - screenHost.dataset.bound = "1"; - screenHost.addEventListener("scroll", () => { - markDashcamScrollBusy(); - saveLogsScrollTop("screen"); - scheduleScreenrecordWindowRender(); - if (screenrecordShouldLoadMore(screenHost)) { - loadScreenrecordVideos({ silent: true, append: true }).catch(() => {}); - } - }, { passive: true }); - screenHost.addEventListener("click", (ev) => { - const actionEl = ev.target?.closest?.("[data-action]"); - if (!actionEl) return; - if (actionEl.dataset.action === "download-screenrecord") { - const id = actionEl.dataset.id || ""; - if (id) window.open(screenrecordApiPath("download", id), "_blank", "noopener"); - } else if (actionEl.dataset.action === "play-screenrecord") { - openScreenrecordPlayer(actionEl.dataset.id || "", actionEl.dataset.name || ""); - } - }); - } -} - -function initLogsPage() { - bindLogsPage(); - activateLogsTab(logsActiveTab, { load: false }); - startDashcamAutoRefresh(); - resumeDashcamUploadJobIfNeeded().catch(() => {}); - if (logsActiveTab === "screen") { - if (!screenrecordState.initialized) { - screenrecordState.initialized = true; - loadScreenrecordVideos().catch(() => {}); - } else { - renderScreenrecordVideos({ preserve: true }); - loadScreenrecordVideos({ silent: true }).catch(() => {}); - } - } else if (!dashcamState.initialized) { - dashcamState.initialized = true; - loadDashcamRoutes().catch(() => {}); - } else { - renderDashcamRoutes({ animate: false, preserve: true }); - loadDashcamRoutes({ silent: true }).catch(() => {}); - } -} diff --git a/selfdrive/carrot/web/js/pages/logs/screenrecord.js b/selfdrive/carrot/web/js/pages/logs/screenrecord.js new file mode 100644 index 0000000000..eee6f40bf4 --- /dev/null +++ b/selfdrive/carrot/web/js/pages/logs/screenrecord.js @@ -0,0 +1,247 @@ +"use strict"; + +// Logs page — Screen Recording tab. +// Virtual list of saved screen recordings with lazy thumbnails, paged loading, +// and a download/playback action row. + +const SCREENRECORD_PAGE_SIZE = 40; +const SCREENRECORD_LOAD_AHEAD_PX = 720; +const SCREENRECORD_WINDOW_OVERSCAN = 8; + +const screenrecordState = { + initialized: false, + loading: false, + loadingMore: false, + loadSeq: 0, + videos: [], + rowHeight: 80, + windowStart: 0, + windowEnd: 0, + total: 0, + nextOffset: 0, + hasMore: false, + signature: "", + renderFrame: 0, +}; + +function setScreenrecordStatus(message, tone = "") { + const status = document.getElementById("screenrecordStatus"); + if (!status) return; + status.textContent = message || ""; + status.hidden = !message; + status.classList.toggle("is-error", tone === "error"); +} + +function screenrecordApiPath(kind, fileId) { + return `/api/screenrecord/${kind}/${encodeURIComponent(fileId)}`; +} + +function screenrecordVideosSignature(videos) { + return (videos || []).map((video) => [ + video.id || "", + video.name || "", + video.modifiedLabel || "", + video.size || 0, + ].join("|")).join("\n") + "|" + (typeof LANG !== "undefined" ? LANG : ""); +} + +function screenrecordShouldLoadMore(scroller) { + if (!scroller || !screenrecordState.hasMore || screenrecordState.loading || screenrecordState.loadingMore) return false; + const remaining = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight; + return remaining <= SCREENRECORD_LOAD_AHEAD_PX; +} + +function screenrecordWindowFor(host, count) { + const rowHeight = Math.max(48, Number(screenrecordState.rowHeight) || 80); + const viewportHeight = Math.max(1, host?.clientHeight || rowHeight * 8); + const scrollTop = Math.max(0, host?.scrollTop || 0); + const visibleRows = Math.ceil(viewportHeight / rowHeight); + const start = Math.max(0, Math.floor(scrollTop / rowHeight) - SCREENRECORD_WINDOW_OVERSCAN); + const end = Math.min(count, start + visibleRows + (SCREENRECORD_WINDOW_OVERSCAN * 2)); + return { start, end, rowHeight }; +} + +function screenrecordMeasureRowHeight(host) { + const row = host?.querySelector?.(".screenrecord-row"); + if (!row) return; + const styles = window.getComputedStyle?.(host); + const gap = Number.parseFloat(styles?.rowGap || styles?.gap || "0") || 0; + const nextHeight = Math.max(48, row.getBoundingClientRect().height + gap); + if (Math.abs(nextHeight - screenrecordState.rowHeight) < 1) return; + screenrecordState.rowHeight = nextHeight; +} + +function screenrecordSpacerNode(height, position) { + if (height <= 0) return null; + const node = document.createElement("div"); + node.className = "screenrecord-virtual-spacer"; + node.dataset.spacer = position; + node.style.height = `${Math.round(height)}px`; + return node; +} + +function screenrecordRowNode(video, index, existingRows) { + const id = String(video?.id || ""); + const existing = id ? existingRows.get(id) : null; + if (existing) { + existing.style.setProperty("--i", String(index)); + existing.classList.remove("ui-stagger-item"); + return existing; + } + const template = document.createElement("template"); + template.innerHTML = screenrecordVideoRowHtml(video, index); + return template.content.firstElementChild; +} + +function patchScreenrecordWindow(host, videos, view) { + const existingRows = new Map( + Array.from(host.querySelectorAll(".screenrecord-row")) + .map((node) => [node.dataset.id || "", node]) + .filter(([id]) => Boolean(id)) + ); + const frag = document.createDocumentFragment(); + const topSpacer = screenrecordSpacerNode(view.start * view.rowHeight, "top"); + const bottomSpacer = screenrecordSpacerNode((videos.length - view.end) * view.rowHeight, "bottom"); + if (topSpacer) frag.appendChild(topSpacer); + videos.slice(view.start, view.end).forEach((video, offset) => { + const row = screenrecordRowNode(video, view.start + offset, existingRows); + if (row) frag.appendChild(row); + }); + if (bottomSpacer) frag.appendChild(bottomSpacer); + unobserveLogsLazyImages(host); + host.replaceChildren(frag); +} + +function setScreenrecordLoadingMoreUi(active) { + const host = document.getElementById("screenrecordVideos"); + if (!host) return; + host.classList.toggle("is-loading-more", Boolean(active)); +} + +function scheduleScreenrecordWindowRender() { + if (screenrecordState.renderFrame) return; + screenrecordState.renderFrame = requestAnimationFrame(() => { + screenrecordState.renderFrame = 0; + renderScreenrecordVideos({ preserve: true }); + }); +} + +function openScreenrecordPlayer(id, name) { + if (!id) return; + openLogsVideoPlayer(name || getUIText("logs_screenrecord", "Screen Record"), screenrecordApiPath("video", id), { kind: "screenrecord" }); +} + +function screenrecordVideoRowHtml(video, index = 0) { + const id = escapeHtml(video.id || ""); + const name = escapeHtml(video.name || "-"); + const date = escapeHtml(formatRelativeEpoch(video.modifiedEpoch) || localizeRelativeLabel(video.modifiedLabel || video.relativeModifiedLabel) || "-"); + const size = escapeHtml(formatLogBytes(video.size)); + const ext = escapeHtml((video.ext || "video").toUpperCase()); + return `
+ +
+
${name}
+
+ ${date} + ${size} + ${ext} +
+
+ +
`; +} + +function renderScreenrecordVideos(options = {}) { + const host = document.getElementById("screenrecordVideos"); + if (!host) return; + if (!isLogsPageActive()) return; + const preserve = options.preserve === true; + const videos = screenrecordState.videos || []; + if (screenrecordState.loading && !videos.length) { + setScreenrecordStatus(""); + host.innerHTML = logsLoadingSkeletonHtml("screen"); + host.dataset.signature = ""; + host.dataset.renderCount = "0"; + return; + } + if (!videos.length) { + host.innerHTML = logsEmptyStateHtml("screen"); + host.dataset.signature = ""; + host.dataset.renderCount = "0"; + setScreenrecordStatus(""); + return; + } + setScreenrecordStatus(""); + const view = screenrecordWindowFor(host, videos.length); + const nextSignature = `${screenrecordState.signature || screenrecordVideosSignature(videos)}|${view.start}:${view.end}|${screenrecordState.loadingMore ? "more" : ""}`; + if (preserve && host.dataset.signature === nextSignature) { + hydrateLogsLazyImages(host); + return; + } + patchScreenrecordWindow(host, videos, view); + host.dataset.signature = nextSignature; + host.dataset.renderCount = String(view.end - view.start); + screenrecordState.windowStart = view.start; + screenrecordState.windowEnd = view.end; + setScreenrecordLoadingMoreUi(screenrecordState.loadingMore); + hydrateLogsLazyImages(host); + requestAnimationFrame(() => screenrecordMeasureRowHeight(host)); +} + +async function loadScreenrecordVideos({ silent = false, append = false } = {}) { + if (append && (!screenrecordState.hasMore || screenrecordState.loading || screenrecordState.loadingMore)) return; + const seq = ++screenrecordState.loadSeq; + if (append) { + screenrecordState.loadingMore = true; + setScreenrecordLoadingMoreUi(true); + } else if (!silent) { + screenrecordState.loading = true; + screenrecordState.loadingMore = false; + setScreenrecordLoadingMoreUi(false); + renderScreenrecordVideos(); + } + try { + const offset = append ? (screenrecordState.nextOffset || screenrecordState.videos.length || 0) : 0; + const limit = append ? SCREENRECORD_PAGE_SIZE : Math.max(SCREENRECORD_PAGE_SIZE, screenrecordState.videos.length || 0); + const json = await getJson(`/api/screenrecord/videos?offset=${offset}&limit=${limit}`); + if (seq !== screenrecordState.loadSeq) return; + if (!isLogsPageActive()) { + screenrecordState.loading = false; + screenrecordState.loadingMore = false; + setScreenrecordLoadingMoreUi(false); + return; + } + const incoming = Array.isArray(json.videos) ? json.videos : []; + const videos = append ? screenrecordState.videos.concat(incoming) : incoming; + const nextSignature = screenrecordVideosSignature(videos); + if (silent && nextSignature === screenrecordState.signature) { + screenrecordState.loading = false; + screenrecordState.loadingMore = false; + setScreenrecordLoadingMoreUi(false); + return; + } + screenrecordState.videos = videos; + screenrecordState.signature = nextSignature; + screenrecordState.total = Number.isFinite(Number(json.total)) ? Number(json.total) : videos.length; + screenrecordState.nextOffset = json.nextOffset == null ? videos.length : Number(json.nextOffset) || videos.length; + screenrecordState.hasMore = Boolean(json.hasMore); + screenrecordState.loading = false; + screenrecordState.loadingMore = false; + setScreenrecordLoadingMoreUi(false); + renderScreenrecordVideos({ animate: !silent }); + if (!silent && logsScrollTops.screen === 0) restoreLogsScrollTop("screen", { reset: true }); + } catch (e) { + if (seq !== screenrecordState.loadSeq) return; + screenrecordState.loading = false; + screenrecordState.loadingMore = false; + setScreenrecordLoadingMoreUi(false); + if (!silent && isLogsPageActive()) { + setScreenrecordStatus(`${getUIText("screenrecord_load_failed", "Failed to load screen recordings")}: ${e.message || e}`, "error"); + showAppToast(e.message || getUIText("screenrecord_load_failed", "Failed to load screen recordings"), { tone: "error" }); + } + } +} diff --git a/selfdrive/carrot/web/js/pages/logs/shared.js b/selfdrive/carrot/web/js/pages/logs/shared.js new file mode 100644 index 0000000000..77bdf32a96 --- /dev/null +++ b/selfdrive/carrot/web/js/pages/logs/shared.js @@ -0,0 +1,436 @@ +"use strict"; + +// Logs page — shared infra used by both the Dashcam and Screen Recording tabs. +// Owns: tab state, scroll persistence, lazy-image observer, generic helpers, +// the video player, and page bind/init/teardown. + +let logsActiveTab = "dashcam"; +const logsScrollTops = { dashcam: 0, screen: 0 }; +let logsLazyImageObserver = null; + +function isLogsPageActive() { + return CURRENT_PAGE === "logs"; +} + +function getLogsScroller(tab = logsActiveTab) { + return document.getElementById(tab === "screen" ? "screenrecordVideos" : "dashcamRoutes"); +} + +function saveLogsScrollTop(tab = logsActiveTab) { + const scroller = getLogsScroller(tab); + if (!scroller) return; + logsScrollTops[tab === "screen" ? "screen" : "dashcam"] = scroller.scrollTop || 0; +} + +function restoreLogsScrollTop(tab = logsActiveTab, options = {}) { + const scroller = getLogsScroller(tab); + if (!scroller) return; + const key = tab === "screen" ? "screen" : "dashcam"; + const nextTop = options.reset === true ? 0 : (logsScrollTops[key] || 0); + if (CURRENT_PAGE === "logs") { + window.scrollTo(0, 0); + document.documentElement.scrollTop = 0; + document.body.scrollTop = 0; + } + requestAnimationFrame(() => { + if (!isLogsPageActive()) return; + scroller.scrollTop = nextTop; + requestAnimationFrame(() => { + if (!isLogsPageActive()) return; + scroller.scrollTop = nextTop; + if (key === "dashcam" && typeof scheduleDashcamWindowRender === "function") scheduleDashcamWindowRender(); + if (key === "screen" && typeof scheduleScreenrecordWindowRender === "function") scheduleScreenrecordWindowRender(); + }); + }); +} + +function formatRelativeEpoch(epochSeconds) { + const epoch = Number(epochSeconds || 0); + if (!Number.isFinite(epoch) || epoch <= 0) return ""; + const delta = Math.max(0, Math.floor(Date.now() / 1000) - Math.floor(epoch)); + if (delta < 60) return getUIText("just_now", "just now"); + if (delta < 3600) return getUIText("minutes_ago", "{count} min ago", { count: Math.floor(delta / 60) }); + if (delta < 86400) return getUIText("hours_ago", "{count} hr ago", { count: Math.floor(delta / 3600) }); + return getUIText("days_ago", "{count} days ago", { count: Math.floor(delta / 86400) }); +} + +function localizeRelativeLabel(label) { + const text = String(label || "").trim(); + if (!text) return ""; + if (/^(방금\s*전|just\s*now)$/i.test(text)) return getUIText("just_now", "just now"); + const minuteMatch = text.match(/^(\d+)\s*(?:분\s*전|min(?:ute)?s?\s*ago)$/i); + if (minuteMatch) return getUIText("minutes_ago", "{count} min ago", { count: minuteMatch[1] }); + const hourMatch = text.match(/^(\d+)\s*(?:시간\s*전|hr?s?\s*ago|hour?s?\s*ago)$/i); + if (hourMatch) return getUIText("hours_ago", "{count} hr ago", { count: hourMatch[1] }); + const dayMatch = text.match(/^(\d+)\s*(?:일\s*전|day?s?\s*ago)$/i); + if (dayMatch) return getUIText("days_ago", "{count} days ago", { count: dayMatch[1] }); + return text; +} + +function formatLogBytes(bytes) { + const n = Number(bytes) || 0; + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`; + return `${(n / (1024 * 1024 * 1024)).toFixed(1)} GB`; +} + +function loadLogsLazyImage(img) { + if (!img) return; + const src = img.dataset?.src || ""; + if (!src) return; + img.src = src; + img.removeAttribute("data-src"); +} + +function hydrateLogsLazyImages(root) { + if (!isLogsPageActive()) return; + const scope = root || document; + const images = Array.from(scope.querySelectorAll?.("img[data-src]") || []); + if (!images.length) return; + + if (!("IntersectionObserver" in window)) { + images.forEach(loadLogsLazyImage); + return; + } + + if (!logsLazyImageObserver) { + logsLazyImageObserver = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (!entry.isIntersecting) return; + logsLazyImageObserver.unobserve(entry.target); + loadLogsLazyImage(entry.target); + }); + }, { root: null, rootMargin: "720px 0px", threshold: 0.01 }); + } + + images.forEach((img) => logsLazyImageObserver.observe(img)); +} + +function disconnectLogsLazyImages() { + if (!logsLazyImageObserver) return; + logsLazyImageObserver.disconnect(); + logsLazyImageObserver = null; +} + +function unobserveLogsLazyImages(root) { + if (!logsLazyImageObserver || !root) return; + root.querySelectorAll?.("img[data-src]").forEach((img) => { + logsLazyImageObserver.unobserve(img); + }); +} + +function logsLoadingSkeletonHtml(type = "dashcam") { + const count = type === "screen" ? 6 : 4; + const itemClass = type === "screen" ? "logs-loading-row" : "logs-loading-card"; + return ``; +} + +function logsEmptyStateHtml(type = "dashcam") { + const isScreen = type === "screen"; + const title = isScreen + ? getUIText("screenrecord_empty_title", "No screen recordings") + : getUIText("dashcam_empty_title", "No dashcam records"); + + return ` +
+
${escapeHtml(title)}
+
`; +} + +function openLogsVideoPlayer(title, src, options = {}) { + const overlay = document.createElement("div"); + const kind = String(options.kind || "video").replace(/[^a-z0-9_-]/gi, ""); + overlay.className = `dashcam-player-overlay dashcam-player-overlay--${kind}`; + overlay.innerHTML = ``; + const videoEl = overlay.querySelector("video"); + const toastEl = overlay.querySelector(".dashcam-player-toast"); + const downloadUrl = src + (src.includes("?") ? "&" : "?") + "download=1"; + let toastTimer = null; + let suppressToasts = true; + const showToast = (text) => { + if (!toastEl || suppressToasts || !text) return; + toastEl.textContent = text; + toastEl.classList.add("is-visible"); + if (toastTimer) window.clearTimeout(toastTimer); + toastTimer = window.setTimeout(() => toastEl.classList.remove("is-visible"), 850); + }; + let player = null; + const close = () => { + if (toastTimer) window.clearTimeout(toastTimer); + try { player?.destroy?.(); } catch {} + overlay.remove(); + }; + overlay.addEventListener("click", (ev) => { + if (ev.target === overlay) close(); + }); + overlay.querySelector(".dashcam-player-close")?.addEventListener("click", close); + document.body.appendChild(overlay); + requestAnimationFrame(() => { + overlay.classList.add("is-open"); + try { + player = new Plyr(videoEl, { + controls: ["play-large","rewind","play","fast-forward","progress","current-time","fullscreen","download"], + hideControls: false, + seekTime: 5, + keyboard: { focused: true, global: false }, + fullscreen: { enabled: true, fallback: true, iosNative: true }, + urls: { download: downloadUrl }, + }); + player.source = { + type: "video", + title: title || "Video", + sources: [{ src, type: "video/mp4" }], + }; + player.once("ready", () => { + const container = player.elements?.container || overlay; + const bindBtn = (sel, label) => { + container.querySelectorAll(sel).forEach((btn) => btn.addEventListener("click", () => showToast(label))); + }; + bindBtn('[data-plyr="rewind"]', `⏪ ${getUIText("rewind_5", "5s")}`); + bindBtn('[data-plyr="fast-forward"]', `${getUIText("forward_5", "5s")} ⏩`); + bindBtn('[data-plyr="download"]', `⤓ ${getUIText("download", "Download")}`); + container.addEventListener("keydown", (ev) => { + if (ev.key === "ArrowLeft") showToast(`⏪ ${getUIText("rewind_5", "5s")}`); + else if (ev.key === "ArrowRight") showToast(`${getUIText("forward_5", "5s")} ⏩`); + }); + player.on("play", () => showToast(`▶ ${getUIText("play", "Play")}`)); + player.on("pause", () => showToast(`⏸ ${getUIText("pause", "Pause")}`)); + player.on("ended", () => showToast(getUIText("ended", "End"))); + player.on("ratechange", () => showToast(`⚡ ${player.speed}x`)); + player.on("enterfullscreen", () => showToast(`⛶ ${getUIText("fullscreen", "Fullscreen")}`)); + player.on("exitfullscreen", () => showToast(getUIText("fullscreen_exit", "Exit fullscreen"))); + videoEl.addEventListener("enterpictureinpicture", () => showToast("⊞ PiP")); + videoEl.addEventListener("leavepictureinpicture", () => showToast(`⊟ ${getUIText("pip_exit", "Exit PiP")}`)); + window.setTimeout(() => { suppressToasts = false; }, 350); + }); + } catch (err) { + videoEl.controls = true; + videoEl.src = src; + } + }); +} + +function activateLogsTab(tab, options = {}) { + const nextTab = tab === "screen" ? "screen" : "dashcam"; + const shouldLoad = options.load !== false; + if (nextTab !== logsActiveTab) saveLogsScrollTop(logsActiveTab); + logsActiveTab = nextTab; + const dashTab = document.getElementById("logsTabDashcam"); + const screenTab = document.getElementById("logsTabScreen"); + const dashPanel = document.getElementById("logsDashcamPanel"); + const screenPanel = document.getElementById("logsScreenPanel"); + + dashTab?.classList.toggle("is-active", logsActiveTab === "dashcam"); + screenTab?.classList.toggle("is-active", logsActiveTab === "screen"); + dashTab?.setAttribute("aria-selected", logsActiveTab === "dashcam" ? "true" : "false"); + screenTab?.setAttribute("aria-selected", logsActiveTab === "screen" ? "true" : "false"); + if (dashPanel) dashPanel.hidden = logsActiveTab !== "dashcam"; + if (screenPanel) screenPanel.hidden = logsActiveTab !== "screen"; + + if (shouldLoad) { + if (logsActiveTab === "screen" && !screenrecordState.initialized) { + screenrecordState.initialized = true; + loadScreenrecordVideos().catch(() => {}); + } else if (logsActiveTab === "screen") { + renderScreenrecordVideos(); + loadScreenrecordVideos({ silent: true }).catch(() => {}); + } else if (dashcamState.initialized) { + loadDashcamRoutes({ silent: true }).catch(() => {}); + } + } + if (options.restoreScroll !== false) restoreLogsScrollTop(logsActiveTab); +} + +function handleLogsPageChange(event) { + const page = event?.detail?.page || ""; + if (page === "logs") return; + saveLogsScrollTop(logsActiveTab); + cancelDashcamRouteRender(); + dashcamState.loadSeq += 1; + screenrecordState.loadSeq += 1; + dashcamState.loading = false; + dashcamState.loadingMore = false; + dashcamState.loadingSegments?.clear?.(); + setDashcamLoadingMoreUi(false); + screenrecordState.loading = false; + dashcamState.scrollBusy = false; + if (dashcamState.scrollTimer) { + window.clearTimeout(dashcamState.scrollTimer); + dashcamState.scrollTimer = null; + } + if (dashcamState.layoutTimer) { + window.clearTimeout(dashcamState.layoutTimer); + dashcamState.layoutTimer = null; + } + disconnectLogsLazyImages(); +} + +function bindLogsPage() { + const dashTab = document.getElementById("logsTabDashcam"); + const screenTab = document.getElementById("logsTabScreen"); + const routesHost = document.getElementById("dashcamRoutes"); + const screenHost = document.getElementById("screenrecordVideos"); + + if (!dashcamState.layoutBound) { + dashcamState.layoutBound = true; + dashcamState.landscape = isCompactLandscapeMode(); + dashcamState.layoutKey = dashcamLayoutKey(); + window.addEventListener("carrot:pagechange", handleLogsPageChange); + window.addEventListener("carrot:languagechange", () => { + dashcamState.signature = ""; + screenrecordState.signature = ""; + dashcamState.routeHeights = Object.create(null); + const dashcamHost = document.getElementById("dashcamRoutes"); + if (dashcamHost) dashcamHost.dataset.signature = ""; + const screenHost = document.getElementById("screenrecordVideos"); + if (screenHost) screenHost.dataset.signature = ""; + + if (isLogsPageActive()) { + renderDashcamRoutes({ animate: false }); + if (typeof renderScreenrecordVideos === "function") renderScreenrecordVideos({ animate: false }); + } + }); + window.addEventListener("resize", () => { + if (CURRENT_PAGE !== "logs") return; + if (dashcamState.layoutTimer) window.clearTimeout(dashcamState.layoutTimer); + dashcamState.layoutTimer = window.setTimeout(() => { + dashcamState.layoutTimer = null; + if (!isLogsPageActive()) return; + const nextLandscape = isCompactLandscapeMode(); + const nextLayoutKey = dashcamLayoutKey(); + if (dashcamState.layoutKey === nextLayoutKey) return; + dashcamState.landscape = nextLandscape; + dashcamState.layoutKey = nextLayoutKey; + dashcamState.routeHeights = Object.create(null); + dashcamState.routeHeight = dashcamDefaultRouteHeight(); + const dashcamHost = document.getElementById("dashcamRoutes"); + if (dashcamHost) dashcamHost.dataset.signature = ""; + renderDashcamRoutes({ animate: false }); + if (typeof renderScreenrecordVideos === "function") renderScreenrecordVideos({ preserve: true, animate: false }); + }, 120); + }, { passive: true }); + } + + if (dashTab && dashTab.dataset.bound !== "1") { + dashTab.dataset.bound = "1"; + dashTab.addEventListener("click", () => activateLogsTab("dashcam")); + } + + if (screenTab && screenTab.dataset.bound !== "1") { + screenTab.dataset.bound = "1"; + screenTab.addEventListener("click", () => activateLogsTab("screen")); + } + + if (routesHost && routesHost.dataset.bound !== "1") { + routesHost.dataset.bound = "1"; + routesHost.addEventListener("scroll", () => { + markDashcamScrollBusy(); + saveLogsScrollTop("dashcam"); + if (dashcamWindowNeedsRender(routesHost)) scheduleDashcamWindowRender(); + maybeLoadMoreDashcamRoutes(routesHost); + }, { passive: true }); + routesHost.addEventListener("scroll", (ev) => { + const segmentList = ev.target?.closest?.(".dashcam-segment-list"); + if (!segmentList || segmentList === routesHost) return; + scheduleSegmentListScrollPersist(segmentList); + }, { passive: true, capture: true }); + routesHost.addEventListener("click", (ev) => { + const actionEl = ev.target?.closest?.("[data-action]"); + if (!actionEl) return; + const action = actionEl.dataset.action; + const route = actionEl.dataset.route || ""; + const segment = actionEl.dataset.segment || ""; + if (action === "toggle-route") { + if (dashcamState.expanded.has(route)) dashcamState.expanded.delete(route); + else dashcamState.expanded.add(route); + if (route && dashcamState.routeHeights) delete dashcamState.routeHeights[route]; + if (!renderDashcamRoute(route)) renderDashcamRoutes({ animate: false }); + } else if (action === "play") { + openDashcamPlayer(route, segment); + } else if (action === "segment-menu") { + ev.stopPropagation(); + showDashcamSegmentMenu(route, segment).catch(() => {}); + } else if (action === "select-route") { + const entry = dashcamState.routes.find((item) => item.route === route); + if (!entry) return; + const shouldClear = actionEl.dataset.selected === "1"; + for (const item of dashcamSegmentsForRoute(entry)) { + if (shouldClear) dashcamState.selected.delete(item); + else dashcamState.selected.add(item); + } + if (!updateDashcamRouteSelectionUi(route)) renderDashcamRoutes({ animate: false }); + } else if (action === "upload-selected") { + const entry = dashcamState.routes.find((item) => item.route === route); + const targets = dashcamSelectedForRoute(entry || { segmentFolders: [] }); + uploadDashcamSegments(targets).catch(() => {}); + } + }); + routesHost.addEventListener("change", (ev) => { + const input = ev.target; + if (!input?.matches?.('input[data-action="select-segment"]')) return; + const segment = input.dataset.segment || ""; + if (input.checked) dashcamState.selected.add(segment); + else dashcamState.selected.delete(segment); + const route = input.closest("[data-route-card]")?.dataset.routeCard || ""; + if (!updateDashcamRouteSelectionUi(route)) renderDashcamRoutes({ animate: false }); + }); + } + + if (screenHost && screenHost.dataset.bound !== "1") { + screenHost.dataset.bound = "1"; + screenHost.addEventListener("scroll", () => { + markDashcamScrollBusy(); + saveLogsScrollTop("screen"); + scheduleScreenrecordWindowRender(); + if (screenrecordShouldLoadMore(screenHost)) { + loadScreenrecordVideos({ silent: true, append: true }).catch(() => {}); + } + }, { passive: true }); + screenHost.addEventListener("click", (ev) => { + const actionEl = ev.target?.closest?.("[data-action]"); + if (!actionEl) return; + if (actionEl.dataset.action === "download-screenrecord") { + const id = actionEl.dataset.id || ""; + if (id) window.open(screenrecordApiPath("download", id), "_blank", "noopener"); + } else if (actionEl.dataset.action === "play-screenrecord") { + openScreenrecordPlayer(actionEl.dataset.id || "", actionEl.dataset.name || ""); + } + }); + } +} + +function initLogsPage() { + bindLogsPage(); + activateLogsTab(logsActiveTab, { load: false }); + startDashcamAutoRefresh(); + resumeDashcamUploadJobIfNeeded().catch(() => {}); + if (logsActiveTab === "screen") { + if (!screenrecordState.initialized) { + screenrecordState.initialized = true; + loadScreenrecordVideos().catch(() => {}); + } else { + renderScreenrecordVideos({ preserve: true }); + loadScreenrecordVideos({ silent: true }).catch(() => {}); + } + } else if (!dashcamState.initialized) { + dashcamState.initialized = true; + loadDashcamRoutes().catch(() => {}); + } else { + renderDashcamRoutes({ animate: false, preserve: true }); + loadDashcamRoutes({ silent: true }).catch(() => {}); + } +} diff --git a/selfdrive/carrot/web/js/app_realtime.js b/selfdrive/carrot/web/js/realtime/app_realtime.js similarity index 100% rename from selfdrive/carrot/web/js/app_realtime.js rename to selfdrive/carrot/web/js/realtime/app_realtime.js diff --git a/selfdrive/carrot/web/js/home_drive.js b/selfdrive/carrot/web/js/realtime/home_drive.js similarity index 100% rename from selfdrive/carrot/web/js/home_drive.js rename to selfdrive/carrot/web/js/realtime/home_drive.js diff --git a/selfdrive/carrot/web/js/hud_card.js b/selfdrive/carrot/web/js/realtime/hud_card.js similarity index 100% rename from selfdrive/carrot/web/js/hud_card.js rename to selfdrive/carrot/web/js/realtime/hud_card.js diff --git a/selfdrive/carrot/web/js/raw_capnp.js b/selfdrive/carrot/web/js/realtime/raw_capnp.js similarity index 100% rename from selfdrive/carrot/web/js/raw_capnp.js rename to selfdrive/carrot/web/js/realtime/raw_capnp.js diff --git a/selfdrive/carrot/web/js/raw_capnp_worker.js b/selfdrive/carrot/web/js/realtime/raw_capnp_worker.js similarity index 95% rename from selfdrive/carrot/web/js/raw_capnp_worker.js rename to selfdrive/carrot/web/js/realtime/raw_capnp_worker.js index 1271a407bf..4165e235e6 100644 --- a/selfdrive/carrot/web/js/raw_capnp_worker.js +++ b/selfdrive/carrot/web/js/realtime/raw_capnp_worker.js @@ -1,6 +1,6 @@ "use strict"; -self.importScripts("/js/raw_capnp.js"); +self.importScripts("/js/realtime/raw_capnp.js"); const rawCapnp = self.CarrotRawCapnp || null; diff --git a/selfdrive/carrot/web/js/vision_raw.js b/selfdrive/carrot/web/js/realtime/vision_raw.js similarity index 99% rename from selfdrive/carrot/web/js/vision_raw.js rename to selfdrive/carrot/web/js/realtime/vision_raw.js index 0b019f1ea7..1afd48243f 100644 --- a/selfdrive/carrot/web/js/vision_raw.js +++ b/selfdrive/carrot/web/js/realtime/vision_raw.js @@ -32,7 +32,7 @@ let RAW_HUD_MUX_DISABLED = false; let RAW_OVERLAY_MUX_WS = null; let RAW_OVERLAY_MUX_RETRY_T = null; let RAW_OVERLAY_MUX_DISABLED = false; -const RAW_DECODE_WORKER_URL = "/js/raw_capnp_worker.js"; +const RAW_DECODE_WORKER_URL = "/js/realtime/raw_capnp_worker.js"; let RAW_DECODE_WORKER = null; let RAW_DECODE_WORKER_FAILED = false; let RAW_DECODE_REQ_ID = 0; diff --git a/selfdrive/carrot/web/js/vision_rtc.js b/selfdrive/carrot/web/js/realtime/vision_rtc.js similarity index 100% rename from selfdrive/carrot/web/js/vision_rtc.js rename to selfdrive/carrot/web/js/realtime/vision_rtc.js diff --git a/selfdrive/carrot/web/js/vision_state.js b/selfdrive/carrot/web/js/realtime/vision_state.js similarity index 100% rename from selfdrive/carrot/web/js/vision_state.js rename to selfdrive/carrot/web/js/realtime/vision_state.js From 1ed8ed7c5c873669e91f7ac9a13bcf7d973fe2ce Mon Sep 17 00:00:00 2001 From: jominki354 Date: Mon, 11 May 2026 13:52:37 +0900 Subject: [PATCH 22/23] base guideline carrot web(for everyone) --- selfdrive/carrot/README.md | 430 ++++++++++++++++++ selfdrive/carrot/web/css/base.css | 33 ++ selfdrive/carrot/web/css/components.css | 336 ++++++++++++++ selfdrive/carrot/web/css/layout.css | 24 +- selfdrive/carrot/web/css/tokens.css | 148 +++++- selfdrive/carrot/web/index.html | 1 + .../carrot/web/js/shared/ui/focus_trap.js | 144 ++++++ 7 files changed, 1091 insertions(+), 25 deletions(-) create mode 100644 selfdrive/carrot/web/js/shared/ui/focus_trap.js diff --git a/selfdrive/carrot/README.md b/selfdrive/carrot/README.md index 232bc5a3e1..7a9b5583f4 100644 --- a/selfdrive/carrot/README.md +++ b/selfdrive/carrot/README.md @@ -184,3 +184,433 @@ recovery/ ├── __init__.py └── server.py port 6999, minimal self-contained recovery UI ``` + +--- + +## Design System Reference + +Everything below is a working contract. **When in doubt, copy the pattern.** +Don't invent new motion durations, shadow stacks, z-index numbers, or focus +ring colors — use the tokens. New components should compose existing +primitives before adding their own. + +All tokens live in [css/tokens.css](web/css/tokens.css) with usage comments next to each group. + +### Color (Material 3 dark) + +#### Surface & text + +| Token | Use for | +|---|---| +| `--md-surface` | page background | +| `--md-surface-cont` | cards, list rows | +| `--md-surface-cont-l` | slightly recessed (input field background) | +| `--md-surface-cont-h` | raised surfaces (dialog sheet, popover) | +| `--md-surface-cont-hh` | nested raised (chip on a card) | +| `--md-surface-bright` | highlight surface (selected row hover) | +| `--md-on-surface` | primary text | +| `--md-on-surface-var` | secondary text, captions | +| `--md-outline` / `--md-outline-var` | borders, dividers | + +#### Brand & state surfaces + +| Token | Use for | +|---|---| +| `--md-primary` | accent (Carrot orange) | +| `--md-on-primary` | text on a primary-filled surface | +| `--md-action-filled` / `--md-on-action-filled` | primary action button (filled variant) | +| `--md-primary-state-soft` / `-state` / `-state-strong` | hover/pressed surfaces tinted by primary | + +#### Semantic status + +Use these — don't hardcode greens, ambers, blues. Each family has a base color, a `-strong` accent, a `-cont` (container surface for chips/badges), and an `-on-*-cont` (text on that container). + +| Family | Base | When | +|---|---|---| +| Success | `--md-success` (`#8fdc9b`) | confirmation, "OK", restored states | +| Warning | `--md-warning` (`#ffc94a`) | non-critical alerts, slow network, "may take a while" | +| Info | `--md-info` (`#7dd3fc`) | informational hints, "did you know" | +| Error | `--md-error` (`#ff9d94`) | soft errors — form validation, failed toast | +| Danger | `--md-danger` (`#ff8a80`) | alarm-level — active hazard, irreversible destructive | + +**Example — semantic chip:** +```html +Saved +Slow network +Recording +``` + +`prefers-contrast: more` shifts surface and outline tokens automatically — don't override per-component. + +### Motion + +| Duration | Value | Use for | +|---|---|---| +| `--motion-instant` | 80ms | state flicks (toggle on/off colour) | +| `--motion-quick` | 140ms | hover, small togglers, taps | +| `--motion-base` | 180ms | default for most things | +| `--motion-medium` | 240ms | FAB menus, sheets entering | +| `--motion-long` | 380ms | page transitions, large slides | + +| Easing | Use for | +|---|---| +| `--ease-standard` | symmetric in/out (default) | +| `--ease-emphasized` | enter / open / expand (decelerates onto place) | +| `--ease-emphasized-accelerate` | exit / close / dismiss (accelerates away) | +| `--ease-linear` | crossfades, progress bars only | + +**Pattern — asymmetric open/close (preferred):** +```css +.menu { transition: opacity var(--motion-quick) var(--ease-emphasized-accelerate); } +.menu.is-open { transition: opacity var(--motion-medium) var(--ease-emphasized); } +``` + +For a worked example see the Setting FAB menu in [css/pages/settings/base.css](web/css/pages/settings/base.css) (`.setting-fab-actions`). + +### State layer (Material 3) + +```css +--state-hover: 0.08; +--state-focus: 0.12; +--state-pressed: 0.12; +--state-dragged: 0.16; +``` + +**Pattern A — use the `.state-layer` helper:** position any interactive element relative, add the class, and it overlays a primary-tinted layer that intensifies on hover/focus/press. +```html + +``` + +**Pattern B — manual color-mix** (when you need control over which color tints): +```css +.row:hover { + background: color-mix(in srgb, var(--md-primary) var(--state-hover-pct), transparent); +} +``` + +### Elevation + +5 levels, dark-tuned, picked smallest-first. + +| Token | Use for | +|---|---| +| `--shadow-1` | hovered/lifted controls | +| `--shadow-2` | default cards, FAB | +| `--shadow-3` | popovers, dropdowns | +| `--shadow-4` | dialogs, sheets | +| `--shadow-5` | fullscreen modals, video player, pickers | + +For brand-tinted elevation (orange FAB) compose with the base shadow rather than re-encoding a coloured shadow inline: +```css +box-shadow: var(--shadow-3), 0 0 0 1px var(--md-primary); +``` + +### Z-index scale + +Use these tokens for cross-component layering. Local stacking inside one component (1/2/3) can stay as raw numbers. + +| Token | Value | Use for | +|---|---|---| +| `--z-base` | 1 | in-flow content | +| `--z-sticky` | 50 | sticky headers, subnav | +| `--z-rail` | 100 | side nav rail (landscape) | +| `--z-nav` | 120 | bottom nav bar | +| `--z-fab` | 130 | FAB / FAB menus | +| `--z-popover` | 150 | dropdowns, tooltips | +| `--z-modal` | 170 | dialogs, sheets, pickers | +| `--z-toast` | 200 | transient toast layer | +| `--z-overlay` | 220 | fullscreen overlays | + +### Focus & reduced motion (global) + +[base.css](web/css/base.css) sets: +- One global `:focus-visible` ring using `--focus-ring-*` tokens — covers every interactive element. Override only when shape requires it. +- `@media (prefers-reduced-motion: reduce)` collapses every animation/transition to 0.001ms so things still *snap* into state without movement. + +Both rules are intentionally broad. Don't recreate them per component. + +### Shared primitives ([components.css](web/css/components.css)) + +These exist so pages don't reinvent the same chip / icon button / loading +skeleton / empty state over and over. Compose them before writing new CSS. + +| Class | Variants | Use for | +|---|---|---| +| `.btn` | `--filled`, `--danger`, `.smallBtn` | text buttons | +| `.icon-btn` | `--circle`, `--ghost`, `--sm`, `--lg` | icon-only buttons (36×36 default) | +| `.chip` | `--accent`, `--danger`, `--success`, `--warning`, `--info` | status tags, counts, labels | +| `.skeleton` | `--circle` | loading placeholders (shimmer) | +| `.empty-state` | `__title`, `__message`, `__action` | "no items" cards in lists | +| `.state-layer` | — | M3 state overlay on any interactive surface | +| `.ui-stagger-item` | (uses CSS var `--i`) | sequenced list reveal animation | +| `.ui-dropdown-menu` | `__button`, `__panel`, `__item`, `--primary`, `--danger` | dropdown menus | +| `.ui-action-grid` | `--quick` | button grids (Tools quick actions) | +| `.app-dialog` | `__sheet`, `__title`, `__body`, `__actions` | dialogs (use the JS API instead) | +| `.app-toast` | `is-error`, `is-success`, `is-hint` | toasts (use `showAppToast` instead) | +| `.visually-hidden` | — | screen-reader-only text | + +#### Worked examples + +**Icon-only button** — picks up the global focus ring automatically: +```html + +``` + +**Status chip:** +```html +3 segments +Connected +Recording +``` + +**Loading skeleton** — animates a shimmer; respects reduced motion: +```html +
+
+``` + +**Empty state:** +```html +
+
No items
+
Try changing filters.
+ +
+``` + +**Staggered list reveal** — the animation runs once on append. Set `--i` +per item; the delay caps at 420 ms so long lists don't drag: +```js +items.forEach((el, i) => el.style.setProperty('--i', i)); +items.forEach((el) => el.classList.add('ui-stagger-item')); +``` + +**Accessible icon-only close (with hidden label):** +```html + +``` + +### Shared keyframes + +Named animations available via `animation: …`: + +| Keyframe | Where | Used by | +|---|---|---| +| `uiStaggerIn` | components.css | `.ui-stagger-item` — slide-up + fade-in | +| `skeleton-shimmer` | components.css | `.skeleton::after` — horizontal sweep | + +Per-feature animations (e.g. `dashcam-segment-append`, `tools-detail-open`, +`record-blink`) live in the relevant page CSS and use a +`-` name. Promote one to a shared keyframe only when a +new primitive will use it. + +### Naming conventions + +- **BEM-ish** is the working style. + - Block: `.app-dialog`, `.dashcam-route-card`. + - Element: `.app-dialog__title`, `.dashcam-route-card__head`. + - Modifier: `.btn--filled`, `.chip--success`, `.icon-btn--circle`. +- **State classes** (toggled at runtime): `.is-open`, `.is-active`, + `.is-loading`, `.is-error`, `.is-collapsed`, `.is-visible`. Always + `is-` prefixed. +- **Behavior vs. style separation:** + - `[data-action="play"]` → wired in JS via event delegation. + - `.is-active` → read by CSS only. + - Don't put `[data-action="…"]` in CSS selectors. +- **JS globals.** Top-level `let`/`const` are visible across every file + in load order (no modules). For state, prefer namespaced names — + `dashcamState`, `screenrecordState`, `settingFabMenuOpen`. Grep + before claiming a new name; collisions are real. +- **Translations.** `getUIText("key", "English fallback", { count })`. + Always provide the English fallback inline — it's the source string. + When adding a new key, update all five locale files in + [`web/js/translations/`](web/js/translations/). + +### JS UI utilities + +`shared/ui/` — call these instead of building your own modal/toast: + +| Function | Source | Use for | +|---|---|---| +| `appAlert`, `appConfirm`, `appPrompt`, `openAppDialog` | [dialog.js](web/js/shared/ui/dialog.js) | All modal text dialogs and choice sheets | +| `showAppToast(message, { tone, duration })` | dialog.js | Transient feedback. Tones: `default`, `error`, `success`, `hint` | +| `syncModalBodyLock` | dialog.js | Call after manually showing/hiding a sheet to lock body scroll | +| `createFocusTrap(container, opts)` | [focus_trap.js](web/js/shared/ui/focus_trap.js) | Required for any new modal/overlay (a11y) | +| `showPage`, page transition helpers | [navigation.js](web/js/shared/ui/navigation.js) | Page-level navigation | +| Viewport metrics, `--app-vv-height` | [viewport.js](web/js/shared/ui/viewport.js) | Responsive math against the real viewport | +| `escapeHtml`, `clamp`, `copyToClipboard` | [utils.js](web/js/shared/utils.js) | Always escape interpolated text in template literals | +| `getJson`, `postJson`, `bulkGet`, `setParam` | [api.js](web/js/shared/api.js) | Backend access (don't use raw `fetch`) | + +**Modal pattern with focus trap** — required for any new dialog/overlay +to remain keyboard-accessible: +```js +const overlay = document.createElement("div"); +overlay.className = "my-overlay"; +overlay.setAttribute("role", "dialog"); +overlay.setAttribute("aria-modal", "true"); +overlay.innerHTML = `
`; +document.body.appendChild(overlay); + +const trap = createFocusTrap(overlay, { + initialFocus: ".my-overlay__primary", // selector or element + escape: () => close(), // optional Esc handler +}); +trap.activate(); + +function close() { + trap.deactivate(); // restores focus to whoever had it before open + overlay.remove(); + syncModalBodyLock(); +} +``` + +Page-change broadcasting: +```js +window.addEventListener("carrot:pagechange", (ev) => { /* ev.detail.page */ }); +``` + +Language-change broadcasting (re-render translated strings): +```js +window.addEventListener("carrot:languagechange", () => { /* re-render */ }); +``` + +### Page lifecycle + +- The current page is on `body[data-page="…"]`. Listen for + `carrot:pagechange` to **clean up everything you started**: timers, + observers, WebSockets, scroll listeners. Mirror the + [`handleLogsPageChange`](web/js/pages/logs/shared.js) pattern. +- For lists with more than ~30 items: build a virtual window with + top/bottom spacers, not a flat render. The canonical pattern is in + [`logs/dashcam.js`](web/js/pages/logs/dashcam.js) — `dashcamWindowFor` + computes the visible slice and `patchDashcamWindow` patches the DOM. +- For "load more" sentinels at list ends: use `IntersectionObserver` + with the scroll container as `root`. See + `ensureDashcamSegmentLoaderObserver` in + [`logs/dashcam.js`](web/js/pages/logs/dashcam.js). **Never poll + `getBoundingClientRect()` from a scroll handler** — it forces a + layout per frame and kills scroll smoothness. +- For lazy images: reuse `hydrateLogsLazyImages` / + `loadLogsLazyImage` in [`logs/shared.js`](web/js/pages/logs/shared.js). + +### Accessibility checklist for new UI + +1. Every interactive element is focusable and reachable by keyboard (Tab / Shift+Tab). +2. Icon-only buttons have an `aria-label`. +3. Dialogs use `role="dialog" aria-modal="true"` and a `.app-dialog__title` for the accessible name. +4. The global `:focus-visible` ring is visible — don't `outline: none` without a replacement. +5. Color is never the *only* signal (status chips include a label, error rows have an icon). +6. Animations respect the global `prefers-reduced-motion` guard — don't bypass it. +7. Tap targets are ≥ 44×44px (Material 3 / WCAG). + +### Anti-patterns (common mistakes) + +| Don't | Do | Why | +|---|---|---| +| `transition: opacity 0.2s ease;` | `transition: opacity var(--motion-base) var(--ease-standard);` | Consistent motion + reduced-motion guard hooks in | +| `z-index: 170;` | `z-index: var(--z-modal);` | Magic numbers drift; tokens describe intent | +| `color: #8fdc9b;` | `color: var(--md-success);` | Hardcoded greens fragment the palette | +| `box-shadow: 0 18px 40px rgba(0,0,0,.34);` | `box-shadow: var(--shadow-4);` | Same elevation everywhere = clear hierarchy | +| `confirm("…")`, `alert("…")` | `await appConfirm("…")`, `showAppToast("…")` | Native dialogs ignore theming and block the page | +| `outline: none;` (then no replacement) | leave the global ring, or replace with an equivalent | Keyboard users lose all feedback | +| `` (no label) | add `aria-label="…"` or a `.visually-hidden` text | Screen readers can't announce the button | +| `addEventListener("touchmove", h)` | `addEventListener("touchmove", h, { passive: true })` | Non-passive `touchmove` blocks the compositor — every scroll jumps | +| `setInterval(check, 16)` polling rects | `IntersectionObserver` on the sentinel | rAF-rate rect polling kills scrolling on mobile | +| `\`
${userText}
\`` | `\`
${escapeHtml(userText)}
\`` | Template literals are interpolated raw; XSS risk | +| `fetch("/api/...")` | `getJson("/api/...")` / `postJson(...)` | Consistent error handling, JSON parsing, auth headers | + +### Failure modes specific to this codebase + +- **Scroll jank from non-passive listeners.** Any `touchmove` listener + that isn't `passive: true` makes the entire scroll path go through + the main thread. Even if you never call `preventDefault()`, the + browser must wait for JS to decide. The dashcam segment list had this + bug and removing the guard fixed it. +- **`[hidden]` kills transitions.** `[hidden] { display: none }` removes + the element from layout — there's nothing for a transition to animate + from/to. To animate close, remove `is-open` first to start the + transition, then set `hidden` after the duration completes (use + `--motion-medium` as the timer). +- **`replaceChildren` resets nested `scrollTop`.** When a virtual list + re-renders, child scroll containers lose their position. Capture + before replacing, restore after — see + `rememberVisibleDashcamSegmentScrolls` / + `restoreVisibleDashcamSegmentScrolls` in + [`logs/dashcam.js`](web/js/pages/logs/dashcam.js). +- **Asymmetric container padding.** Padding cascades to every child. + If a list looks lopsided, check the parent's `padding` first. The + fix in dashcam was `padding: 12px 28px 12px 12px` → `padding: 12px 14px`. +- **Hardcoded scrollbar gutter.** On touch, scrollbars overlay and + don't take space — `padding-right: 2px` to "make room" creates + visible asymmetry on devices that show the scrollbar. Either + `scrollbar-width: none` (hide on touch) or symmetric + `padding-inline`. +- **Loader height change.** A loader that grows from 16 px to 22 px on + state change shoves the list above it. Keep the loader at a fixed + height and fade in the indicator with opacity instead. + +### When adding a new page + +1. Add `` for `css/pages/.css` in load order (after `components.css`). +2. Add ` + diff --git a/selfdrive/carrot/web/js/shared/ui/focus_trap.js b/selfdrive/carrot/web/js/shared/ui/focus_trap.js new file mode 100644 index 0000000000..5eb23bfdbd --- /dev/null +++ b/selfdrive/carrot/web/js/shared/ui/focus_trap.js @@ -0,0 +1,144 @@ +"use strict"; + +// Focus trap for modal-like surfaces (dialogs, sheets, full-screen overlays). +// +// Why: +// When a modal opens, Tab/Shift+Tab should cycle inside it. Otherwise +// keyboard users can tab "behind" the modal into the obscured page, +// which is broken UX and a WCAG 2.1 violation (2.4.3, 2.4.11). +// +// Use: +// const trap = createFocusTrap(overlayEl, { initialFocus: btn }); +// trap.activate(); // call when overlay becomes visible +// trap.deactivate(); // call when overlay closes (restores prior focus) +// +// `initialFocus` (optional): element or selector to focus first. +// Defaults to the first focusable element inside the container. +// `returnFocus` (optional): override where focus goes on deactivate. +// Defaults to whichever element had focus when activate() was called. +// `escape` (optional): callback fired when Esc is pressed. +// If provided, Esc is intercepted and passed to this handler. +// If omitted, Esc is left for the host element to handle. +// +// Pairs with .app-dialog and similar surfaces. dialog.js can adopt it +// by calling createFocusTrap when opening a dialog and deactivate() on +// resolve. New overlays should use this rather than re-implementing. + +const FOCUSABLE_SELECTOR = [ + 'a[href]', + 'area[href]', + 'button:not([disabled])', + 'input:not([disabled]):not([type="hidden"])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])', + '[contenteditable]:not([contenteditable="false"])', + 'audio[controls]', + 'video[controls]', + 'iframe', + 'object', + 'embed', + 'summary', +].join(","); + +function getFocusableElements(root) { + if (!root) return []; + return Array.from(root.querySelectorAll(FOCUSABLE_SELECTOR)) + .filter((el) => { + if (el.hasAttribute("inert")) return false; + if (el.getAttribute("aria-hidden") === "true") return false; + // Reject hidden elements (display:none or visibility:hidden, including ancestors). + const rect = el.getBoundingClientRect(); + if (rect.width === 0 && rect.height === 0) return false; + const style = window.getComputedStyle(el); + if (style.visibility === "hidden" || style.display === "none") return false; + return true; + }); +} + +function resolveInitial(container, value) { + if (!value) return null; + if (typeof value === "string") return container.querySelector(value); + if (value instanceof Element) return value; + return null; +} + +function createFocusTrap(container, options = {}) { + if (!container) return { activate() {}, deactivate() {} }; + let active = false; + let restoreTo = null; + let keydownHandler = null; + + function onKeydown(ev) { + if (!active) return; + if (ev.key === "Escape" && typeof options.escape === "function") { + options.escape(ev); + return; + } + if (ev.key !== "Tab") return; + + const focusable = getFocusableElements(container); + if (!focusable.length) { + // Nothing focusable inside; keep focus on the container itself. + ev.preventDefault(); + container.focus?.(); + return; + } + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + const activeEl = document.activeElement; + + if (ev.shiftKey) { + if (activeEl === first || !container.contains(activeEl)) { + ev.preventDefault(); + last.focus(); + } + } else { + if (activeEl === last || !container.contains(activeEl)) { + ev.preventDefault(); + first.focus(); + } + } + } + + return { + activate() { + if (active) return; + active = true; + restoreTo = options.returnFocus || document.activeElement; + // Make the container focusable as a fallback target. + if (!container.hasAttribute("tabindex")) container.setAttribute("tabindex", "-1"); + + keydownHandler = onKeydown; + document.addEventListener("keydown", keydownHandler, true); + + const initial = resolveInitial(container, options.initialFocus) + || getFocusableElements(container)[0] + || container; + // Defer to next frame so the container's open transition can start + // before focus moves (some screen readers misannounce otherwise). + requestAnimationFrame(() => { + if (!active) return; + initial?.focus?.({ preventScroll: true }); + }); + }, + + deactivate() { + if (!active) return; + active = false; + if (keydownHandler) { + document.removeEventListener("keydown", keydownHandler, true); + keydownHandler = null; + } + const target = restoreTo; + restoreTo = null; + if (target && typeof target.focus === "function" && document.contains(target)) { + target.focus({ preventScroll: true }); + } + }, + + isActive() { + return active; + }, + }; +} From 120d6c279a9a1f4a19cc5292535f023e14df0e69 Mon Sep 17 00:00:00 2001 From: jominki354 Date: Mon, 11 May 2026 14:22:38 +0900 Subject: [PATCH 23/23] locale fix --- selfdrive/carrot/server/features/static.py | 18 ++++++++++++++++++ selfdrive/carrot/web/js/pages/tools.js | 18 +++++++++++++++--- selfdrive/carrot/web/js/shared/i18n.js | 17 +++++++++++------ 3 files changed, 44 insertions(+), 9 deletions(-) diff --git a/selfdrive/carrot/server/features/static.py b/selfdrive/carrot/server/features/static.py index ed8c410b1b..3944ad5729 100644 --- a/selfdrive/carrot/server/features/static.py +++ b/selfdrive/carrot/server/features/static.py @@ -8,6 +8,23 @@ from ..services.web_settings import read_web_settings +_LANGUAGES_JSON_PATH = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))), + "ui", "translations", "languages.json", +) + + +def _load_device_languages() -> list: + """Read selfdrive/ui/translations/languages.json and return + a list of ``{code, name}`` dicts that the web client can use directly.""" + try: + with open(_LANGUAGES_JSON_PATH, "r", encoding="utf-8") as f: + mapping = json.load(f) # e.g. {"English": "main_en", ...} + return [{"code": code, "name": name} for name, code in mapping.items()] + except Exception: + return [] + + def _build_bootstrap_payload() -> dict: try: device_values = get_param_values(["LanguageSetting"], {"LanguageSetting": ""}) @@ -17,6 +34,7 @@ def _build_bootstrap_payload() -> dict: return { "webSettings": read_web_settings(), "deviceLanguage": device_language, + "deviceLanguages": _load_device_languages(), } diff --git a/selfdrive/carrot/web/js/pages/tools.js b/selfdrive/carrot/web/js/pages/tools.js index 3c467d2cda..1eec421e0f 100644 --- a/selfdrive/carrot/web/js/pages/tools.js +++ b/selfdrive/carrot/web/js/pages/tools.js @@ -753,10 +753,22 @@ async function syncDeviceLanguageOnce() { const values = await bulkGet(["LanguageSetting"]); const currentLang = String(values["LanguageSetting"] || "").trim(); + // Map browser language → web language code (ko/en/zh only) const browserLang = (navigator.language || navigator.userLanguage || "en").toLowerCase(); - let targetParam = "main_en"; - if (browserLang.startsWith("ko")) targetParam = "main_ko"; - else if (browserLang.startsWith("zh")) targetParam = browserLang.includes("tw") || browserLang.includes("hk") ? "main_zh-CHT" : "main_zh-CHS"; + const webLang = normalizeLangCode(browserLang); // returns "ko" | "en" | "zh" | "" + + // Map web language → device language code + const WEB_TO_DEVICE = { ko: "main_ko", en: "main_en", zh: "main_zh-CHS" }; + if (browserLang.startsWith("zh") && (browserLang.includes("tw") || browserLang.includes("hk"))) { + WEB_TO_DEVICE.zh = "main_zh-CHT"; + } + + // Only sync if browser language has BOTH a web pack AND a device translation + const deviceCodes = (window.CarrotDeviceLanguageOptions || []).map((o) => o.code); + let targetParam = "main_en"; // default fallback + if (webLang && WEB_TO_DEVICE[webLang] && deviceCodes.includes(WEB_TO_DEVICE[webLang])) { + targetParam = WEB_TO_DEVICE[webLang]; + } if (currentLang !== targetParam) { await setParam("LanguageSetting", targetParam); diff --git a/selfdrive/carrot/web/js/shared/i18n.js b/selfdrive/carrot/web/js/shared/i18n.js index 9359b049bf..8b6e7fe194 100644 --- a/selfdrive/carrot/web/js/shared/i18n.js +++ b/selfdrive/carrot/web/js/shared/i18n.js @@ -9,12 +9,17 @@ const DRIVE_MODES = TRANSLATION_REGISTRY.driveModes || {}; const CARROT_WEB_LANGUAGE_CODES = Object.freeze(["en", "ko", "zh"]); window.CARROT_WEB_LANGUAGE_CODES = CARROT_WEB_LANGUAGE_CODES; -window.CarrotDeviceLanguageOptions = Object.freeze([ - { code: "main_en", name: "English" }, - { code: "main_ko", name: "한국어" }, - { code: "main_zh-CHS", name: "简体中文" }, - { code: "main_zh-CHT", name: "繁體中文" }, -]); +// Device language options — loaded from the server bootstrap (languages.json). +// Falls back to a minimal set if the server data is unavailable. +window.CarrotDeviceLanguageOptions = Object.freeze( + (Array.isArray(window.__CARROT_BOOTSTRAP__?.deviceLanguages) && + window.__CARROT_BOOTSTRAP__.deviceLanguages.length > 0) + ? window.__CARROT_BOOTSTRAP__.deviceLanguages + : [ + { code: "main_en", name: "English" }, + { code: "main_ko", name: "한국어" }, + ] +); let LANG = "en";