diff --git a/webapp_gallery/app.js b/webapp_gallery/app.js index 869e862..6c75c16 100644 --- a/webapp_gallery/app.js +++ b/webapp_gallery/app.js @@ -1,52 +1,42 @@ "use strict"; - -const $ = id => document.getElementById(id); -const elements = { - pages: $("pages"), pageTemplate: $("page-template"), cardTemplate: $("card-template"), - connection: $("connection-status"), filters: $("category-filter"), modeTabs: $("mode-tabs"), - streamToggle: $("toggle-streams"), pageCount: $("page-count"), caseCount: $("case-count"), - canvasCount: $("canvas-count"), apiCount: $("api-count"), modeDescription: $("mode-description"), - menu: $("context-menu"), menuTitle: $("menu-title"), menuComponent: $("menu-component"), - menuDescription: $("menu-description"), menuBody: $("menu-body"), menuStatus: $("menu-status"), - menuClose: $("menu-close"), menuTabs: [...document.querySelectorAll(".menu-tabs button")], toast: $("toast") -}; - const query = new URLSearchParams(location.search); const hosted = location.protocol !== "file:" && ["/", "/index.html", "/gallery", "/gallery/"].includes(location.pathname); const jetBrainsPreview = location.port === "63342"; const socketPort = query.get("port") || (hosted && !jetBrainsPreview ? location.port : "8848") || "8848"; const socketHost = query.get("host") || (hosted ? location.hostname : "127.0.0.1") || "127.0.0.1"; const socketUrl = `${location.protocol === "https:" ? "wss" : "ws"}://${socketHost}:${socketPort}/renderive/gallery`; - -const pages = new Map(); +const amisEmbed = window.amisRequire("amis/embed"); +const amisLib = window.amisRequire("amis"); +const React = window.amisRequire("react"); +const sessions = new Map(); +const rootNode = document.getElementById("root"); +const menuHost = document.getElementById("menu-host"); +let catalog = null; let definitions = []; let modes = []; -let activeMode = "low_latency"; -let activeCategory = "全部"; -let activeCard = null; -let activeTab = "controls"; +let mainScoped = null; +let menuScoped = null; +let menuSession = null; +let selectedCategory = "全部"; let streamsPaused = false; -let toastTimer = 0; +let connectionState = "connecting"; +let connectionText = "读取后端目录"; +let noticeText = ""; +let noticeError = false; +let noticeTimer = 0; +let menuStatus = "字段、验证与 API 映射来自后端 JSON"; let lastAnimationFrameAt = 0; let displayIntervalMs = 0; let displayIntervalLatestMs = 0; let displayIntervalP95Ms = 0; let displayJitterMs = 0; +let lastUiUpdateAt = 0; const displayIntervalSamples = []; - const message = (type, payload = {}) => JSON.stringify({category: "event", type, ...payload}); -const setConnection = (state, text) => { - elements.connection.dataset.state = state; - elements.connection.querySelector("span").textContent = text; -}; -function toast(text, error = false) { - clearTimeout(toastTimer); - elements.toast.textContent = text; - elements.toast.dataset.error = String(error); - elements.toast.hidden = false; - toastTimer = setTimeout(() => { elements.toast.hidden = true; }, 2600); -} -function grouped(items) { +const quote = value => JSON.stringify(String(value)); +const escapeHtml = value => String(value ?? "").replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'"); +const expression = path => `\${${path}}`; +const groupItems = items => { const groups = new Map(); for (const item of items || []) { const group = item.group || "其他"; @@ -54,19 +44,37 @@ function grouped(items) { groups.get(group).push(item); } return groups; -} -function flatten(value, prefix = "", result = []) { - if (value !== null && typeof value === "object" && !Array.isArray(value)) { - for (const [key, child] of Object.entries(value)) flatten(child, prefix ? `${prefix}.${key}` : key, result); - } else result.push([prefix, typeof value === "object" ? JSON.stringify(value) : String(value)]); - return result; -} +}; function formatNanoseconds(value) { const nanoseconds = Math.max(0, Number(value) || 0); if (nanoseconds < 1_000) return `${Math.round(nanoseconds)} ns`; if (nanoseconds < 1_000_000) return `${(nanoseconds / 1_000).toFixed(2)} µs`; return `${(nanoseconds / 1_000_000).toFixed(3)} ms`; } +function formatInteger(value) { + return Number(value || 0).toLocaleString(); +} +function formatFps(value, digits = 1) { + return Number(value || 0).toFixed(digits); +} +function normalizeEventValue(payload, name) { + if (payload !== null && typeof payload === "object") { + if (Object.prototype.hasOwnProperty.call(payload, "value")) return payload.value; + if (Object.prototype.hasOwnProperty.call(payload, name)) return payload[name]; + if (payload.data !== null && typeof payload.data === "object") return normalizeEventValue(payload.data, name); + } + return payload; +} +function notify(text, error = false) { + clearTimeout(noticeTimer); + noticeText = String(text || ""); + noticeError = Boolean(error); + updateMainData(); + noticeTimer = setTimeout(() => { + noticeText = ""; + updateMainData(); + }, 2600); +} function updateDisplayTiming(time) { if (lastAnimationFrameAt > 0) { displayIntervalLatestMs = Math.max(0, time - lastAnimationFrameAt); @@ -86,14 +94,74 @@ function updateDisplayTiming(time) { displayIntervalP95Ms = percentile(0.95); displayJitterMs = Math.max(0, displayIntervalP95Ms - displayIntervalMs); } - -class GalleryCard { - constructor(definition, mode) { +const observerMetricDefinitions = [ + ["configured_frequency_hz", "配置频率"], ["observation_count", "观察次数"], ["latest_sequence", "最新序号"], ["produced_frame_count", "发布"], + ["consumed_frame_count", "完成"], ["pending_frame_count", "待处理"], ["dropped_frame_count", "丢弃"], ["failed_operation_count", "失败"], + ["target_interval_ns", "目标间隔"], ["paint_duration_ns", "PaintEvent"], ["render_duration_ns", "后台渲染"], ["bottleneck_duration_ns", "内部瓶颈"], + ["consumer_feedback_master_enabled", "消费者总开关"], ["consumer_feedback_enabled", "Kernel 反馈有效"], ["consumer_pixel_feedback_enabled", "像素响应反馈"], ["consumer_presentation_feedback_enabled", "浏览器呈现反馈"], + ["consumer_manual_feedback_enabled", "手动消费者反馈"], ["consumer_manual_fps", "手动消费者 FPS"], ["consumer_feedback_source", "生效反馈来源"], ["consumer_pixel_interval_ns", "像素响应周期"], + ["consumer_presentation_interval_ns", "浏览器呈现周期"], ["consumer_manual_interval_ns", "手动消费者周期"], ["consumer_sample_interval_ns", "消费者原始采样"], ["consumer_smoothed_interval_ns", "消费者平滑周期"], + ["consumer_variation_ns", "消费者抖动"], ["consumer_safety_interval_ns", "消费者安全期限"], ["consumer_interval_ns", "消费者限速周期"], ["consumer_effective_fps", "消费者限速 FPS"], + ["next_refresh_interval_ns", "下次刷新"], ["paint_lease_wait_ns", "Paint lease 等待"], ["paint_state_wait_ns", "Paint state 等待"], ["publish_state_wait_ns", "Publish state 等待"], + ["ready_wait_ns", "Ready 等待"], ["frame_age_at_render_ns", "渲染时帧龄"], ["render_lease_wait_ns", "Render lease 等待"], ["render_state_wait_ns", "Render state 等待"], + ["render_finish_state_wait_ns", "Render finish 等待"], ["queue_wait_ns", "Queue 等待"], ["end_to_end_ns", "端到端延迟"], ["last_event", "观察事件"] +]; +const clientMetricDefinitions = [ + ["display_interval_latest_ms", "RAF 最新周期"], ["display_interval_ms", "RAF 中位周期"], ["display_interval_p95_ms", "RAF P95 周期"], ["display_jitter_ms", "RAF P95-P50 抖动"], + ["frame_round_trip_ms", "WS 往返"], ["transport_fps", "像素响应 FPS"], ["presentation_fps", "浏览器呈现 FPS"], ["websocket_buffered_bytes", "WS 缓冲"], + ["overwritten_pixel_frames", "未呈现覆盖"], ["changed_pixel_frames", "变化像素帧"], ["duplicate_pixel_frames", "重复像素帧"], ["frame_request_timeout_count", "像素请求超时"], + ["last_pixel_receive_age_ms", "最近像素龄"], ["last_pixel_change_age_ms", "最近变化龄"] +]; +const mainMetricDefinitions = [ + ["backendFps", "后端渲染 FPS"], ["transportFps", "像素响应 FPS"], ["presentationFps", "浏览器呈现 FPS"], ["roundTrip", "WS 往返 ms"], ["overwritten", "未呈现覆盖"], + ["renderMs", "Core 渲染 ms"], ["encodeMs", "像素编码 ms"], ["bandwidth", "响应负载 MB/s"], ["pending", "Kernel 待处理"], ["dropped", "Kernel 丢弃"], + ["points", "输入→绘制"], ["limit", "当前瓶颈"], ["event", "观察事件"], ["timeouts", "像素超时"], ["pixelAge", "最近像素龄"] +]; +const limitDefinitions = [ + ["limitFrequency", "Kernel 用户频率"], ["limitPaint", "Kernel PaintEvent"], ["limitRender", "Kernel 后台渲染"], ["limitConsumer", "消费者反馈"] +]; +const nanosecondFields = new Set([ + "paint_duration_ns", "render_duration_ns", "target_interval_ns", "bottleneck_duration_ns", "consumer_pixel_interval_ns", "consumer_presentation_interval_ns", + "consumer_manual_interval_ns", "consumer_sample_interval_ns", "consumer_smoothed_interval_ns", "consumer_variation_ns", "consumer_safety_interval_ns", "consumer_interval_ns", + "next_refresh_interval_ns", "paint_lease_wait_ns", "paint_state_wait_ns", "publish_state_wait_ns", "ready_wait_ns", "frame_age_at_render_ns", "render_lease_wait_ns", + "render_state_wait_ns", "render_finish_state_wait_ns", "queue_wait_ns", "end_to_end_ns" +]); +const booleanObserverFields = new Set([ + "consumer_feedback_master_enabled", "consumer_feedback_enabled", "consumer_pixel_feedback_enabled", "consumer_presentation_feedback_enabled", "consumer_manual_feedback_enabled" +]); +function observerValue(name, observer) { + const raw = observer[name] ?? (name === "last_event" || name === "consumer_feedback_source" ? "none" : 0); + if (name === "consumer_effective_fps") { + const interval = Number(observer.consumer_interval_ns || 0); + return interval > 0 ? `${(1e9 / interval).toFixed(2)} FPS` : "0 FPS"; + } + if (name === "consumer_manual_fps") return `${Number(raw || 0).toFixed(2)} FPS`; + if (name === "consumer_feedback_source") { + const names = {disabled: "总开关关闭", none: "无", pixel: "像素响应", presentation: "浏览器呈现", manual: "手动"}; + return String(raw).split("+").map(value => names[value] || value).join(" + "); + } + if (booleanObserverFields.has(name)) return raw ? "启用" : "关闭"; + if (nanosecondFields.has(name)) return formatNanoseconds(raw); + if (name === "configured_frequency_hz") return observer.frequency_limit_enabled === false ? "已关闭" : `${Number(raw || 0).toLocaleString()} Hz`; + return typeof raw === "number" ? raw.toLocaleString() : String(raw); +} +function clientValue(name, client) { + const raw = Number(client[name] || 0); + if (name.endsWith("_fps")) return `${raw.toFixed(2)} FPS`; + if (name.endsWith("_ms")) return `${raw.toFixed(3)} ms`; + if (name === "websocket_buffered_bytes") return `${raw.toLocaleString()} B`; + return raw.toLocaleString(); +} +class GallerySession { + constructor(key, definition, mode) { + this.key = key; this.definition = definition; this.mode = mode; this.controls = []; this.actions = []; this.telemetry = {}; + this.controlValues = new Map(); + this.lastSubmittedControls = new Map(); this.frameCount = 0; this.framePending = false; this.frameTimeout = null; @@ -109,6 +177,9 @@ class GalleryCard { this.lastPixelReceivedAt = 0; this.lastPixelChangeAt = 0; this.motionState = "waiting"; + this.motionText = "等待动态帧"; + this.socketState = "connecting"; + this.socketText = "等待可见"; this.reconnectTimer = null; this.disposed = false; this.ready = false; @@ -118,54 +189,72 @@ class GalleryCard { this.frameRoundTripMs = 0; this.overwrittenPixelFrames = 0; this.lastObserveRequest = 0; - this.node = elements.cardTemplate.content.firstElementChild.cloneNode(true); - this.node.dataset.category = definition.category; - this.node.dataset.mode = mode.id; - this.canvas = this.node.querySelector("canvas"); - this.shell = this.node.querySelector(".canvas-shell"); - this.context = this.canvas.getContext("2d", {alpha: false}); - this.socketState = this.node.querySelector(".card-socket"); - this.motionStatus = this.node.querySelector(".motion-status"); - this.observerFields = new Map([...this.node.querySelectorAll("[data-observer-field]")] - .map(field => [field.dataset.observerField, field])); - this.frameLabel = this.node.querySelector(".card-frames"); - this.node.querySelector(".card-category").textContent = definition.category; - this.node.querySelector(".card-title").textContent = definition.title; - this.node.querySelector(".card-description").textContent = definition.description; - this.node.querySelector(".card-component").textContent = definition.component; - this.node.querySelector(".card-controls").textContent = definition.control_count_by_mode?.[mode.id] ?? "—"; - this.node.querySelector(".card-actions").textContent = definition.action_count_by_mode?.[mode.id] ?? "—"; - const frameButton = this.node.querySelector(".frame-button"); - frameButton.textContent = mode.id === "manual" ? "手动刷新一帧" : mode.id === "playback" ? "消费下一帧" : "立即刷新"; - frameButton.addEventListener("click", () => this.requestFrame(performance.now(), true)); - this.node.querySelector(".open-menu").addEventListener("click", event => { - const rect = event.currentTarget.getBoundingClientRect(); - openMenu(this, rect.right, rect.bottom); - }); - this.node.addEventListener("contextmenu", event => { + this.shell = null; + this.canvas = null; + this.context = null; + this.eventAbort = null; + this.resizeObserver = null; + this.intersectionObserver = null; + this.lastResizeWidth = 0; + this.lastResizeHeight = 0; + } + attach(shell, canvas) { + this.shell = shell; + this.canvas = canvas; + this.context = canvas.getContext("2d", {alpha: false}); + this.eventAbort = new AbortController(); + const options = {signal: this.eventAbort.signal}; + const pointer = (type, event) => this.send(type, {...this.position(event), button: ["left", "middle", "right"][event.button] || "none", buttons: event.buttons, modifiers: this.modifiers(event)}); + canvas.addEventListener("pointermove", event => pointer("pointer_move", event), options); + canvas.addEventListener("pointerdown", event => { + if (event.button === 2) return; + shell.focus(); + canvas.setPointerCapture(event.pointerId); + pointer("pointer_press", event); + }, options); + canvas.addEventListener("pointerup", event => { + if (event.button !== 2) pointer("pointer_release", event); + }, options); + canvas.addEventListener("pointerleave", () => this.send("leave"), options); + canvas.addEventListener("contextmenu", event => { event.preventDefault(); - openMenu(this, event.clientX, event.clientY); - }); - this.installCanvasEvents(); + window.RenderiveGallery.openMenu(this.key); + }, options); + canvas.addEventListener("wheel", event => { + event.preventDefault(); + this.send("wheel", {...this.position(event), pixelDeltaX: event.deltaX, pixelDeltaY: event.deltaY, angleDeltaX: -event.deltaX * 8, angleDeltaY: -event.deltaY * 8, buttons: event.buttons, modifiers: this.modifiers(event)}); + }, {passive: false, signal: this.eventAbort.signal}); + shell.addEventListener("keydown", event => this.send("key_press", {key: event.key, nativeKey: event.keyCode, repeat: event.repeat, modifiers: this.modifiers(event)}), options); + shell.addEventListener("keyup", event => this.send("key_release", {key: event.key, nativeKey: event.keyCode, repeat: false, modifiers: this.modifiers(event)}), options); this.resizeObserver = new ResizeObserver(() => this.resize()); - this.resizeObserver.observe(this.shell); + this.resizeObserver.observe(shell); this.intersectionObserver = new IntersectionObserver(entries => { const intersecting = entries[0]?.isIntersecting === true; if (this.intersecting === intersecting) return; this.intersecting = intersecting; this.syncActivity(); }, {rootMargin: "160px"}); - this.intersectionObserver.observe(this.node); + this.intersectionObserver.observe(shell); + this.syncActivity(true); } - setSocketState(state, text) { - this.socketState.dataset.state = state; - this.socketState.querySelector("span").textContent = text; - } - categoryVisible() { - return activeCategory === "全部" || this.definition.category === activeCategory; + detach() { + this.eventAbort?.abort(); + this.eventAbort = null; + this.resizeObserver?.disconnect(); + this.resizeObserver = null; + this.intersectionObserver?.disconnect(); + this.intersectionObserver = null; + this.intersecting = false; + this.lastResizeWidth = 0; + this.lastResizeHeight = 0; + this.shell = null; + this.canvas = null; + this.context = null; + if (this.ready && this.backendActive) this.send("hide"); + this.backendActive = false; } displayVisible() { - return !document.hidden && this.mode.id === activeMode && this.categoryVisible() && this.intersecting; + return !document.hidden && this.intersecting && this.shell !== null; } streamActive() { return !streamsPaused && this.displayVisible(); @@ -188,29 +277,38 @@ class GalleryCard { clearTimeout(this.reconnectTimer); const socket = new WebSocket(socketUrl); this.socket = socket; + this.socketState = "connecting"; + this.socketText = "连接中"; socket.binaryType = "arraybuffer"; socket.addEventListener("open", () => { if (this.socket !== socket) return; - this.setSocketState("ready", "WS 已连接"); + this.socketState = "ready"; + this.socketText = "WS 已连接"; this.send("gallery_open", {case: this.definition.id, frame_mode: this.mode.id}); this.resize(); }); socket.addEventListener("message", event => { - if (this.socket === socket) - typeof event.data === "string" ? this.receiveJson(event.data) : this.receivePixels(event.data); + if (this.socket !== socket) return; + if (typeof event.data === "string") this.receiveJson(event.data); + else this.receivePixels(event.data); }); socket.addEventListener("close", () => { if (this.socket !== socket) return; this.ready = false; + this.shell?.classList.remove("rv-canvas-ready"); this.backendActive = null; this.framePending = false; - clearTimeout(this.frameTimeout); this.frameTimeout = null; - this.setSocketState("error", this.displayVisible() ? "连接关闭 · 自动重连" : "连接关闭 · 等待可见"); + clearTimeout(this.frameTimeout); + this.frameTimeout = null; + this.socketState = "error"; + this.socketText = this.displayVisible() ? "连接关闭 · 自动重连" : "连接关闭 · 等待可见"; this.updateMotionStatus(); if (!this.disposed && this.displayVisible()) this.reconnectTimer = setTimeout(() => this.connect(), 1000); }); socket.addEventListener("error", () => { - if (this.socket === socket) this.setSocketState("error", "连接错误 · 自动重连"); + if (this.socket !== socket) return; + this.socketState = "error"; + this.socketText = "连接错误 · 自动重连"; }); } send(type, payload = {}) { @@ -218,130 +316,43 @@ class GalleryCard { } receiveJson(raw) { let data; - try { data = JSON.parse(raw); } catch { toast(`${this.definition.title} 返回无效 JSON`, true); return; } + try { + data = JSON.parse(raw); + } catch { + notify(`${this.definition.title} 返回无效 JSON`, true); + return; + } if (data.type === "error") { - const detail = Object.values(data.field_errors || {})[0] || data.message; - toast(detail || "后端拒绝操作", true); - if (activeCard === this) elements.menuStatus.textContent = detail; + const detail = Object.values(data.field_errors || {})[0] || data.message || "后端拒绝操作"; + this.lastSubmittedControls.clear(); + notify(detail, true); + if (menuSession === this) { + menuStatus = detail; + renderMenu(this, true); + } return; } if (data.type === "observer_state") { this.telemetry = data.telemetry || {}; - this.updatePerformance(); - if (activeCard === this && ["observer", "performance"].includes(activeTab)) renderMenuBody(); return; } if (data.type !== "case_state") return; this.controls = data.controls?.data || []; this.actions = data.actions?.data || []; this.telemetry = data.telemetry || {}; + this.controlValues.clear(); + for (const item of this.controls) this.controlValues.set(item.id, item.value); + this.lastSubmittedControls.clear(); this.ready = true; + this.shell?.classList.add("rv-canvas-ready"); this.backendActive = null; - this.node.dataset.ready = "true"; + this.socketState = "ready"; + this.socketText = `${data.frame_mode?.strategy || "Core2"} 在线`; this.syncActivity(); - this.node.querySelector(".card-controls").textContent = this.controls.length; - this.node.querySelector(".card-actions").textContent = this.actions.length; - this.setSocketState("ready", `${data.frame_mode?.strategy || "Core2"} 在线`); - this.updatePerformance(); - if (data.notice && !data.notice.includes("已创建")) toast(`${this.definition.title}:${data.notice}`); - if (activeCard === this) { elements.menuStatus.textContent = data.notice || "后端状态已回读"; renderMenuBody(); } - } - updatePerformance() { - const performanceData = this.telemetry.performance || {}; - const observer = this.telemetry.kernel_observer || {}; - const dataShape = this.telemetry.data_shape || {}; - const limit = this.telemetry.low_latency_limit || {}; - const limitNames = {frequency_limited: "Kernel 频率受限", paint_limited: "PaintEvent 受限", render_limited: "后台渲染受限", consumer_limited: "消费者反馈受限", unlimited: "无限制", not_applicable: "N/A"}; - this.node.querySelector(".perf-fps").textContent = Number(performanceData.measured_fps || 0).toFixed(1); - this.node.querySelector(".perf-transport-fps").textContent = Number(performanceData.pixel_response_fps || 0).toFixed(1); - this.node.querySelector(".perf-present-fps").textContent = Number(this.presentationFps || 0).toFixed(1); - this.node.querySelector(".perf-rtt").textContent = Number(this.frameRoundTripMs || 0).toFixed(2); - this.node.querySelector(".perf-overwritten").textContent = this.overwrittenPixelFrames.toLocaleString(); - this.node.querySelector(".perf-render").textContent = Number(performanceData.last_render_ms || 0).toFixed(2); - this.node.querySelector(".perf-encode").textContent = Number(performanceData.last_pixel_encode_ms || 0).toFixed(2); - this.node.querySelector(".perf-bandwidth").textContent = Number(performanceData.pixel_payload_megabytes_per_second || 0).toFixed(1); - this.node.querySelector(".perf-pending").textContent = observer.pending_frame_count ?? 0; - this.node.querySelector(".perf-dropped").textContent = observer.dropped_frame_count ?? 0; - this.node.querySelector(".perf-points").textContent = `${Number(dataShape.input_points || 0).toLocaleString()}→${Number(dataShape.rendered_elements || 0).toLocaleString()}`; - const limitDurations = {frequency_limited: observer.target_interval_ns, paint_limited: observer.paint_duration_ns, render_limited: observer.render_duration_ns, consumer_limited: observer.consumer_interval_ns}; - const limitName = limitNames[limit.current] || limit.current || "N/A"; - const limitDuration = limitDurations[limit.current]; - const limitValue = limitDuration === undefined ? limitName : `${limitName} · ${formatNanoseconds(limitDuration)}`; - const limitNode = this.node.querySelector(".perf-limit"); - limitNode.textContent = limitValue; - limitNode.title = limitDuration === undefined ? limitName : `${limitName} · ${Number(limitDuration || 0).toLocaleString()} ns`; - this.node.querySelector(".perf-event").textContent = observer.last_event || "none"; - this.node.querySelector(".perf-timeouts").textContent = this.frameTimeoutCount.toLocaleString(); - this.node.querySelector(".perf-pixel-age").textContent = this.lastPixelReceivedAt ? `${Math.max(0, performance.now() - this.lastPixelReceivedAt).toFixed(0)} ms` : "—"; - this.updateObserverDashboard(observer); - this.updateClientDashboard(this.telemetry.client_performance || {}); - this.updateMotionStatus(); - const setLimitFlag = (selector, enabled, active, duration) => { - const flag = this.node.querySelector(selector); - flag.dataset.active = String(Boolean(active)); - flag.querySelector("b").textContent = enabled ? `${active ? "当前瓶颈" : "未受限"} · ${formatNanoseconds(duration)}` : `已关闭 · ${formatNanoseconds(duration)}`; - }; - setLimitFlag(".limit-frequency", observer.frequency_limit_enabled !== false, limit.current === "frequency_limited", observer.target_interval_ns); - setLimitFlag(".limit-paint", true, limit.current === "paint_limited", observer.paint_duration_ns); - setLimitFlag(".limit-render", true, limit.current === "render_limited", observer.render_duration_ns); - setLimitFlag(".limit-consumer", Boolean(observer.consumer_feedback_enabled), limit.current === "consumer_limited", observer.consumer_interval_ns); - } - updateObserverDashboard(observer) { - const nanosecondFields = new Set([ - "paint_duration_ns", "render_duration_ns", "target_interval_ns", - "bottleneck_duration_ns", "consumer_pixel_interval_ns", "consumer_presentation_interval_ns", - "consumer_manual_interval_ns", "consumer_sample_interval_ns", "consumer_smoothed_interval_ns", - "consumer_variation_ns", "consumer_safety_interval_ns", "consumer_interval_ns", "next_refresh_interval_ns", "paint_lease_wait_ns", - "paint_state_wait_ns", "publish_state_wait_ns", "ready_wait_ns", - "frame_age_at_render_ns", "render_lease_wait_ns", "render_state_wait_ns", - "render_finish_state_wait_ns", "queue_wait_ns", "end_to_end_ns" - ]); - const booleanFields = new Set([ - "consumer_feedback_master_enabled", "consumer_feedback_enabled", - "consumer_pixel_feedback_enabled", "consumer_presentation_feedback_enabled", - "consumer_manual_feedback_enabled" - ]); - for (const [name, field] of this.observerFields) { - const raw = observer[name] ?? (name === "limit_state" ? "not_applicable" : - name === "last_event" || name === "consumer_feedback_source" ? "none" : 0); - if (name === "consumer_effective_fps") { - const interval = Number(observer.consumer_interval_ns || 0); - field.textContent = interval > 0 ? `${(1e9 / interval).toFixed(2)} FPS` : "0 FPS"; - field.title = interval > 0 ? `${1e9 / interval} FPS` : "0 FPS"; - continue; - } - if (name === "consumer_manual_fps") { - field.textContent = `${Number(raw || 0).toFixed(2)} FPS`; - field.title = `${Number(raw || 0)} FPS`; - continue; - } - if (name === "consumer_feedback_source") { - const names = {disabled: "总开关关闭", none: "无", pixel: "像素响应", - presentation: "浏览器呈现", manual: "手动"}; - field.textContent = String(raw).split("+").map(value => names[value] || value).join(" + "); - field.title = String(raw); - continue; - } - if (booleanFields.has(name)) { - field.textContent = raw ? "启用" : "关闭"; - field.title = String(Boolean(raw)); - continue; - } - field.textContent = nanosecondFields.has(name) ? formatNanoseconds(raw) : - name === "configured_frequency_hz" ? (observer.frequency_limit_enabled === false ? "已关闭" : `${Number(raw || 0).toLocaleString()} Hz`) : - typeof raw === "number" ? raw.toLocaleString() : String(raw); - field.title = nanosecondFields.has(name) ? `${Number(raw || 0).toLocaleString()} ns` : String(raw); - } - } - updateClientDashboard(client) { - for (const field of this.node.querySelectorAll("[data-client-field]")) { - const name = field.dataset.clientField; - const raw = Number(client[name] || 0); - if (name.endsWith("_fps")) field.textContent = `${raw.toFixed(2)} FPS`; - else if (name.endsWith("_ms")) field.textContent = `${raw.toFixed(3)} ms`; - else if (name === "websocket_buffered_bytes") field.textContent = `${raw.toLocaleString()} B`; - else field.textContent = raw.toLocaleString(); - field.title = String(raw); + if (data.notice && !data.notice.includes("已创建")) notify(`${this.definition.title}:${data.notice}`); + if (menuSession === this) { + menuStatus = data.notice || "后端状态已回读"; + updateMenuData(); } } pixelSignature(buffer, width, height, stride) { @@ -355,38 +366,26 @@ class GalleryCard { } return hash; } - updateMotionStatus(now = performance.now()) { - let state = "waiting", label = "等待动态帧"; - if (!this.ready || this.socket?.readyState !== WebSocket.OPEN) { - state = "stalled"; label = "像素流断开"; - } else if (!this.streamActive()) { - state = "waiting"; label = streamsPaused && this.displayVisible() ? "像素流已暂停" : "非活动视图"; - } else if (this.lastPixelChangeAt && now - this.lastPixelChangeAt < 1500) { - state = "moving"; label = `画面变化 ${this.changedPixelFrames.toLocaleString()}`; - } else if (this.lastPixelReceivedAt && now - this.lastPixelReceivedAt < 1500) { - state = "duplicate"; label = `重复像素帧 ${this.duplicatePixelFrames.toLocaleString()}`; - } else if (this.lastPixelReceivedAt) { - state = "stalled"; label = "像素帧已停滞"; - } - if (state !== this.motionState || this.motionStatus.querySelector("span").textContent !== label) { - this.motionState = state; - this.motionStatus.dataset.state = state; - this.motionStatus.querySelector("span").textContent = label; - } - } receivePixels(buffer) { this.framePending = false; - if (this.frameTimeout !== null) { clearTimeout(this.frameTimeout); this.frameTimeout = null; } + if (this.frameTimeout !== null) { + clearTimeout(this.frameTimeout); + this.frameTimeout = null; + } if (!(buffer instanceof ArrayBuffer) || buffer.byteLength < 16) { - this.setSocketState("error", "像素帧无效 · 自动重连"); + this.socketState = "error"; + this.socketText = "像素帧无效 · 自动重连"; this.socket?.close(1003, "invalid pixel frame"); return; } const header = new DataView(buffer, 0, 16); const magic = String.fromCharCode(...new Uint8Array(buffer, 0, 4)); - const width = header.getUint32(4, true), height = header.getUint32(8, true), stride = header.getUint32(12, true); + const width = header.getUint32(4, true); + const height = header.getUint32(8, true); + const stride = header.getUint32(12, true); if (magic !== "RVP1" || !width || !height || stride < width * 4 || buffer.byteLength < 16 + stride * height) { - this.setSocketState("error", "像素帧协议错误 · 自动重连"); + this.socketState = "error"; + this.socketText = "像素帧协议错误 · 自动重连"; this.socket?.close(1003, "invalid pixel frame"); return; } @@ -404,19 +403,21 @@ class GalleryCard { } if (this.latestPixelBuffer !== null) this.overwrittenPixelFrames++; this.latestPixelBuffer = buffer; - this.setSocketState("ready", `${this.mode.strategy || "Core2"} 在线`); + this.socketState = "ready"; + this.socketText = `${this.mode.strategy || "Core2"} 在线`; this.transportFps = this.recordRate(this.transportTimes, now); this.frameCount++; - this.frameLabel.textContent = this.frameCount.toLocaleString(); this.updateMotionStatus(now); this.requestFrame(now); } presentLatest(time) { const buffer = this.latestPixelBuffer; - if (!buffer) return; + if (!buffer || !this.context || !this.canvas) return; this.latestPixelBuffer = null; const header = new DataView(buffer, 0, 16); - const width = header.getUint32(4, true), height = header.getUint32(8, true), stride = header.getUint32(12, true); + const width = header.getUint32(4, true); + const height = header.getUint32(8, true); + const stride = header.getUint32(12, true); if (this.canvas.width !== width || this.canvas.height !== height) { this.canvas.width = width; this.canvas.height = height; @@ -424,7 +425,8 @@ class GalleryCard { if (stride === width * 4) { this.context.putImageData(new ImageData(new Uint8ClampedArray(buffer, 16, width * height * 4), width, height), 0, 0); } else { - const packed = new Uint8ClampedArray(width * height * 4), source = new Uint8Array(buffer, 16); + const packed = new Uint8ClampedArray(width * height * 4); + const source = new Uint8Array(buffer, 16); for (let row = 0; row < height; row++) packed.set(source.subarray(row * stride, row * stride + width * 4), row * width * 4); this.context.putImageData(new ImageData(packed, width, height), 0, 0); } @@ -440,9 +442,13 @@ class GalleryCard { return this.currentRate(history, now); } resize() { - if (this.socket?.readyState !== WebSocket.OPEN) return; - const rect = this.shell.getBoundingClientRect(); - this.send("resize", {width: Math.max(240, Math.round(rect.width)), height: Math.max(180, Math.round(rect.height))}); + if (this.socket?.readyState !== WebSocket.OPEN || !this.shell) return; + const width = Math.max(240, Math.round(this.shell.clientWidth)); + const height = Math.max(180, Math.round(this.shell.clientHeight)); + if (width === this.lastResizeWidth && height === this.lastResizeHeight) return; + this.lastResizeWidth = width; + this.lastResizeHeight = height; + this.send("resize", {width, height}); } requestFrame(time, explicit = false) { if ((!explicit && !this.streamActive()) || (explicit && !this.displayVisible()) || !this.ready || this.framePending || this.socket?.readyState !== WebSocket.OPEN) return; @@ -456,7 +462,8 @@ class GalleryCard { this.frameTimeout = null; this.frameTimeoutCount++; if (!this.streamActive() || this.socket?.readyState !== WebSocket.OPEN) return; - this.setSocketState("error", "像素响应超时 · 正在恢复"); + this.socketState = "error"; + this.socketText = "像素响应超时 · 正在恢复"; this.syncActivity(true); }, 1500); } @@ -465,256 +472,455 @@ class GalleryCard { this.lastObserveRequest = time; this.transportFps = this.currentRate(this.transportTimes, time); this.presentationFps = this.currentRate(this.presentationTimes, time); - this.send("gallery_observe", {client_metrics: { + this.send("gallery_observe", {client_metrics: this.clientMetrics(time)}); + } + clientMetrics(time = performance.now()) { + return { transport_fps: this.transportFps, presentation_fps: this.presentationFps, - websocket_buffered_bytes: this.socket.bufferedAmount || 0, + websocket_buffered_bytes: this.socket?.bufferedAmount || 0, changed_pixel_frames: this.changedPixelFrames, duplicate_pixel_frames: this.duplicatePixelFrames, frame_request_timeout_count: this.frameTimeoutCount, frame_round_trip_ms: this.frameRoundTripMs, - display_interval_ms: displayIntervalMs, display_interval_latest_ms: displayIntervalLatestMs, + display_interval_ms: displayIntervalMs, display_interval_p95_ms: displayIntervalP95Ms, display_jitter_ms: displayJitterMs, overwritten_pixel_frames: this.overwrittenPixelFrames, last_pixel_receive_age_ms: this.lastPixelReceivedAt ? Math.max(0, time - this.lastPixelReceivedAt) : 0, last_pixel_change_age_ms: this.lastPixelChangeAt ? Math.max(0, time - this.lastPixelChangeAt) : 0 - }}); + }; + } + updateMotionStatus(now = performance.now()) { + let state = "waiting"; + let label = "等待动态帧"; + if (!this.ready || this.socket?.readyState !== WebSocket.OPEN) { + state = "stalled"; + label = "像素流断开"; + } else if (!this.streamActive()) { + state = "waiting"; + label = streamsPaused && this.displayVisible() ? "像素流已暂停" : "非活动视图"; + } else if (this.lastPixelChangeAt && now - this.lastPixelChangeAt < 1500) { + state = "moving"; + label = `画面变化 ${this.changedPixelFrames.toLocaleString()}`; + } else if (this.lastPixelReceivedAt && now - this.lastPixelReceivedAt < 1500) { + state = "duplicate"; + label = `重复像素帧 ${this.duplicatePixelFrames.toLocaleString()}`; + } else if (this.lastPixelReceivedAt) { + state = "stalled"; + label = "像素帧已停滞"; + } + this.motionState = state; + this.motionText = label; } position(event) { const rect = this.canvas.getBoundingClientRect(); return {x: (event.clientX - rect.left) * this.canvas.width / Math.max(1, rect.width), y: (event.clientY - rect.top) * this.canvas.height / Math.max(1, rect.height)}; } - modifiers(event) { return (event.shiftKey ? 1 : 0) | (event.ctrlKey ? 2 : 0) | (event.altKey ? 4 : 0) | (event.metaKey ? 8 : 0); } - installCanvasEvents() { - const pointer = (type, event) => this.send(type, {...this.position(event), button: ["left", "middle", "right"][event.button] || "none", buttons: event.buttons, modifiers: this.modifiers(event)}); - this.canvas.addEventListener("pointermove", event => pointer("pointer_move", event)); - this.canvas.addEventListener("pointerdown", event => { if (event.button !== 2) { this.shell.focus(); this.canvas.setPointerCapture(event.pointerId); pointer("pointer_press", event); } }); - this.canvas.addEventListener("pointerup", event => { if (event.button !== 2) pointer("pointer_release", event); }); - this.canvas.addEventListener("pointerleave", () => this.send("leave")); - this.canvas.addEventListener("wheel", event => { event.preventDefault(); this.send("wheel", {...this.position(event), pixelDeltaX: event.deltaX, pixelDeltaY: event.deltaY, angleDeltaX: -event.deltaX * 8, angleDeltaY: -event.deltaY * 8, buttons: event.buttons, modifiers: this.modifiers(event)}); }, {passive: false}); - this.shell.addEventListener("keydown", event => this.send("key_press", {key: event.key, nativeKey: event.keyCode, repeat: event.repeat, modifiers: this.modifiers(event)})); - this.shell.addEventListener("keyup", event => this.send("key_release", {key: event.key, nativeKey: event.keyCode, repeat: false, modifiers: this.modifiers(event)})); + modifiers(event) { + return (event.shiftKey ? 1 : 0) | (event.ctrlKey ? 2 : 0) | (event.altKey ? 4 : 0) | (event.metaKey ? 8 : 0); } -} - -function createPage(mode) { - const page = elements.pageTemplate.content.firstElementChild.cloneNode(true); - page.dataset.mode = mode.id; - page.querySelector(".page-strategy").textContent = mode.strategy; - page.querySelector(".page-title").textContent = `${mode.title} · 全控件页`; - page.querySelector(".page-description").textContent = mode.description; - const gallery = page.querySelector(".gallery"); - const cards = definitions.map(definition => new GalleryCard(definition, mode)); - cards.forEach(card => gallery.append(card.node)); - elements.pages.append(page); - pages.set(mode.id, {mode, page, cards}); - return pages.get(mode.id); -} -function syncCardActivity() { - for (const page of pages.values()) for (const card of page.cards) card.syncActivity(); -} -function selectMode(id) { - activeMode = id; - let selected = pages.get(id); - if (!selected) selected = createPage(modes.find(mode => mode.id === id)); - for (const [modeId, page] of pages) page.page.hidden = modeId !== id; - [...elements.modeTabs.children].forEach(button => button.classList.toggle("active", button.dataset.mode === id)); - elements.modeDescription.textContent = selected.mode.description; - applyCategory(); - syncCardActivity(); - closeMenu(); -} -function applyCategory() { - const page = pages.get(activeMode); - if (!page) return; - for (const card of page.cards) { - card.node.hidden = !card.categoryVisible(); - card.syncActivity(); + commitControl(id, payload) { + const item = this.controls.find(control => control.id === id); + if (!item) return; + let value = normalizeEventValue(payload, id); + if (item.input === "number") value = Number(value); + else if (item.input === "boolean") value = Boolean(value); + else value = String(value ?? ""); + if (item.input === "number" && (!Number.isFinite(value) || value < Number(item.minimum) || value > Number(item.maximum))) { + menuStatus = `${item.label} 超出后端允许范围`; + renderMenu(this, true); + return; + } + if (Object.is(this.lastSubmittedControls.get(id), value) || Object.is(this.controlValues.get(id), value)) return; + this.lastSubmittedControls.set(id, value); + this.send("gallery_patch", {patch: {[id]: value}}); + menuStatus = `提交 ${item.api} · 等待后端回读`; + updateMenuData(); } -} -function installNavigation() { - elements.modeTabs.replaceChildren(...modes.map(mode => { - const button = document.createElement("button"); - button.type = "button"; button.dataset.mode = mode.id; - button.innerHTML = `${mode.title}${mode.strategy}`; - button.addEventListener("click", () => selectMode(mode.id)); - return button; - })); - const categories = ["全部", ...new Set(definitions.map(item => item.category))]; - elements.filters.replaceChildren(...categories.map(category => { - const button = document.createElement("button"); - button.type = "button"; button.textContent = category; - button.classList.toggle("active", category === activeCategory); - button.addEventListener("click", () => { - activeCategory = category; - [...elements.filters.children].forEach(child => child.classList.toggle("active", child === button)); - applyCategory(); - }); - return button; - })); -} - -function openMenu(card, x, y) { - if (!card.ready) { toast("该控件仍在等待后端描述", true); return; } - activeCard = card; activeTab = "controls"; - elements.menuComponent.textContent = `${card.mode.strategy} / ${card.definition.component}`; - elements.menuTitle.textContent = card.definition.title; - elements.menuDescription.textContent = card.definition.description; - elements.menuStatus.textContent = "菜单仅包含当前控件和当前帧策略可调用的 API"; - elements.menuTabs.forEach(button => button.classList.toggle("active", button.dataset.tab === activeTab)); - renderMenuBody(); - elements.menu.hidden = false; - const rect = elements.menu.getBoundingClientRect(), gap = 10; - elements.menu.style.left = `${Math.max(gap, Math.min(x, innerWidth - rect.width - gap))}px`; - elements.menu.style.top = `${Math.max(gap, Math.min(y, innerHeight - rect.height - gap))}px`; -} -function closeMenu() { elements.menu.hidden = true; activeCard = null; } -function renderControl(item) { - const card = activeCard; - const row = document.createElement("div"); row.className = "control-row"; - const copy = document.createElement("div"); copy.className = "control-copy"; - const label = document.createElement("label"); label.textContent = item.label; - const api = document.createElement("code"); api.textContent = item.api; api.title = item.api; copy.append(label, api); - let input; - if (item.input === "select") { - input = document.createElement("select"); - for (const value of item.options || []) { const option = document.createElement("option"); option.value = value; option.textContent = value; input.append(option); } - input.value = String(item.value); - } else { - input = document.createElement("input"); input.type = item.input === "boolean" ? "checkbox" : item.input; - if (item.input === "boolean") input.checked = Boolean(item.value); else input.value = item.value; - if (item.input === "number") { input.min = item.minimum; input.max = item.maximum; input.step = item.step || "any"; } + runAction(id, payload) { + const item = this.actions.find(action => action.id === id); + if (!item) return; + const request = {action: item.id}; + if (item.argument_input) { + const field = `action_${item.id}`; + let value = normalizeEventValue(payload, field); + if (item.argument_input === "number") value = Number(value); + request.argument = value; + } + this.send("gallery_action", request); + menuStatus = `执行 ${item.api}`; + updateMenuData(); + if (["mode_render", "mode_dequeue", "mode_cycle"].includes(item.id)) setTimeout(() => this.requestFrame(performance.now(), true), 40); } - input.className = "control-input"; input.title = item.description || item.api; - const submit = value => { - card.send("gallery_patch", {patch: {[item.id]: value}}); - elements.menuStatus.textContent = `提交 ${item.api} · 等待后端回读`; - }; - if (item.input === "number") { - let committedValue = input.value; - const commit = () => { - const value = input.valueAsNumber; - const minimum = Number(item.minimum); - const maximum = Number(item.maximum); - if (input.value === "" || !Number.isFinite(value) || - (Number.isFinite(minimum) && value < minimum) || - (Number.isFinite(maximum) && value > maximum)) { - input.value = committedValue; - return; - } - if (value === Number(committedValue)) { - input.value = committedValue; - return; - } - committedValue = input.value; - item.value = value; - submit(value); + controlData() { + const data = {}; + for (const item of this.controls) data[item.id] = this.controlValues.get(item.id) ?? item.value; + for (const item of this.actions) if (item.argument_input) data[`action_${item.id}`] = item.argument_default; + return data; + } + snapshot() { + const performanceData = this.telemetry.performance || {}; + const observer = this.telemetry.kernel_observer || {}; + const dataShape = this.telemetry.data_shape || {}; + const limit = this.telemetry.low_latency_limit || {}; + const limitNames = {frequency_limited: "Kernel 频率受限", paint_limited: "PaintEvent 受限", render_limited: "后台渲染受限", consumer_limited: "消费者反馈受限", unlimited: "无限制", not_applicable: "N/A"}; + const limitDurations = {frequency_limited: observer.target_interval_ns, paint_limited: observer.paint_duration_ns, render_limited: observer.render_duration_ns, consumer_limited: observer.consumer_interval_ns}; + const limitName = limitNames[limit.current] || limit.current || "N/A"; + const limitDuration = limitDurations[limit.current]; + const client = this.clientMetrics(); + const observerValues = {}; + const clientValues = {}; + observerValues.mode = observer.mode || this.mode.id; + observerValues.limit_state = observer.limit_state || "not_applicable"; + for (const [name] of observerMetricDefinitions) observerValues[name] = observerValue(name, observer); + for (const [name] of clientMetricDefinitions) clientValues[name] = clientValue(name, client); + const limitTile = (enabled, active, duration) => ({text: enabled ? `${active ? "当前瓶颈" : "未受限"} · ${formatNanoseconds(duration)}` : `已关闭 · ${formatNanoseconds(duration)}`, active: Boolean(active)}); + return { + ready: this.ready, + socketState: this.socketState, + socketText: this.socketText, + motionState: this.motionState, + motionText: this.motionText, + frameCount: formatInteger(this.frameCount), + backendFps: formatFps(performanceData.measured_fps), + transportFps: formatFps(performanceData.pixel_response_fps || this.transportFps), + presentationFps: formatFps(this.presentationFps), + roundTrip: Number(this.frameRoundTripMs || 0).toFixed(2), + overwritten: formatInteger(this.overwrittenPixelFrames), + renderMs: Number(performanceData.last_render_ms || 0).toFixed(2), + encodeMs: Number(performanceData.last_pixel_encode_ms || 0).toFixed(2), + bandwidth: Number(performanceData.pixel_payload_megabytes_per_second || 0).toFixed(1), + pending: formatInteger(observer.pending_frame_count), + dropped: formatInteger(observer.dropped_frame_count), + points: `${formatInteger(dataShape.input_points)}→${formatInteger(dataShape.rendered_elements)}`, + limit: limitDuration === undefined ? limitName : `${limitName} · ${formatNanoseconds(limitDuration)}`, + event: observer.last_event || "none", + timeouts: formatInteger(this.frameTimeoutCount), + pixelAge: this.lastPixelReceivedAt ? `${Math.max(0, performance.now() - this.lastPixelReceivedAt).toFixed(0)} ms` : "—", + limitFrequency: limitTile(observer.frequency_limit_enabled !== false, limit.current === "frequency_limited", observer.target_interval_ns), + limitPaint: limitTile(true, limit.current === "paint_limited", observer.paint_duration_ns), + limitRender: limitTile(true, limit.current === "render_limited", observer.render_duration_ns), + limitConsumer: limitTile(Boolean(observer.consumer_feedback_enabled), limit.current === "consumer_limited", observer.consumer_interval_ns), + observer: observerValues, + client: clientValues, + rawTelemetry: this.telemetry }; - input.addEventListener("blur", commit); - input.addEventListener("keydown", event => { - if (event.key === "Enter") { - event.preventDefault(); - commit(); - } else if (event.key === "Escape") { - event.preventDefault(); - event.stopPropagation(); - input.value = committedValue; - } - }); - } else input.addEventListener("change", () => submit(item.input === "boolean" ? input.checked : input.value)); - row.append(copy, input); return row; -} -function renderAction(item) { - const row = document.createElement("div"); row.className = "action-row"; - const copy = document.createElement("div"); copy.className = "action-copy"; - const label = document.createElement("strong"); label.textContent = item.label; - const api = document.createElement("code"); api.textContent = item.api; api.title = item.api; copy.append(label, api); - const controls = document.createElement("div"); controls.className = "action-controls"; - let argument = null; - if (item.argument_input) { argument = document.createElement("input"); argument.className = "control-input"; argument.type = item.argument_input; argument.value = item.argument_default; controls.append(argument); } - const button = document.createElement("button"); button.className = "action-button"; button.type = "button"; button.textContent = "执行"; - button.addEventListener("click", () => { - const card = activeCard; - const payload = {action: item.id}; - if (argument) payload.argument = item.argument_input === "number" ? Number(argument.value) : argument.value; - card.send("gallery_action", payload); - elements.menuStatus.textContent = `执行 ${item.api}`; - if (["mode_render", "mode_dequeue", "mode_cycle"].includes(item.id)) setTimeout(() => card.requestFrame(performance.now(), true), 40); - }); - controls.append(button); row.append(copy, controls); return row; -} -function renderGroups(items, renderer) { - const fragment = document.createDocumentFragment(); - for (const [name, children] of grouped(items)) { - const section = document.createElement("section"); section.className = "control-group"; - const title = document.createElement("h3"); title.textContent = name; - section.append(title, ...children.map(renderer)); fragment.append(section); } - elements.menuBody.replaceChildren(fragment); + dispose() { + this.disposed = true; + clearTimeout(this.frameTimeout); + clearTimeout(this.reconnectTimer); + this.send("hide"); + this.socket?.close(); + this.detach(); + } } -function renderData(value) { - const list = document.createElement("dl"); list.className = "telemetry-grid"; - for (const [key, content] of flatten(value)) { const dt = document.createElement("dt"), dd = document.createElement("dd"); dt.textContent = key; dd.textContent = content; list.append(dt, dd); } - elements.menuBody.replaceChildren(list); +function sessionKey(modeId, caseId) { + return `${modeId}_${caseId}`; } -function renderMenuBody() { - if (!activeCard) return; - if (activeTab === "actions") renderGroups(activeCard.actions, renderAction); - else if (activeTab === "observer") renderData(activeCard.telemetry.kernel_observer || {}); - else if (activeTab === "performance") renderData({performance: activeCard.telemetry.performance || {}, client_performance: activeCard.telemetry.client_performance || {}, low_latency_limit: activeCard.telemetry.low_latency_limit || {current: "not_applicable"}, data_shape: activeCard.telemetry.data_shape || {}, overlay: {enabled: activeCard.telemetry.performance_overlay_enabled, lines: activeCard.telemetry.performance_overlay_lines}, frame_mode: activeCard.telemetry.frame_mode}); - else renderGroups(activeCard.controls, renderControl); +function ensureSession(key, definition, mode) { + if (!sessions.has(key)) sessions.set(key, new GallerySession(key, definition, mode)); + return sessions.get(key); +} +function RenderiveCanvas(props) { + const shellRef = React.useRef(null); + const canvasRef = React.useRef(null); + const definition = definitions.find(item => item.id === props.caseId); + const mode = modes.find(item => item.id === props.modeId); + const session = ensureSession(props.cardKey, definition, mode); + React.useEffect(() => { + session.attach(shellRef.current, canvasRef.current); + return () => session.detach(); + }, [session]); + return React.createElement("div", {ref: shellRef, className: `rv-canvas-shell ${session.ready ? "rv-canvas-ready" : ""}`, tabIndex: 0}, + React.createElement("canvas", {ref: canvasRef, "aria-label": "Core2 后端像素画布"}), + React.createElement("div", {className: "rv-canvas-hint"}, "右键:本控件全部 API"), + React.createElement("div", {className: "rv-canvas-loading"}, React.createElement("small", null, "创建 Kernel Scene"))); +} +amisLib.Renderer({test: /(^|\/)renderive-canvas$/})(RenderiveCanvas); +function metricGrid(key, definitionsList, className, prefix = "sessions") { + return { + type: "grid", + className, + columns: definitionsList.map(([field, label]) => ({xs: 6, sm: 4, md: 2, body: {type: "tpl", tpl: `
${expression(`${prefix}.${key}.${field}`)}${label}
`}})) + }; +} +function observerGrid(key, definitionList, source, className) { + return { + type: "grid", + className, + columns: definitionList.map(([field, label]) => ({xs: 6, sm: 4, md: 3, body: {type: "tpl", tpl: `
${expression(`sessions.${key}.${source}.${field}`)}${label}
`}})) + }; +} +function buildCardSchema(definition, mode) { + const key = sessionKey(mode.id, definition.id); + ensureSession(key, definition, mode); + const category = String(definition.category).replace(/'/g, "\\'"); + const frameLabel = mode.id === "manual" ? "手动刷新一帧" : mode.id === "playback" ? "消费下一帧" : "立即刷新"; + const body = [ + {type: "tpl", tpl: `
${definition.category}

${definition.title}

${expression(`sessions.${key}.motionText`)}${expression(`sessions.${key}.socketText`)}
`}, + {type: "renderive-canvas", cardKey: key, caseId: definition.id, modeId: mode.id}, + metricGrid(key, mainMetricDefinitions, "rv-metrics-grid") + ]; + if (mode.id === "low_latency") { + body.push({ + type: "grid", + className: "rv-limit-grid", + columns: limitDefinitions.map(([field, label]) => ({xs: 12, sm: 6, body: {type: "tpl", tpl: `
${label}${expression(`sessions.${key}.${field}.text`)}
`}})) + }); + body.push({type: "tpl", tpl: `
KERNEL ${expression(`sessions.${key}.observer.mode`)} OBSERVER事件 ${expression(`sessions.${key}.observer.last_event`)}
`}); + body.push(observerGrid(key, observerMetricDefinitions, "observer", "rv-observer-grid")); + body.push(observerGrid(key, clientMetricDefinitions, "client", "rv-client-grid")); + } + body.push({type: "tpl", tpl: `
${definition.component}Core2 组件
${definition.control_count_by_mode?.[mode.id] ?? "—"}属性入口
${definition.action_count_by_mode?.[mode.id] ?? "—"}动作入口
${expression(`sessions.${key}.frameCount`)}像素帧
`}); + return { + type: "panel", + className: "rv-card", + visibleOn: `\${selectedCategory === '全部' || selectedCategory === '${category}'}`, + body, + actions: [ + {type: "button", label: frameLabel, level: "primary", onEvent: {click: {actions: [{actionType: "custom", script: `window.RenderiveGallery.requestFrame(${quote(key)});`}]}}}, + {type: "button", label: "打开测试菜单", onEvent: {click: {actions: [{actionType: "custom", script: `window.RenderiveGallery.openMenu(${quote(key)});`}]}}} + ] + }; +} +function buildModeBody(mode) { + return { + type: "container", + body: [ + {type: "tpl", tpl: `
${mode.strategy}

${mode.title} · 全控件页

${mode.description}

`}, + {type: "grid", className: "rv-gallery", columns: definitions.map(definition => ({xs: 12, lg: 6, body: buildCardSchema(definition, mode)}))} + ] + }; +} +function buildMainSchema() { + const categories = ["全部", ...new Set(definitions.map(item => item.category))]; + return { + type: "page", + className: "rv-shell", + body: [ + {type: "tpl", tpl: `
R2

CORE2 · KERNEL · WEBSOCKET · AMIS

全控件 API 与帧策略性能画廊

${expression("connectionText")}
`}, + {type: "flex", justify: "flex-end", className: "rv-top-actions", items: [{type: "button", label: "${streamsPaused ? '恢复自动像素流' : '暂停自动像素流'}", onEvent: {click: {actions: [{actionType: "custom", script: "window.RenderiveGallery.toggleStreams();"}]}}}]}, + {type: "grid", className: "rv-hero", columns: [ + {xs: 12, lg: 7, body: {type: "tpl", tpl: `

THREE REAL KERNEL STRATEGIES

三套对称页面,同一批控件,直接比较

标准 UI 由 AMIS SDK 渲染;实时二进制像素画布使用 React 自定义 Renderer。

`}}, + {xs: 12, lg: 5, body: {type: "grid", className: "rv-hero-metrics", columns: [ + {xs: 6, body: {type: "tpl", tpl: `
${expression("pageCount")}帧策略页
`}}, + {xs: 6, body: {type: "tpl", tpl: `
${expression("caseCount")}每页控件
`}}, + {xs: 6, body: {type: "tpl", tpl: `
${expression("canvasCount")}独立场景
`}}, + {xs: 6, body: {type: "tpl", tpl: `
${expression("apiCount")}手测入口
`}} + ]}} + ]}, + {type: "alert", level: "danger", body: "${noticeText}", visibleOn: "${noticeText && noticeError}", className: "rv-notice", showIcon: true}, + {type: "alert", level: "info", body: "${noticeText}", visibleOn: "${noticeText && !noticeError}", className: "rv-notice", showIcon: true}, + {type: "form", wrapWithPanel: false, className: "rv-filter-form", body: [{type: "button-group-select", name: "selectedCategory", options: categories.map(value => ({label: value, value})), onEvent: {change: {actions: [{actionType: "custom", script: "window.RenderiveGallery.setCategory(event.data);"}]}}}]}, + {type: "tabs", className: "rv-mode-tabs", activeKey: modes.some(mode => mode.id === "low_latency") ? "low_latency" : modes[0]?.id, mountOnEnter: true, unmountOnExit: false, tabs: modes.map(mode => ({title: `${mode.title} · ${mode.strategy}`, key: mode.id, body: buildModeBody(mode)}))} + ] + }; +} +function controlEventAction(session, item, source) { + return {actionType: "custom", script: `window.RenderiveGallery.commitControl(${quote(session.key)},${quote(item.id)},${source});`}; +} +function controlSchema(session, item) { + const base = {name: item.id, label: item.label, description: item.description || item.api, remark: item.api, size: "md"}; + if (item.input === "boolean") { + return {type: "form", wrapWithPanel: false, className: "rv-control-form", body: [{...base, type: "switch", onEvent: {change: {actions: [controlEventAction(session, item, "event.data")]}}}]}; + } + if (item.input === "select") { + return {type: "form", wrapWithPanel: false, className: "rv-control-form", body: [{...base, type: "select", options: (item.options || []).map(value => ({label: value, value})), onEvent: {change: {actions: [controlEventAction(session, item, "event.data")]}}}]}; + } + if (item.input === "color") { + return {type: "form", wrapWithPanel: false, className: "rv-control-form", body: [{...base, type: "input-color", onEvent: {change: {actions: [controlEventAction(session, item, "event.data")]}}}]}; + } + if (item.input === "number") { + return { + type: "form", + wrapWithPanel: false, + className: "rv-control-form", + body: [{...base, type: "input-number", min: item.minimum, max: item.maximum, step: item.step || 1, precision: item.integer ? 0 : undefined, onEvent: {blur: {actions: [controlEventAction(session, item, "event.data")]}}}], + actions: [], + onEvent: {submit: {preventDefault: true, actions: [controlEventAction(session, item, "context.data")]}} + }; + } + return {type: "form", wrapWithPanel: false, className: "rv-control-form", body: [{...base, type: "input-text", onEvent: {blur: {actions: [controlEventAction(session, item, "event.data")]}}}]}; +} +function buildControlTab(session) { + return [...groupItems(session.controls)].map(([group, items]) => ({type: "panel", className: "rv-control-panel", title: group, body: items.map(item => controlSchema(session, item))})); +} +function actionSchema(session, item) { + const field = `action_${item.id}`; + const label = item.label || item.id; + const api = item.api || item.id; + const description = item.description || ""; + const body = [{type: "tpl", tpl: `
${escapeHtml(label)}${escapeHtml(api)}
${description ? `${escapeHtml(description)}` : ""}
`}]; + if (item.argument_input) body.push({type: item.argument_input === "number" ? "input-number" : "input-text", name: field, label: item.argument_label || "参数"}); + body.push({type: "button", label: `执行 · ${label}`, level: "primary", onEvent: {click: {actions: [{actionType: "custom", script: `window.RenderiveGallery.runAction(${quote(session.key)},${quote(item.id)},context.data);`}]}}}); + return {type: "form", wrapWithPanel: false, className: "rv-action-form", body, actions: []}; +} +function buildActionTab(session) { + return [...groupItems(session.actions)].map(([group, items]) => ({type: "panel", className: "rv-control-panel", title: group, body: items.map(item => actionSchema(session, item))})); +} +function menuObserverGrid(session) { + return { + type: "grid", + className: "rv-observer-grid", + columns: observerMetricDefinitions.map(([field, label]) => ({xs: 6, sm: 4, md: 3, body: {type: "tpl", tpl: `
${expression(`observer.${field}`)}${label}
`}})) + }; +} +function buildMenuSchema(session) { + return { + type: "page", + className: "rv-menu-shell", + body: [ + {type: "flex", className: "rv-menu-header", justify: "space-between", alignItems: "flex-start", items: [ + {type: "tpl", tpl: `

${session.mode.strategy} / ${session.definition.component}

${session.definition.title}

${session.definition.description}

`}, + {type: "button", label: "关闭", onEvent: {click: {actions: [{actionType: "custom", script: "window.RenderiveGallery.closeMenu();"}]}}} + ]}, + {type: "alert", level: "info", body: "${menuStatus}", showIcon: false}, + {type: "tabs", className: "rv-menu-tabs", mountOnEnter: true, tabs: [ + {title: "控件属性", body: buildControlTab(session)}, + {title: "专属 API", body: buildActionTab(session)}, + {title: "Kernel 观察者", body: menuObserverGrid(session)}, + {title: "性能", body: {type: "json", className: "rv-json", source: "${rawTelemetry}", levelExpand: 2}} + ]} + ] + }; +} +function rootData() { + const sessionData = {}; + for (const [key, session] of sessions) sessionData[key] = session.snapshot(); + const coverage = catalog?.coverage || {}; + return { + connectionState, + connectionText, + streamsPaused, + selectedCategory, + noticeText, + noticeError, + pageCount: coverage.page_count ?? modes.length, + caseCount: coverage.case_count ?? definitions.length, + canvasCount: coverage.canvas_count ?? definitions.length * modes.length, + apiCount: (coverage.manual_control_count || 0) + (coverage.manual_action_count || 0), + sessions: sessionData + }; +} +function updateMainData() { + if (mainScoped?.updateProps) mainScoped.updateProps({data: rootData()}); +} +function menuData(session) { + const snapshot = session.snapshot(); + return {...session.controlData(), menuStatus, observer: snapshot.observer, rawTelemetry: snapshot.rawTelemetry}; +} +function updateMenuData() { + if (menuScoped?.updateProps && menuSession) menuScoped.updateProps({data: menuData(menuSession)}); +} +function renderMenu(session, rebuild = false) { + if (!session.ready) { + notify("该控件仍在等待后端描述", true); + return; + } + menuSession = session; + menuHost.hidden = false; + if (rebuild) { + menuScoped?.unmount(); + menuScoped = null; + } + if (!menuScoped) menuScoped = amisEmbed.embed("#menu-root", buildMenuSchema(session), {data: menuData(session)}, {theme: "cxd"}); + else updateMenuData(); +} +function closeMenu() { + menuScoped?.unmount(); + menuScoped = null; + menuSession = null; + menuHost.hidden = true; } - function buildCatalog(data) { - definitions = [...(data.cases || [])].sort((a, b) => a.order - b.order); - modes = [...(data.frame_modes || [])].sort((a, b) => a.order - b.order); + catalog = data; + definitions = [...(data.cases || [])].sort((left, right) => left.order - right.order); + modes = [...(data.frame_modes || [])].sort((left, right) => left.order - right.order); if (!modes.length) throw new Error("后端没有返回帧策略目录"); - elements.pages.replaceChildren(); - elements.pageCount.textContent = data.coverage?.page_count ?? modes.length; - elements.caseCount.textContent = data.coverage?.case_count ?? definitions.length; - elements.canvasCount.textContent = data.coverage?.canvas_count ?? definitions.length * modes.length; - elements.apiCount.textContent = (data.coverage?.manual_control_count || 0) + (data.coverage?.manual_action_count || 0); - installNavigation(); - if (!modes.some(mode => mode.id === activeMode)) activeMode = modes[0].id; - selectMode(activeMode); - setConnection("ready", "三种 Kernel 策略目录已加载"); + for (const mode of modes) for (const definition of definitions) ensureSession(sessionKey(mode.id, definition.id), definition, mode); + connectionState = "ready"; + connectionText = "三种 Kernel 策略目录已加载"; + mainScoped?.unmount(); + mainScoped = amisEmbed.embed(rootNode, buildMainSchema(), {data: rootData()}, {theme: "cxd"}); } function connectCatalog() { const socket = new WebSocket(socketUrl); socket.addEventListener("open", () => socket.send(message("gallery_catalog"))); socket.addEventListener("message", event => { if (typeof event.data !== "string") return; - try { const data = JSON.parse(event.data); if (data.type === "catalog") { buildCatalog(data); socket.close(); } else if (data.type === "error") throw new Error(data.message); } - catch (error) { setConnection("error", "目录解析失败"); toast(error.message, true); } + try { + const data = JSON.parse(event.data); + if (data.type === "catalog") { + buildCatalog(data); + socket.close(); + } else if (data.type === "error") throw new Error(data.message); + } catch (error) { + connectionState = "error"; + connectionText = "目录解析失败"; + notify(error instanceof Error ? error.message : String(error), true); + } + }); + socket.addEventListener("error", () => { + connectionState = "error"; + connectionText = `无法连接 ${socketUrl} · 自动重连`; + updateMainData(); }); - socket.addEventListener("error", () => { setConnection("error", `无法连接 ${socketUrl} · 自动重连`); }); socket.addEventListener("close", () => { - if (!definitions.length) setTimeout(connectCatalog, 1000); + if (!catalog) setTimeout(connectCatalog, 1000); }); } function loop(time) { updateDisplayTiming(time); - const page = pages.get(activeMode); - for (const card of page?.cards || []) { - card.presentLatest(time); - card.updateMotionStatus(time); - if (card.mode.id !== "low_latency") card.requestFrame(time); - card.observe(time); + for (const session of sessions.values()) { + if (!session.shell) continue; + session.presentLatest(time); + session.updateMotionStatus(time); + if (session.mode.id !== "low_latency") session.requestFrame(time); + session.observe(time); + } + if (time - lastUiUpdateAt >= 200) { + lastUiUpdateAt = time; + updateMainData(); + updateMenuData(); } requestAnimationFrame(loop); } - -elements.streamToggle.addEventListener("click", () => { - streamsPaused = !streamsPaused; - elements.streamToggle.textContent = streamsPaused ? "恢复自动像素流" : "暂停自动像素流"; - syncCardActivity(); +window.RenderiveGallery = { + setCategory(payload) { + const value = normalizeEventValue(payload, "selectedCategory"); + selectedCategory = String(value || "全部"); + updateMainData(); + for (const session of sessions.values()) session.syncActivity(); + }, + toggleStreams() { + streamsPaused = !streamsPaused; + updateMainData(); + for (const session of sessions.values()) session.syncActivity(true); + }, + requestFrame(key) { + sessions.get(key)?.requestFrame(performance.now(), true); + }, + openMenu(key) { + const session = sessions.get(key); + if (session) renderMenu(session, menuSession !== session); + }, + closeMenu, + commitControl(key, id, payload) { + sessions.get(key)?.commitControl(id, payload); + }, + runAction(key, id, payload) { + sessions.get(key)?.runAction(id, payload); + } +}; +menuHost.addEventListener("pointerdown", event => { + if (event.target === menuHost) closeMenu(); +}); +document.addEventListener("keydown", event => { + if (event.key === "Escape" && !menuHost.hidden) closeMenu(); }); -elements.menuClose.addEventListener("click", closeMenu); -elements.menuTabs.forEach(button => button.addEventListener("click", () => { activeTab = button.dataset.tab; elements.menuTabs.forEach(item => item.classList.toggle("active", item === button)); renderMenuBody(); })); -document.addEventListener("keydown", event => { if (event.key === "Escape" && !elements.menu.hidden) closeMenu(); }); -document.addEventListener("pointerdown", event => { if (!elements.menu.hidden && !elements.menu.contains(event.target) && !event.target.closest(".open-menu")) closeMenu(); }); document.addEventListener("visibilitychange", () => { lastAnimationFrameAt = 0; displayIntervalMs = 0; @@ -722,9 +928,11 @@ document.addEventListener("visibilitychange", () => { displayIntervalP95Ms = 0; displayJitterMs = 0; displayIntervalSamples.length = 0; - syncCardActivity(); + for (const session of sessions.values()) session.syncActivity(); }); -window.addEventListener("beforeunload", () => { for (const page of pages.values()) for (const card of page.cards) { card.disposed = true; clearTimeout(card.frameTimeout); clearTimeout(card.reconnectTimer); card.send("hide"); card.socket?.close(); } }); - +window.addEventListener("beforeunload", () => { + for (const session of sessions.values()) session.dispose(); +}); +mainScoped = amisEmbed.embed(rootNode, {type: "page", className: "rv-shell", body: [{type: "tpl", tpl: "

RENDERIVE · AMIS SDK

等待后端返回控件与帧策略目录

正在连接 WebSocket catalog。

"}]}, {data: rootData()}, {theme: "cxd"}); connectCatalog(); requestAnimationFrame(loop); diff --git a/webapp_gallery/index.html b/webapp_gallery/index.html index a45bd59..cb54879 100644 --- a/webapp_gallery/index.html +++ b/webapp_gallery/index.html @@ -3,182 +3,19 @@ + Renderive Core2 全控件性能画廊 + + + -
-
- -

CORE2 · KERNEL · WEBSOCKET

全控件 API 与帧策略性能画廊

-
-
- 读取后端目录 - -
-
- -
-
-

THREE REAL KERNEL STRATEGIES

-

三套对称页面,同一批控件,直接比较

-

所有属性、动作、观察者和性能数据均由后端通过 WebSocket 返回。

-
-
-
3
帧策略页
-
每页控件
-
独立场景
-
手测入口
-
-
- - - - -
-
等待后端返回控件与帧策略目录
-
- - - - - - - - - +
+ + diff --git a/webapp_gallery/styles.css b/webapp_gallery/styles.css index 6a4ddb7..b1d51be 100644 --- a/webapp_gallery/styles.css +++ b/webapp_gallery/styles.css @@ -1,84 +1,599 @@ :root { - color-scheme: dark; - font-family: Inter, "Segoe UI Variable", "Microsoft YaHei UI", sans-serif; - --bg: #07090d; --panel: #10141b; --panel2: #151b24; --line: #29313d; - --strong: #3b4655; --text: #f2f5f8; --muted: #929eac; --accent: #45ddbe; - --blue: #6aa9ff; --warning: #ffd166; --danger: #ff6885; + color-scheme:dark; + --bg:#060a10; + --panel:#0b1119; + --panel2:#101823; + --panel3:#151f2c; + --line:#1e2b3a; + --line2:#2a3b4e; + --text:#dce9ff; + --muted:#8190a5; + --accent:#35e6b2; + --accent2:#6bc8ff; + --warn:#ffcf82; + --danger:#ff6b81; +} +* { + box-sizing:border-box; +} +html,body,#root { + min-height:100%; + margin:0; } -* { box-sizing: border-box; } -html { background: var(--bg); } body { - margin: 0; min-width: 320px; min-height: 100vh; color: var(--text); - background: linear-gradient(rgba(255,255,255,.018) 1px, transparent 1px), - linear-gradient(90deg, rgba(255,255,255,.018) 1px, transparent 1px), - radial-gradient(circle at 16% -10%, rgba(69,221,190,.12), transparent 34rem), var(--bg); - background-size: 24px 24px, 24px 24px, auto, auto; + overflow-x:hidden; + background:radial-gradient(circle at 18% 0,#102136 0,transparent 30rem),linear-gradient(180deg,#070c13,#05080d 58%,#060a10); + color:var(--text); + font:13px/1.5 Inter,"Segoe UI","Microsoft YaHei",sans-serif; +} +button,input,select,textarea { + font:inherit; +} +.rv-app,.cxd-Page,.cxd-Page-body,.cxd-Page-content { + min-height:100vh; + background:transparent!important; + color:var(--text)!important; +} +.cxd-Page-body,.cxd-Page-content { + padding:0!important; +} +.rv-shell { + width:min(1760px,100%); + margin:0 auto; + padding:18px 22px 44px; +} +.rv-topbar { + display:flex; + align-items:center; + justify-content:space-between; + gap:18px; + padding:8px 0 18px; +} +.rv-brand { + display:flex; + align-items:center; + gap:12px; +} +.rv-brand-mark { + display:grid; + width:42px; + height:42px; + place-items:center; + border:1px solid #35e6b255; + border-radius:10px; + color:var(--accent); + background:#35e6b20d; + box-shadow:inset 0 0 20px #35e6b20a; + font:800 13px/1 ui-monospace,SFMono-Regular,Consolas,monospace; +} +.rv-eyebrow { + margin:0 0 4px; + color:var(--accent); + font:700 10px/1.2 ui-monospace,SFMono-Regular,Consolas,monospace; + letter-spacing:.15em; + text-transform:uppercase; +} +.rv-brand h1,.rv-hero h2 { + margin:0; + color:#f2f7ff; + font-weight:720; + letter-spacing:-.02em; +} +.rv-brand h1 { + font-size:18px; +} +.rv-top-actions { + display:flex; + align-items:center; + justify-content:flex-end; + gap:10px; +} +.rv-connection,.rv-live-status { + display:inline-flex; + align-items:center; + gap:7px; + min-width:0; + color:var(--muted); + font:700 10px/1.25 ui-monospace,SFMono-Regular,Consolas,monospace; +} +.rv-connection i,.rv-live-status i { + width:7px; + height:7px; + flex:0 0 auto; + border-radius:50%; + background:#8793a3; + box-shadow:0 0 0 3px #8793a312; +} +.rv-connection-ready i,.rv-live-status-moving i,.rv-live-status-ready i { + background:var(--accent); + box-shadow:0 0 0 3px #35e6b21a,0 0 12px #35e6b280; +} +.rv-connection-error i,.rv-live-status-stalled i,.rv-live-status-error i { + background:var(--danger); + box-shadow:0 0 0 3px #ff6b811a,0 0 12px #ff6b8166; +} +.rv-live-status-duplicate i { + background:var(--warn); +} +.rv-hero { + display:grid; + grid-template-columns:minmax(0,1.45fr) minmax(380px,.75fr); + gap:20px; + padding:22px; + border:1px solid var(--line); + border-radius:12px; + background:linear-gradient(135deg,#0c1420ee,#091019ee); + box-shadow:0 16px 50px #00000036; +} +.rv-hero h2 { + margin-top:6px; + font-size:25px; +} +.rv-hero p { + margin:10px 0 0; + color:var(--muted); +} +.rv-hero-metrics .cxd-Grid-col { + padding:4px!important; +} +.rv-hero-metric,.rv-metric,.rv-limit-tile,.rv-observer-metric { + min-width:0; + height:100%; + padding:9px 10px; + border:1px solid var(--line); + border-radius:7px; + background:#090f17; +} +.rv-hero-metric b,.rv-metric b,.rv-observer-metric b { + display:block; + min-width:0; + overflow-wrap:anywhere; + color:var(--accent); + font:700 11px/1.35 ui-monospace,SFMono-Regular,Consolas,monospace; +} +.rv-hero-metric b { + font-size:18px; +} +.rv-hero-metric small,.rv-metric small,.rv-observer-metric small { + display:block; + margin-top:5px; + color:var(--muted); + font-size:9px; + line-height:1.35; +} +.rv-notice { + margin-top:12px; +} +.rv-filter-form { + margin:12px 0 5px; +} +.rv-filter-form .cxd-Form-row { + margin:0!important; +} +.rv-filter-form .cxd-ButtonGroup { + display:flex; + flex-wrap:wrap; + gap:5px; +} +.rv-mode-tabs { + margin-top:12px; +} +.rv-mode-tabs>.cxd-Tabs-links { + display:grid!important; + grid-template-columns:repeat(3,minmax(0,1fr)); + gap:8px; + border:0!important; + background:transparent!important; +} +.rv-mode-tabs>.cxd-Tabs-links>li { + margin:0!important; + border:1px solid var(--line)!important; + border-radius:7px!important; + background:#0a111a!important; +} +.rv-mode-tabs>.cxd-Tabs-links>li>a { + padding:10px 12px!important; + color:var(--muted)!important; + font-weight:700!important; +} +.rv-mode-tabs>.cxd-Tabs-links>li.is-active { + border-color:#35e6b266!important; + background:#35e6b20d!important; +} +.rv-mode-tabs>.cxd-Tabs-links>li.is-active>a { + color:var(--accent)!important; +} +.rv-mode-tabs>.cxd-Tabs-content { + padding:10px 0 0!important; + border:0!important; + background:transparent!important; +} +.rv-page-header { + display:flex; + align-items:flex-end; + justify-content:space-between; + gap:14px; + margin:6px 0 12px; + padding:0 2px; +} +.rv-page-header h2 { + margin:2px 0 0; + color:#f2f7ff; + font-size:18px; +} +.rv-page-header p { + max-width:720px; + margin:0; + color:var(--muted); + text-align:right; +} +.rv-gallery>.cxd-Grid-col { + padding:6px!important; +} +.rv-card { + overflow:hidden; + min-width:0; + height:100%; + margin:0!important; + border:1px solid var(--line)!important; + border-radius:9px!important; + background:var(--panel)!important; + box-shadow:0 12px 32px #0000002b!important; +} +.rv-card>.cxd-Panel-body { + padding:0!important; + background:transparent!important; +} +.rv-card>.cxd-Panel-footer { + display:flex; + flex-wrap:wrap; + gap:8px; + padding:10px!important; + border-top:1px solid var(--line)!important; + background:#090f17!important; +} +.rv-card-header { + display:flex; + align-items:flex-start; + justify-content:space-between; + gap:12px; + padding:12px 13px 10px; +} +.rv-card-category { + display:block; + margin-bottom:3px; + color:var(--accent2); + font:700 9px/1.2 ui-monospace,SFMono-Regular,Consolas,monospace; + text-transform:uppercase; +} +.rv-card-title { + margin:0; + color:#eef5ff; + font-size:15px; +} +.rv-card-statuses { + display:flex; + flex-wrap:wrap; + justify-content:flex-end; + gap:8px; +} +.rv-canvas-shell { + position:relative; + width:100%; + height:clamp(300px,34vw,520px); + min-height:0; + overflow:hidden; + outline:none; + border-block:1px solid var(--line); + background:#04070b; +} +.rv-canvas-shell:focus-within { + box-shadow:inset 0 0 0 1px #35e6b25c; +} +.rv-canvas-shell canvas { + display:block; + width:100%; + height:100%; + background:#07111f; + image-rendering:auto; +} +.rv-canvas-hint { + position:absolute; + right:8px; + bottom:7px; + padding:3px 6px; + border:1px solid #ffffff14; + border-radius:4px; + color:#9cabc0; + background:#05090dc7; + pointer-events:none; + font:9px/1.25 ui-monospace,SFMono-Regular,Consolas,monospace; +} +.rv-canvas-loading { + position:absolute; + inset:0; + display:grid; + place-items:center; + color:var(--muted); + background:#05090de6; + pointer-events:none; +} +.rv-canvas-ready .rv-canvas-loading { + display:none; +} +.rv-metrics-grid,.rv-limit-grid,.rv-observer-grid,.rv-client-grid { + padding:6px; + border-bottom:1px solid var(--line); + background:#070c12; +} +.rv-metrics-grid .cxd-Grid-col,.rv-limit-grid .cxd-Grid-col,.rv-observer-grid .cxd-Grid-col,.rv-client-grid .cxd-Grid-col { + padding:3px!important; +} +.rv-limit-tile { + color:var(--muted); + font:700 9px/1.4 ui-monospace,SFMono-Regular,Consolas,monospace; +} +.rv-limit-tile b { + display:block; + margin-top:3px; + overflow-wrap:anywhere; + color:inherit; +} +.rv-limit-tile[data-active="true"] { + border-color:#ffb84d66; + color:var(--warn); + background:#ff9d170c; +} +.rv-observer-title { + display:flex; + align-items:center; + justify-content:space-between; + gap:12px; + padding:7px 10px; + border-bottom:1px solid var(--line); + color:#9ab1cc; + background:#07101a; + font:700 9px/1.4 ui-monospace,SFMono-Regular,Consolas,monospace; +} +.rv-observer-title b { + color:var(--accent); +} +.rv-card-meta { + display:grid; + grid-template-columns:repeat(4,minmax(0,1fr)); + gap:1px; + border-top:1px solid var(--line); + background:var(--line); +} +.rv-card-meta div { + min-width:0; + padding:8px 10px; + background:#0a1018; +} +.rv-card-meta b,.rv-card-meta code { + display:block; + min-width:0; + overflow-wrap:anywhere; + color:#c8d7ea; + font-size:10px; +} +.rv-card-meta small { + display:block; + margin-top:3px; + color:var(--muted); + font-size:8px; +} +.rv-menu-host { + position:fixed; + z-index:10000; + inset:0; + padding:12px; + background:#00000078; + backdrop-filter:blur(3px); +} +.rv-menu-host[hidden] { + display:none; +} +.rv-menu-root { + width:min(760px,calc(100vw - 24px)); + max-height:calc(100vh - 24px); + margin-left:auto; + overflow:auto; + border:1px solid var(--line2); + border-radius:10px; + background:#0a1018; + box-shadow:0 24px 80px #00000099; +} +.rv-menu-root .cxd-Page,.rv-menu-root .cxd-Page-body,.rv-menu-root .cxd-Page-content { + min-height:0!important; +} +.rv-menu-shell { + padding:12px; +} +.rv-menu-header { + display:flex; + align-items:flex-start; + justify-content:space-between; + gap:12px; + padding-bottom:10px; + border-bottom:1px solid var(--line); +} +.rv-menu-header h2 { + margin:2px 0 4px; + color:#f2f7ff; + font-size:18px; +} +.rv-menu-header p { + margin:0; + color:var(--muted); +} +.rv-menu-tabs>.cxd-Tabs-links { + margin-top:10px!important; + border-bottom-color:var(--line)!important; +} +.rv-menu-tabs>.cxd-Tabs-content { + padding:10px 0 0!important; + border:0!important; + background:transparent!important; +} +.rv-control-panel { + margin:0 0 8px!important; + border:1px solid var(--line)!important; + background:#0b121b!important; +} +.rv-control-panel>.cxd-Panel-heading { + border-bottom:1px solid var(--line)!important; + background:#0d1621!important; + color:var(--accent2)!important; + font-weight:700!important; +} +.rv-control-panel>.cxd-Panel-body { + padding:8px 10px!important; + background:transparent!important; +} +.rv-control-form { + margin:0!important; + padding:4px 0!important; + border-bottom:1px solid #182331; +} +.rv-control-form:last-child { + border-bottom:0; +} +.rv-control-form .cxd-Form-row { + margin:0!important; +} +.rv-action-form { + margin:0!important; + padding:10px 0!important; + border-bottom:1px solid #182331; +} +.rv-action-form:last-child { + border-bottom:0; +} +.rv-action-head { + display:flex; + align-items:flex-start; + justify-content:space-between; + gap:12px; + margin-bottom:8px; +} +.rv-action-head>div { + min-width:0; +} +.rv-action-head b { + display:block; + color:#edf7ff; + font-size:12px; +} +.rv-action-head code { + display:block; + margin-top:3px; + color:var(--accent); + font:9px/1.35 ui-monospace,monospace; + overflow-wrap:anywhere; +} +.rv-action-head small { + max-width:45%; + color:var(--muted); + font-size:9px; + line-height:1.4; +} +.rv-json .cxd-JSONField { + border-color:var(--line)!important; + background:#070c12!important; + color:#cddbf0!important; +} +.cxd-Panel,.cxd-Card,.cxd-Form,.cxd-Tabs-content,.cxd-Drawer-content,.cxd-Modal-content { + color:var(--text)!important; +} +.cxd-Button { + border-color:var(--line2)!important; + background:#111b27!important; + color:#c9d7ea!important; + box-shadow:none!important; +} +.cxd-Button:hover,.cxd-Button.is-active,.cxd-ButtonGroup .cxd-Button.is-active { + border-color:#35e6b266!important; + background:#35e6b212!important; + color:var(--accent)!important; +} +.cxd-Button--primary { + border-color:#35e6b25c!important; + background:#12342e!important; + color:#75f6d2!important; +} +.cxd-Form-label,.cxd-Form-itemLabel,.cxd-Form-itemLabel label { + color:#c9d7ea!important; +} +.cxd-Form-description,.cxd-Form-help,.cxd-Form-itemDesc { + color:var(--muted)!important; +} +.cxd-TextControl-input,.cxd-NumberControl-input,.cxd-Select,.cxd-SelectControl,.cxd-ColorPicker,.cxd-InputBox,.cxd-TextControl { + border-color:var(--line2)!important; + background:#070d14!important; + color:var(--text)!important; +} +.cxd-Select-menu,.cxd-PopOver,.cxd-PopOverAble-popover { + border-color:var(--line2)!important; + background:#0d1621!important; + color:var(--text)!important; +} +.cxd-Tabs-links>li>a { + color:var(--muted)!important; +} +.cxd-Tabs-links>li.is-active>a { + color:var(--accent)!important; +} +.cxd-Tabs-links>li.is-active>a:after { + background:var(--accent)!important; +} +.cxd-Alert { + border-color:var(--line2)!important; + background:#0c1420!important; + color:#cbd9eb!important; +} +.cxd-Alert--danger { + border-color:#ff6b8159!important; + background:#271018!important; + color:#ffb2bd!important; +} +.cxd-Switch.is-checked { + background:var(--accent)!important; +} +@media (max-width:1100px) { + .rv-hero { + grid-template-columns:1fr; + } + .rv-card-meta { + grid-template-columns:repeat(2,minmax(0,1fr)); + } +} +@media (max-width:720px) { + .rv-shell { + padding:12px 8px 28px; + } + .rv-topbar,.rv-page-header { + align-items:flex-start; + flex-direction:column; + } + .rv-top-actions { + width:100%; + justify-content:space-between; + } + .rv-page-header p { + text-align:left; + } + .rv-mode-tabs>.cxd-Tabs-links { + grid-template-columns:1fr; + } + .rv-card-meta { + grid-template-columns:1fr; + } + .rv-menu-host { + padding:6px; + } + .rv-menu-root { + width:100%; + max-height:calc(100vh - 12px); + } } -button, input, select { font: inherit; } button { color: inherit; } -h1, h2, h3, p { margin-top: 0; } -.eyebrow { margin: 0 0 5px; color: var(--accent); font: 700 10px/1.2 ui-monospace, monospace; letter-spacing: .15em; text-transform: uppercase; } -.topbar { position: sticky; z-index: 20; top: 0; display: flex; align-items: center; justify-content: space-between; gap: 22px; min-height: 76px; padding: 12px clamp(18px,4vw,56px); border-bottom: 1px solid #ffffff14; background: #07090dea; backdrop-filter: blur(20px); } -.brand, .topbar-actions { display: flex; align-items: center; gap: 14px; } -.brand-mark { display: grid; place-items: center; width: 46px; height: 46px; border-radius: 12px; color: #07110f; background: var(--accent); font: 800 16px/1 ui-monospace, monospace; } -h1 { margin: 0; font-size: clamp(17px,2vw,23px); } -.connection-status, .card-socket { display: inline-flex; align-items: center; gap: 7px; color: var(--muted); font: 600 11px/1 ui-monospace, monospace; } -.connection-status i, .card-socket i { width: 8px; height: 8px; border-radius: 50%; background: var(--warning); box-shadow: 0 0 12px currentColor; } -[data-state="ready"] i { background: var(--accent); } [data-state="error"] i { background: var(--danger); } -.card-health { display:flex; align-items:flex-end; flex-direction:column; gap:7px; } -.motion-status { display:inline-flex; align-items:center; gap:6px; color:var(--muted); font:600 9px/1 ui-monospace,monospace; } -.motion-status i { width:6px; height:6px; border-radius:50%; background:var(--warning); } -.motion-status[data-state="moving"] { color:var(--accent); }.motion-status[data-state="moving"] i { background:var(--accent); box-shadow:0 0 10px var(--accent); animation:motion-pulse .9s ease-in-out infinite alternate; } -.motion-status[data-state="duplicate"] { color:var(--warning); }.motion-status[data-state="duplicate"] i { background:var(--warning); } -.motion-status[data-state="stalled"] { color:var(--danger); }.motion-status[data-state="stalled"] i { background:var(--danger); } -@keyframes motion-pulse { to { opacity:.35; transform:scale(.72); } } -.quiet-button, .open-menu, .frame-button, .icon-button, .menu-tabs button, .category-filter button, .mode-tabs button, .action-button { border: 1px solid var(--line); border-radius: 8px; background: var(--panel2); cursor: pointer; transition: .16s border-color,.16s background,.16s transform; } -button:hover { border-color: var(--accent); } button:active { transform: translateY(1px); } -.quiet-button { padding: 9px 13px; font-size: 12px; } -.hero { display: grid; grid-template-columns: minmax(300px,1fr) minmax(430px,.9fr); align-items: end; gap: 40px; max-width: 1680px; margin: auto; padding: clamp(32px,5vw,64px) clamp(18px,4vw,56px) 28px; } -.hero h2 { margin-bottom: 10px; font-size: clamp(27px,4vw,47px); letter-spacing: -.045em; } -.hero > div > p:last-child { margin: 0; max-width: 750px; color: var(--muted); line-height: 1.7; } -.metrics { display: grid; grid-template-columns: repeat(4,1fr); gap: 1px; margin: 0; border: 1px solid var(--line); background: var(--line); } -.metrics div { padding: 17px; background: var(--panel); } .metrics dt { color: var(--text); font: 750 clamp(20px,3vw,33px)/1 ui-monospace,monospace; } .metrics dd { margin: 8px 0 0; color: var(--muted); font-size: 10px; } -.mode-tabs { display: grid; grid-template-columns: repeat(3,1fr); gap: 10px; max-width: 1680px; margin: auto; padding: 0 clamp(18px,4vw,56px) 18px; } -.mode-tabs button { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; color: var(--muted); text-align: left; } -.mode-tabs button span { color: var(--text); font-weight: 750; } .mode-tabs button code { font-size: 10px; } -.mode-tabs button.active { border-color: var(--accent); background: #123029; box-shadow: inset 0 0 0 1px #45ddbe35; } -.category-filter { display: flex; gap: 7px; overflow-x: auto; max-width: 1680px; margin: auto; padding: 0 clamp(18px,4vw,56px) 20px; } -.category-filter button { flex: 0 0 auto; padding: 8px 11px; color: var(--muted); font-size: 11px; } -.category-filter button.active { color: #06110e; border-color: var(--accent); background: var(--accent); } -.pages, .mode-page { max-width: 1680px; margin: auto; } -.mode-page[hidden] { display: none; } -.page-header { display: flex; align-items: end; justify-content: space-between; gap: 24px; padding: 3px clamp(18px,4vw,56px) 18px; } -.page-header h2 { margin: 0; font-size: 23px; }.page-strategy { margin-bottom: 5px; color: var(--accent); font: 700 10px/1 ui-monospace,monospace; }.page-description { max-width: 650px; margin: 0; color: var(--muted); font-size: 12px; line-height: 1.6; text-align: right; } -.gallery { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 18px; padding: 0 clamp(18px,4vw,56px) 70px; } -.loading-card { display: grid; place-items: center; gap: 12px; min-height: 360px; margin: 0 clamp(18px,4vw,56px); border: 1px dashed var(--strong); color: var(--muted); } -.loader { width: 18px; height: 18px; border: 2px solid var(--strong); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } -.plot-card { min-width: 0; overflow: hidden; border: 1px solid var(--line); border-radius: 13px; background: linear-gradient(180deg,#ffffff08,transparent 30%),var(--panel); box-shadow: 0 20px 55px #0000002e; } -.plot-card[data-mode="manual"] { border-top-color: #ffd166aa; }.plot-card[data-mode="low_latency"] { border-top-color: #45ddbeaa; }.plot-card[data-mode="playback"] { border-top-color: #6aa9ffaa; }.plot-card[hidden] { display:none; } -.card-header, .card-footer { display:flex; align-items:center; justify-content:space-between; gap:15px; padding:13px 15px; }.card-header { border-bottom:1px solid var(--line); }.card-category { display:block; margin-bottom:4px; color:var(--blue); font:700 9px/1 ui-monospace,monospace; letter-spacing:.1em; }.card-title { margin:0; font-size:17px; } -.canvas-shell { position:relative; height:clamp(245px,27vw,350px); outline:none; background:#060a11; cursor:crosshair; }.canvas-shell:focus-visible { box-shadow:inset 0 0 0 2px var(--accent); }.canvas-shell canvas { display:block; width:100%; height:100%; }.canvas-hint { position:absolute; right:9px; bottom:8px; padding:5px 7px; border:1px solid #ffffff1e; border-radius:5px; color:#ffffffa8; background:#04070bc9; font:9px/1 ui-monospace,monospace; pointer-events:none; }.canvas-loading { position:absolute; inset:0; display:grid; place-content:center; justify-items:center; gap:9px; color:var(--muted); background:#080d15; }.plot-card[data-ready="true"] .canvas-loading { display:none; } -.performance-strip { display:grid; grid-template-columns:repeat(auto-fit,minmax(105px,1fr)); gap:1px; margin:0; border-block:1px solid var(--line); background:var(--line); }.performance-strip div { min-width:0; padding:9px 8px; background:#0c1118; }.performance-strip dt { min-width:0; overflow:visible; color:var(--accent); font:700 11px/1.35 ui-monospace,monospace; text-overflow:clip; white-space:normal; overflow-wrap:anywhere; word-break:break-word; }.performance-strip .perf-event,.performance-strip .perf-limit { font-size:10px; }.performance-strip .perf-limit-cell { grid-column:auto; }.performance-strip dd { margin:5px 0 0; color:var(--muted); font-size:8px; line-height:1.35; white-space:normal; overflow-wrap:anywhere; word-break:break-word; } -.limit-flags { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:7px; padding:7px 10px; border-bottom:1px solid var(--line); color:var(--muted); background:#090e15; font:9px/1.35 ui-monospace,monospace; }.limit-flags strong { grid-column:1/-1; color:var(--text); }.limit-flags span { min-width:0; padding:6px 7px; border:1px solid var(--line); border-radius:4px; line-height:1.45; white-space:normal; overflow-wrap:anywhere; word-break:break-word; }.limit-flags span[data-active="true"] { border-color:#ffb84d88; color:#ffcf82; background:#ff9d1712; }.limit-flags b { color:inherit; white-space:normal; overflow-wrap:anywhere; }.plot-card:not([data-mode="low_latency"]) .limit-flags { display:none; } -.kernel-observer-panel { border-bottom:1px solid var(--line); background:#080d14; font-family:ui-monospace,monospace; } -.kernel-observer-panel > header { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:9px 11px; border-bottom:1px solid var(--line); color:var(--muted); font-size:8px; flex-wrap:wrap; } -.kernel-observer-panel > header div:first-child { display:flex; align-items:center; gap:9px; min-width:0; flex-wrap:wrap; }.kernel-observer-panel > header span { color:var(--accent); letter-spacing:.08em; overflow-wrap:anywhere; }.kernel-observer-panel > header strong,.kernel-observer-panel > header b { color:var(--text); font-weight:700; overflow-wrap:anywhere; } -.observer-counters,.latency-summary,.client-summary,.latency-details { display:grid; gap:1px; background:var(--line); } -.observer-counters { grid-template-columns:repeat(auto-fit,minmax(90px,1fr)); }.latency-summary,.client-summary { grid-template-columns:repeat(auto-fit,minmax(105px,1fr)); border-top:1px solid var(--line); }.latency-details { grid-template-columns:repeat(auto-fit,minmax(145px,1fr)); border-top:1px solid var(--line); } -.observer-counters div,.latency-summary div,.client-summary div,.latency-details div { min-width:0; padding:8px 7px; background:#0b1119; } -.kernel-observer-panel small { display:block; min-width:0; overflow:visible; margin-bottom:5px; color:var(--muted); font-size:7px; line-height:1.35; text-overflow:clip; white-space:normal; overflow-wrap:anywhere; word-break:break-word; } -.kernel-observer-panel b { display:block; min-width:0; overflow:visible; color:#b7c7dc; font-size:9px; line-height:1.35; text-overflow:clip; white-space:normal; overflow-wrap:anywhere; word-break:break-word; } -.latency-summary b { color:var(--accent); font-size:10px; }.latency-summary .critical { background:#10201e; }.latency-summary .critical b { color:#72f3d9; } -.plot-card:not([data-mode="low_latency"]) .kernel-observer-panel { display:none; } -.card-meta { display:grid; grid-template-columns:1fr auto; gap:17px; padding:13px 15px; }.card-description { margin:0; color:var(--muted); font-size:11px; line-height:1.55; }.card-meta dl { display:flex; margin:0; }.card-meta dl div { min-width:52px; padding-left:11px; border-left:1px solid var(--line); }.card-meta dt { font:700 14px/1 ui-monospace,monospace; }.card-meta dd { margin:5px 0 0; color:var(--muted); font-size:9px; } -.card-footer { border-top:1px solid var(--line); background:#0000001e; }.card-footer code { color:var(--accent); font-size:10px; }.card-footer > div { display:flex; gap:7px; }.open-menu,.frame-button { padding:7px 9px; color:var(--muted); font-size:10px; }.frame-button { color:var(--text); } -.context-menu { position:fixed; z-index:100; width:min(570px,calc(100vw - 24px)); max-height:min(840px,calc(100vh - 24px)); overflow:hidden; border:1px solid var(--strong); border-radius:12px; background:#0f131afa; box-shadow:0 30px 90px #0000009e; backdrop-filter:blur(22px); }.context-menu[hidden] { display:none; } -.menu-header { display:flex; justify-content:space-between; gap:18px; padding:16px 18px 13px; border-bottom:1px solid var(--line); }.menu-header h2 { margin-bottom:6px; font-size:19px; }.menu-header p:last-child { margin:0; color:var(--muted); font-size:10px; line-height:1.5; }.icon-button { flex:0 0 auto; width:31px; height:31px; font-size:20px; } -.menu-tabs { display:grid; grid-template-columns:repeat(4,1fr); padding:8px; border-bottom:1px solid var(--line); }.menu-tabs button { padding:8px 4px; border-color:transparent; color:var(--muted); background:transparent; font-size:10px; }.menu-tabs button.active { color:var(--text); border-color:var(--strong); background:var(--panel2); } -.menu-body { max-height:calc(min(840px,100vh - 24px) - 190px); overflow:auto; padding:12px; overscroll-behavior:contain; }.control-group + .control-group { margin-top:15px; }.control-group h3 { position:sticky; z-index:1; top:-12px; margin:0 -2px 6px; padding:9px 4px 7px; color:var(--accent); background:#0f131af5; font:700 9px/1 ui-monospace,monospace; letter-spacing:.1em; } -.control-row { display:grid; grid-template-columns:minmax(145px,1fr) minmax(130px,.75fr); gap:13px; align-items:center; min-height:49px; padding:8px 9px; border-top:1px solid #ffffff0e; }.control-copy label,.action-copy strong { display:block; margin-bottom:4px; font-size:11px; }.control-copy code,.action-copy code { display:block; overflow:hidden; color:var(--muted); font-size:9px; text-overflow:ellipsis; white-space:nowrap; }.control-input { width:100%; min-width:0; padding:7px 8px; border:1px solid var(--strong); border-radius:6px; outline:none; color:var(--text); background:#090d13; font-size:10px; }.control-input:focus { border-color:var(--accent); }input[type="color"].control-input { height:34px; padding:3px; }input[type="checkbox"].control-input { justify-self:end; width:38px; height:20px; accent-color:var(--accent); } -.action-row { display:flex; align-items:center; justify-content:space-between; gap:13px; padding:10px 9px; border-top:1px solid #ffffff0e; }.action-copy { min-width:0; }.action-controls { display:flex; gap:7px; align-items:center; }.action-controls input { width:105px; }.action-button { padding:7px 10px; color:#07110f; border-color:var(--accent); background:var(--accent); font-size:10px; font-weight:700; } -.telemetry-grid { display:grid; grid-template-columns:minmax(165px,.7fr) 1fr; margin:0; border:1px solid var(--line); }.telemetry-grid dt,.telemetry-grid dd { margin:0; padding:8px 10px; border-bottom:1px solid var(--line); font:10px/1.4 ui-monospace,monospace; overflow-wrap:anywhere; }.telemetry-grid dt { color:var(--muted); background:#ffffff06; }.telemetry-grid dd { color:var(--accent); } -.menu-footer { display:flex; justify-content:space-between; gap:14px; padding:10px 14px; border-top:1px solid var(--line); color:var(--muted); font-size:9px; }.menu-footer code { color:var(--accent); } -.toast { position:fixed; z-index:140; left:50%; bottom:22px; max-width:min(560px,calc(100vw - 30px)); padding:11px 15px; border:1px solid var(--strong); border-radius:8px; background:var(--panel2); box-shadow:0 18px 50px #00000073; transform:translateX(-50%); font-size:11px; }.toast[data-error="true"] { border-color:var(--danger); } -@media (max-width:980px) { .hero { grid-template-columns:1fr; }.gallery { grid-template-columns:1fr; }.mode-tabs button { flex-direction:column; align-items:flex-start; }.page-header { align-items:flex-start; flex-direction:column; }.page-description { text-align:left; } } -@media (max-width:650px) { .topbar { align-items:flex-start; }.topbar-actions { align-items:flex-end; flex-direction:column; }.connection-status span { display:none; }.metrics { grid-template-columns:repeat(2,1fr); }.mode-tabs { grid-template-columns:1fr; }.mode-tabs button { flex-direction:row; }.card-meta { grid-template-columns:1fr; }.performance-strip { grid-template-columns:repeat(auto-fit,minmax(92px,1fr)); }.limit-flags { grid-template-columns:1fr; }.observer-counters { grid-template-columns:repeat(auto-fit,minmax(80px,1fr)); }.latency-summary,.client-summary,.latency-details { grid-template-columns:repeat(auto-fit,minmax(100px,1fr)); }.context-menu { inset:auto 6px 6px!important; width:auto; max-height:calc(100vh - 12px); } }