diff --git a/AGENTS.md b/AGENTS.md index d828426..6c1a5d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -193,16 +193,23 @@ lib.rs # re-exports hidrpc + jsonrpc ``` main.rs # WASM entry: panic hook + tracing-wasm + mount Leptos App -├── app.rs # root component, layout, top-level signals/routing +├── app.rs # root component: connection state, handlers, page content inside the shell +├── shell/ # Claude Desktop frame: sidebar.rs (52px rail / 256px overlay), titlebar.rs +├── ui/ # design-system components: icon.rs, button.rs (Button, IconButton), field.rs (Field, Callout) +├── i18n.rs # zh/en copy via `i18n.t("中文", "English")`, zh by default +├── theme.rs # light/dark toggle (boot script in index.html applies it before paint) ├── rpc.rs # JSON-RPC over WebRTC data channel ├── net/ # peer.rs (WebRTC PeerConnection), rest.rs (HTTP), upload.rs (virtual media) ├── hid/ # keymap.rs (re-exports rkvm_proto::keymap) + mod.rs (keyboard/mouse → hidrpc via rkvm-proto) ├── terminal.rs # vt100 terminal view ├── ocr.rs # screen OCR via Tesseract.js (JS interop, CDN-loaded) -├── settings.rs / settings_advanced.rs # settings drawer +├── settings.rs / settings_advanced.rs # settings dialog sections +├── settings_nav.rs # settings dialog pages, search └── util.rs ``` +Styles live in `crates/rkvm-web/style/` (`tokens.css` → `base.css` → `components.css` → `shell.css` → `panels.css`, linked from `index.html` with `data-trunk rel="css"`). The design rules (tokens, layout, components, copy) are in [`docs/design-system.md`](docs/design-system.md); follow it for any UI change. + ## Frontend The SPA is the in-tree **`crates/rkvm-web`** crate (Leptos 0.8, CSR / `wasm32-unknown-unknown`). It is **excluded from the workspace** — it has its own `Cargo.lock`, `rust-toolchain.toml` (`stable` + `wasm32-unknown-unknown`), `Trunk.toml`, and `deny.toml`. diff --git a/crates/rkvm-web/Cargo.toml b/crates/rkvm-web/Cargo.toml index c8ac344..ba13f6c 100644 --- a/crates/rkvm-web/Cargo.toml +++ b/crates/rkvm-web/Cargo.toml @@ -30,6 +30,7 @@ web-sys = { version = "0.3", features = [ "HtmlVideoElement", "HtmlMediaElement", "MediaStream", + "MediaQueryList", "RtcPeerConnection", "RtcConfiguration", "RtcIceServer", @@ -70,6 +71,7 @@ web-sys = { version = "0.3", features = [ "Navigator", "WebSocket", "Node", + "Storage", ] } [profile.release] diff --git a/crates/rkvm-web/index.html b/crates/rkvm-web/index.html index 5916db7..ccc2cab 100644 --- a/crates/rkvm-web/index.html +++ b/crates/rkvm-web/index.html @@ -4,7 +4,7 @@ rustkvm - + - + + + + + + diff --git a/crates/rkvm-web/src/app.rs b/crates/rkvm-web/src/app.rs index bd1313c..85127e2 100644 --- a/crates/rkvm-web/src/app.rs +++ b/crates/rkvm-web/src/app.rs @@ -11,6 +11,8 @@ use web_sys::HtmlVideoElement; use crate::hid::{MouseMode, Session}; use crate::net::{peer, rest}; use crate::rpc::RpcClient; +use crate::shell::{Sidebar, Titlebar}; +use crate::ui::{Button, Callout, Field, Icon, IconButton, IconName, Variant}; #[derive(Clone, Copy, Default, PartialEq)] struct VideoInfo { @@ -28,6 +30,8 @@ struct Leds { #[component] pub fn App() -> impl IntoView { + let i = crate::i18n::provide(); + let theme = crate::theme::provide(); let video_ref = NodeRef::::new(); let checking = RwSignal::new(true); @@ -37,6 +41,7 @@ pub fn App() -> impl IntoView { let nopw_dismissed = RwSignal::new(false); let conn_state = RwSignal::new("idle".to_string()); let error = RwSignal::new(Option::::None); + crate::util::set_error_sink(error); let password = RwSignal::new(String::new()); let setup_mode = RwSignal::new("noPassword".to_string()); @@ -50,7 +55,11 @@ pub fn App() -> impl IntoView { let show_settings = RwSignal::new(false); let show_terminal = RwSignal::new(false); - let toolbar_open = RwSignal::new(false); + let sidebar_open = RwSignal::new(false); + // Focus mode hides the sidebar and titlebar; `peek` slides the titlebar + // back in as an overlay while the pointer is on it. + let focus_mode = RwSignal::new(false); + let peek = RwSignal::new(false); let ocr_text = RwSignal::new(Option::::None); let rpc_store = StoredValue::new_local(None::>); @@ -246,29 +255,17 @@ pub fn App() -> impl IntoView { mouse_mode.get_value().set(true); } Ok(_) => error.set(Some( - "Relative mouse is disabled: enable it in ⚙ Settings → USB devices first" - .to_string(), + i.t_now( + "相对鼠标未启用:请先在 设置 → USB 设备 中打开", + "Relative mouse is disabled: enable it in Settings → USB devices first", + ) + .to_string(), )), Err(e) => error.set(Some(e.to_string())), } }); }; - let do_reboot = move |_| { - let confirmed = web_sys::window() - .and_then(|w| w.confirm_with_message("Reboot the device?").ok()) - .unwrap_or(false); - if !confirmed { - return; - } - let Some(client) = rpc_store.get_value() else { return }; - spawn_local(async move { - if let Err(e) = client.call("reboot", json!({ "force": false })).await { - error.set(Some(e.to_string())); - } - }); - }; - let send_cad = move |_| { if let Some(p) = peer_store.get_value() { crate::hid::send_key_combo(&p, 0x05, &[0x4c]); @@ -285,30 +282,41 @@ pub fn App() -> impl IntoView { let do_ocr = move |_| { let Some(video) = video_store.get_value() else { return }; - ocr_text.set(Some("Reading text from the screen…".to_string())); + ocr_text + .set(Some(i.t_now("正在识别屏幕文字…", "Reading text from the screen…").to_string())); spawn_local(async move { match crate::ocr::recognize(&video).await { - Ok(text) if text.trim().is_empty() => { - ocr_text.set(Some("No text found on the screen.".to_string())) - } + Ok(text) if text.trim().is_empty() => ocr_text.set(Some( + i.t_now("屏幕上没有识别到文字。", "No text found on the screen.").to_string(), + )), Ok(text) => ocr_text.set(Some(text)), - Err(e) => ocr_text.set(Some(format!("Could not read text: {e}"))), + Err(e) => ocr_text.set(Some(format!( + "{}{e}", + i.t_now("无法识别文字:", "Could not read text: ") + ))), } }); }; let ocr_copy_msg = RwSignal::new(String::new()); + let ocr_ref = NodeRef::::new(); + // Take focus when the dialog opens so Esc reaches it, not the host. + Effect::new(move |_| { + if let Some(el) = ocr_ref.get() { + let _ = el.focus(); + } + }); let copy_ocr = move |_| { let text = ocr_text.get_untracked().unwrap_or_default(); spawn_local(async move { ocr_copy_msg.set(match crate::util::copy_text(&text).await { - Ok(()) => "Copied".to_string(), + Ok(()) => i.t_now("已复制", "Copied").to_string(), Err(e) => e, }); }); }; - // Toolbar errors are transient: clear each one after a few seconds unless a + // Toast errors are transient: clear each one after a few seconds unless a // newer error replaced it. Errors shown on the Connect card stay put. let error_timer = StoredValue::new_local(None::); Effect::new(move |_| { @@ -328,209 +336,371 @@ pub fn App() -> impl IntoView { let hide_timer = StoredValue::new_local(None::); let cancel_hide = move |_| hide_timer.set_value(None); let schedule_hide = move |_| { - hide_timer.set_value(Some(Timeout::new(600, move || toolbar_open.set(false)))); + hide_timer.set_value(Some(Timeout::new(600, move || peek.set(false)))); }; + let connected = Signal::derive(move || conn_state.get() == "connected"); + let status = Signal::derive(move || match conn_state.get().as_str() { + "connected" => (i.t("已连接", "Connected"), "on"), + "connecting" => (i.t("连接中…", "Connecting…"), "warn"), + "lost" => (i.t("连接已断开", "Disconnected"), "warn"), + "error" => (i.t("连接失败", "Connection failed"), ""), + _ => (i.t("未连接", "Not connected"), ""), + }); + let title = Signal::derive(move || { + if connected.get() { i.t("远程桌面", "Remote desktop") } else { "rustkvm" } + }); + let chip = Signal::derive(move || { + connected.get().then(|| { + let v = video_info.get(); + if v.width > 0 { + format!("{}×{} · {:.0} fps", v.width, v.height, v.fps) + } else { + i.t("无信号", "No signal").to_string() + } + }) + }); + let host = StoredValue::new( + web_sys::window().and_then(|w| w.location().host().ok()).unwrap_or_default(), + ); + view! { - - - - - -
- - {move || { - let v = video_info.get(); - if v.width > 0 { - format!("{}×{} @ {:.0}fps", v.width, v.height, v.fps) - } else { - "No signal".to_string() - } - }} - - - "NUM" - "CAPS" - "SCR" - - - - - - - - {move || format!("{:.0}%", quality.get() * 100.0)} +
+ + +
+ - - - - - - - - - - - - - - - + + - {move || error.get().unwrap_or_default()} - -
- - - +
} } @@ -855,7 +1076,7 @@ pub fn Settings( fn send_toggle(rpc: Rpc, method: &'static str, sig: RwSignal, ev: &web_sys::Event) { let on = event_target_checked(ev); sig.set(on); - fire(rpc, method, json!({ "enabled": on })); + fire_revert(rpc, method, json!({ "enabled": on }), move || sig.set(!on)); } #[derive(Clone, Copy, Default, PartialEq)] @@ -867,6 +1088,17 @@ struct UsbDevices { } impl UsbDevices { + /// `device` is the name `setUsbDeviceState` takes. + fn set(&mut self, device: &str, on: bool) { + match device { + "keyboard" => self.keyboard = on, + "absoluteMouse" => self.absolute_mouse = on, + "relativeMouse" => self.relative_mouse = on, + "massStorage" => self.mass_storage = on, + _ => {} + } + } + fn from_value(v: &Value) -> Self { let b = |k: &str| v.get(k).and_then(Value::as_bool).unwrap_or(false); Self { @@ -878,8 +1110,6 @@ impl UsbDevices { } } -const NOTHING_MOUNTED: &str = "Nothing mounted"; - fn or_dash(s: String) -> String { if s.is_empty() { "—".to_string() } else { s } } @@ -887,16 +1117,19 @@ fn or_dash(s: String) -> String { /// Rows for the Network section: address, link state, gateway and DNS. /// The device can have several interfaces, so when this page was loaded from a /// different address than the one reported, say so instead of looking wrong. -fn describe_network(v: &Value) -> Vec<(&'static str, String)> { +fn describe_network(v: &Value, i: I18n) -> Vec<(&'static str, String)> { let ip = json_str(v, "ip"); let online = v.get("online").and_then(Value::as_bool).unwrap_or(false); let mut rows = vec![ - ("Address", or_dash(ip.clone())), - ("Link", if online { "Online" } else { "Offline" }.to_string()), + (i.t("地址", "Address"), or_dash(ip.clone())), + ( + i.t("链路", "Link"), + if online { i.t("在线", "Online") } else { i.t("离线", "Offline") }.to_string(), + ), ]; let gateway = json_str(v, "gateway"); if !gateway.is_empty() { - rows.push(("Gateway", gateway)); + rows.push((i.t("网关", "Gateway"), gateway)); } let dns: Vec<&str> = v .get("dns") @@ -909,12 +1142,18 @@ fn describe_network(v: &Value) -> Vec<(&'static str, String)> { let page_host = web_sys::window().and_then(|w| w.location().hostname().ok()).unwrap_or_default(); if !ip.is_empty() && !page_host.is_empty() && page_host != ip { - rows.push(("This page", format!("opened via {page_host}"))); + rows.push(( + i.t("本页面", "This page"), + match i.lang() { + crate::i18n::Lang::Zh => format!("通过 {page_host} 打开"), + crate::i18n::Lang::En => format!("opened via {page_host}"), + }, + )); } rows } -fn describe_mount(v: &Value) -> String { +fn describe_mount(v: &Value, i: I18n) -> String { let name = json_str(v, "filename"); let url = json_str(v, "url"); let what = if !name.is_empty() { @@ -922,11 +1161,11 @@ fn describe_mount(v: &Value) -> String { } else if !url.is_empty() { url } else { - return NOTHING_MOUNTED.to_string(); + return i.t("未挂载任何镜像", "Nothing mounted").to_string(); }; match json_str(v, "mode").as_str() { - "CDROM" => format!("Mounted as CD-ROM: {what}"), - "DISK" => format!("Mounted as USB disk: {what}"), - _ => format!("Mounted: {what}"), + "CDROM" => format!("{}{what}", i.t("已作为光驱挂载:", "Mounted as CD-ROM: ")), + "DISK" => format!("{}{what}", i.t("已作为 U 盘挂载:", "Mounted as USB disk: ")), + _ => format!("{}{what}", i.t("已挂载:", "Mounted: ")), } } diff --git a/crates/rkvm-web/src/settings_advanced.rs b/crates/rkvm-web/src/settings_advanced.rs index 8128c56..257a6d2 100644 --- a/crates/rkvm-web/src/settings_advanced.rs +++ b/crates/rkvm-web/src/settings_advanced.rs @@ -4,19 +4,26 @@ use leptos::prelude::*; use leptos::task::spawn_local; use serde_json::{Value, json}; +use crate::i18n::{tr, use_i18n}; use crate::rpc::RpcClient; -use crate::util::{Rpc, as_string, fire, fire_status, json_str, json_string_list}; +use crate::settings_nav::SettingsNav; +use crate::util::{Rpc, as_string, fire, fire_revert, fire_status, json_str, json_string_list}; /// Extensions the device knows how to mount (`setActiveExtension`). -const EXTENSIONS: &[(&str, &str)] = - &[("", "None"), ("atx-power", "ATX power board"), ("dc-power", "DC power board")]; +const EXTENSIONS: &[(&str, &str, &str)] = &[ + ("", "无", "None"), + ("atx-power", "ATX 电源板", "ATX power board"), + ("dc-power", "DC 电源板", "DC power board"), +]; #[component] -pub fn AdvancedSettings(open: RwSignal, rpc: Rpc) -> impl IntoView { +pub fn AdvancedSettings(open: RwSignal, rpc: Rpc, nav: SettingsNav) -> impl IntoView { + let i = use_i18n(); let dc_enabled = RwSignal::new(false); let dc_restore = RwSignal::new(String::new()); let dc_reading = RwSignal::new(String::new()); - let atx_state = RwSignal::new(String::new()); + // (power LED, HDD LED), or the error from the last read. + let atx_state = RwSignal::new(Ok::<(bool, bool), String>((false, false))); // "dc-power", "atx-power" or "" (no power extension mounted). let power_ext = RwSignal::new(String::new()); let cloud = RwSignal::new(CloudState::default()); @@ -66,13 +73,9 @@ pub fn AdvancedSettings(open: RwSignal, rpc: Rpc) -> impl IntoView { "atx-power" => match c.call("getATXState", Value::Null).await { Ok(v) => { let on = |k: &str| v.get(k).and_then(Value::as_bool).unwrap_or(false); - atx_state.set(format!( - "Power LED {} · HDD LED {}", - if on("power") { "on" } else { "off" }, - if on("hdd") { "on" } else { "off" } - )); + atx_state.set(Ok((on("power"), on("hdd")))); } - Err(e) => atx_state.set(format!("State unavailable: {e}")), + Err(e) => atx_state.set(Err(e.to_string())), }, _ => {} } @@ -124,11 +127,16 @@ pub fn AdvancedSettings(open: RwSignal, rpc: Rpc) -> impl IntoView { let toggle_dc = move |ev: web_sys::Event| { let on = event_target_checked(&ev); dc_enabled.set(on); - fire(rpc, "setDCPowerState", json!({ "enabled": on })); + fire_revert(rpc, "setDCPowerState", json!({ "enabled": on }), move || dc_enabled.set(!on)); }; let set_restore = move |ev: web_sys::Event| { - let s: i32 = event_target_value(&ev).parse().unwrap_or(0); - fire(rpc, "setDCRestoreState", json!({ "state": s })); + let value = event_target_value(&ev); + let s: i32 = value.parse().unwrap_or(0); + let before = dc_restore.get_untracked(); + dc_restore.set(value); + fire_revert(rpc, "setDCRestoreState", json!({ "state": s }), move || { + dc_restore.set(before) + }); }; let atx = move |action: &'static str| fire(rpc, "setATXPowerAction", json!({ "action": action })); @@ -136,33 +144,37 @@ pub fn AdvancedSettings(open: RwSignal, rpc: Rpc) -> impl IntoView { let save_cloud_url = move |_| { let Some(c) = rpc.get_value() else { return }; let (api, app) = (cloud_api.get().trim().to_string(), cloud_app.get().trim().to_string()); - cloud_msg.set("Saving…".to_string()); + cloud_msg.set(tr("正在保存…", "Saving…").to_string()); spawn_local(async move { match c.call("setCloudUrl", json!({ "apiUrl": api, "appUrl": app })).await { Ok(_) => { - cloud_msg.set("Cloud URLs saved".to_string()); + cloud_msg.set(tr("云服务地址已保存", "Cloud URLs saved").to_string()); if let Ok(v) = c.call("getCloudState", Value::Null).await { cloud.set(CloudState::from_value(&v)); } } - Err(e) => cloud_msg.set(format!("Failed: {e}")), + Err(e) => cloud_msg.set(format!("{}{e}", tr("失败:", "Failed: "))), } }); }; let deregister = move |_| { - if !confirm("Remove this device from the cloud account?") { + if !confirm(tr( + "要从云账号中移除这台设备吗?", + "Remove this device from the cloud account?", + )) { return; } let Some(c) = rpc.get_value() else { return }; spawn_local(async move { match c.call("deregisterDevice", Value::Null).await { Ok(_) => { - cloud_msg.set("Device removed from the cloud".to_string()); + cloud_msg + .set(tr("设备已从云服务移除", "Device removed from the cloud").to_string()); if let Ok(v) = c.call("getCloudState", Value::Null).await { cloud.set(CloudState::from_value(&v)); } } - Err(e) => cloud_msg.set(format!("Failed: {e}")), + Err(e) => cloud_msg.set(format!("{}{e}", tr("失败:", "Failed: "))), } }); }; @@ -172,33 +184,35 @@ pub fn AdvancedSettings(open: RwSignal, rpc: Rpc) -> impl IntoView { "setTailscaleControlURL", json!({ "controlURL": ts_url.get().trim() }), ts_msg, - "Control server saved", + tr("控制服务器已保存", "Control server saved"), ) }; let save_ext = move |_| { let Some(c) = rpc.get_value() else { return }; let ext = active_ext.get(); - ext_msg.set("Applying…".to_string()); + ext_msg.set(tr("正在应用…", "Applying…").to_string()); spawn_local(async move { match c.call("setActiveExtension", json!({ "extensionId": ext })).await { Ok(_) => { - ext_msg.set("Extension applied".to_string()); + ext_msg.set(tr("扩展已应用", "Extension applied").to_string()); load_power(c, ext).await; } - Err(e) => ext_msg.set(format!("Failed: {e}")), + Err(e) => ext_msg.set(format!("{}{e}", tr("失败:", "Failed: "))), } }); }; let toggle_dev = move |ev: web_sys::Event| { let on = event_target_checked(&ev); dev_mode.set(on); - fire(rpc, "setDevModeState", json!({ "enabled": on })); + fire_revert(rpc, "setDevModeState", json!({ "enabled": on }), move || dev_mode.set(!on)); }; let toggle_devchan = move |ev: web_sys::Event| { let on = event_target_checked(&ev); dev_channel.set(on); - fire(rpc, "setDevChannelState", json!({ "enabled": on })); + fire_revert(rpc, "setDevChannelState", json!({ "enabled": on }), move || { + dev_channel.set(!on) + }); }; let save_tls = move |_| { let mode = tls_mode.get(); @@ -212,7 +226,7 @@ pub fn AdvancedSettings(open: RwSignal, rpc: Rpc) -> impl IntoView { "setTLSState", json!({ "state": state }), tls_msg, - "HTTPS settings applied", + tr("HTTPS 设置已应用", "HTTPS settings applied"), ); }; @@ -233,7 +247,10 @@ pub fn AdvancedSettings(open: RwSignal, rpc: Rpc) -> impl IntoView { macro_msg.set(String::new()); }; let delete_macro = move |id: String, name: String| { - if !confirm(&format!("Delete macro \"{name}\"?")) { + if !confirm(&match crate::i18n::lang_now() { + crate::i18n::Lang::Zh => format!("删除宏“{name}”?"), + crate::i18n::Lang::En => format!("Delete macro \"{name}\"?"), + }) { return; } let kept: Vec = macros @@ -247,14 +264,14 @@ pub fn AdvancedSettings(open: RwSignal, rpc: Rpc) -> impl IntoView { "setKeyboardMacros", json!({ "macros": kept }), macro_msg, - "Macro deleted", + tr("宏已删除", "Macro deleted"), ); macros.update(|list| list.retain(|m| m.id != id)); }; let save_macro = move |_| { let name = edit_name.get().trim().to_string(); if name.is_empty() { - macro_msg.set("Give the macro a name first".to_string()); + macro_msg.set(tr("请先给宏起个名字", "Give the macro a name first").to_string()); return; } let id = edit_id.get().unwrap_or_else(|| format!("macro-{}", js_now())); @@ -271,7 +288,7 @@ pub fn AdvancedSettings(open: RwSignal, rpc: Rpc) -> impl IntoView { "setKeyboardMacros", json!({ "macros": payload }), macro_msg, - "Macro saved", + tr("宏已保存", "Macro saved"), ); macros.set(list); edit_id.set(None); @@ -280,53 +297,73 @@ pub fn AdvancedSettings(open: RwSignal, rpc: Rpc) -> impl IntoView { }; view! { -
-

"Power"

+
+

{move || i.t("电源", "Power")}

{move || dc_reading.get()}
-
{move || atx_state.get()}
+
+ {move || match atx_state.get() { + Ok((power, hdd)) => { + let led = |on: bool| if on { i.t("亮", "on") } else { i.t("灭", "off") }; + format!( + "{} {} · {} {}", + i.t("电源灯", "Power LED"), + led(power), + i.t("硬盘灯", "HDD LED"), + led(hdd) + ) + } + Err(e) => format!("{}{e}", i.t("无法读取状态:", "State unavailable: ")), + }} +
- - - + + +
- "No power board is active. Pick the ATX or DC power board under Extension to control host power from here." + {move || i.t("没有启用电源扩展板。在「扩展」里选择 ATX 或 DC 电源板后,就能在这里控制主机电源。", "No power board is active. Pick the ATX or DC power board under Extension to control host power from here.")}
-
-

"Cloud"

+
+

{move || i.t("云服务", "Cloud")}

-
"Status"
+
{move || i.t("状态", "Status")}
- {move || if cloud.get().connected { "Connected" } else { "Not connected" }} + {move || { + if cloud.get().connected { + i.t("已连接", "Connected") + } else { + i.t("未连接", "Not connected") + } + }}
-
"API"
+
{move || i.t("API", "API")}
{move || or_dash(cloud.get().api_url)}
-
"App"
+
{move || i.t("应用", "App")}
{move || or_dash(cloud.get().app_url)}
- - + +
{move || cloud_msg.get()}
-
+

"Tailscale"

-
"Change only if you run your own server, such as Headscale."
- +
{move || i.t("仅在你自建服务器(如 Headscale)时修改。", "Change only if you run your own server, such as Headscale.")}
+
{move || ts_msg.get()}
-
-

"Extension"

+
+

{move || i.t("扩展", "Extension")}

-
"A power board adds its controls to the Power section."
- +
{move || i.t("启用电源板后,它的控制项会出现在「电源」里。", "A power board adds its controls to the Power section.")}
+
{move || ext_msg.get()}
-
-

"Developer"

+
+

{move || i.t("开发者", "Developer")}

-
+

"HTTPS"

@@ -426,16 +467,16 @@ pub fn AdvancedSettings(open: RwSignal, rpc: Rpc) -> impl IntoView { on:input=move |ev| tls_key.set(event_target_value(&ev)) > - +
{move || tls_msg.get()}
-
-

"Serial console"

+
+

{move || i.t("串口控制台", "Serial console")}

// `sendCustomCommand` only logs on the device until a serial // adapter is supported, so don't offer a Send button that does // nothing. Earlier history stays visible and can be cleared. -
"Serial console is not supported on this device yet."
+
{move || i.t("这台设备暂不支持串口控制台。", "Serial console is not supported on this device yet.")}
    {move || { @@ -446,20 +487,20 @@ pub fn AdvancedSettings(open: RwSignal, rpc: Rpc) -> impl IntoView { .collect_view() }}
- +
-
-

"Time zone"

+
+

{move || i.t("时区", "Time zone")}

{move || or_dash(timezone.get())}
-
"Changing the device time zone is not supported yet."
+
{move || i.t("暂不支持修改设备时区。", "Changing the device time zone is not supported yet.")}
-
-

"Keyboard macros"

+
+

{move || i.t("键盘宏", "Keyboard macros")}

-
"No macros yet."
+
{move || i.t("还没有宏。", "No macros yet.")}
, rpc: Rpc) -> impl IntoView { - + }>{move || i.t("运行", "Run")} + + }>{move || i.t("删除", "Delete")} } } />
-
-

"Danger zone"

+
+

{move || i.t("危险操作", "Danger zone")}

diff --git a/crates/rkvm-web/src/settings_nav.rs b/crates/rkvm-web/src/settings_nav.rs new file mode 100644 index 0000000..a9939cb --- /dev/null +++ b/crates/rkvm-web/src/settings_nav.rs @@ -0,0 +1,172 @@ +//! Navigation of the settings dialog: the pages in the left column, which +//! section belongs to which page, and the search box. Sections stay mounted +//! (their signals keep loading and saving); the nav only decides which are +//! shown. + +use leptos::prelude::*; + +use crate::i18n::I18n; +use crate::ui::{Icon, IconName}; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Page { + General, + Video, + Input, + Media, + Network, + Security, + Hardware, + System, +} + +const PAGES: &[(Page, &str, &str, IconName)] = &[ + (Page::General, "通用", "General", IconName::Sliders), + (Page::Video, "视频", "Video", IconName::Monitor), + (Page::Input, "键盘与鼠标", "Keyboard & mouse", IconName::Keyboard), + (Page::Media, "虚拟媒体", "Virtual media", IconName::Disc), + (Page::Network, "网络", "Network", IconName::Globe), + (Page::Security, "访问与安全", "Access & security", IconName::Shield), + (Page::Hardware, "硬件", "Hardware", IconName::Cpu), + (Page::System, "系统", "System", IconName::Info), +]; + +/// Every section of the dialog: id, page, and the title search matches. +const SECTIONS: &[(&str, Page, &str, &str)] = &[ + ("appearance", Page::General, "外观", "Appearance"), + ("language", Page::General, "语言", "Language"), + ("codec", Page::Video, "视频编码", "Video codec"), + ("rotation", Page::Video, "屏幕旋转", "Display rotation"), + ("edid", Page::Video, "EDID", "EDID"), + ("layout", Page::Input, "键盘布局", "Keyboard layout"), + ("usb", Page::Input, "USB 设备", "USB devices"), + ("macros", Page::Input, "键盘宏", "Keyboard macros"), + ("media", Page::Media, "虚拟媒体", "Virtual media"), + ("network", Page::Network, "网络", "Network"), + ("ip", Page::Network, "IP 地址", "IP address"), + ("wol", Page::Network, "网络唤醒", "Wake-on-LAN"), + ("mqtt", Page::Network, "MQTT", "MQTT"), + ("cloud", Page::Network, "云服务", "Cloud"), + ("tailscale", Page::Network, "Tailscale", "Tailscale"), + ("password", Page::Security, "密码", "Password"), + ("ssh", Page::Security, "SSH 访问", "SSH access"), + ("https", Page::Security, "HTTPS 证书", "HTTPS certificate"), + ("brightness", Page::Hardware, "设备屏幕亮度", "Device screen brightness"), + ("features", Page::Hardware, "功能", "Features"), + ("power", Page::Hardware, "电源", "Power"), + ("extension", Page::Hardware, "扩展", "Extension"), + ("serial", Page::Hardware, "串口控制台", "Serial console"), + ("reboot", Page::System, "重启设备", "Reboot device"), + ("firmware", Page::System, "固件更新", "Firmware update"), + ("timezone", Page::System, "时区", "Time zone"), + ("log", Page::System, "日志级别", "Log level"), + ("developer", Page::System, "开发者", "Developer"), + ("about", Page::System, "关于", "About"), + ("danger", Page::System, "危险操作", "Danger zone"), +]; + +fn page_info(page: Page) -> (&'static str, &'static str, IconName) { + PAGES.iter().find(|p| p.0 == page).map(|p| (p.1, p.2, p.3)).unwrap_or(("", "", IconName::Info)) +} + +fn hit(q: &str, zh: &str, en: &str) -> bool { + zh.contains(q) || en.to_lowercase().contains(&q.to_lowercase()) +} + +#[derive(Clone, Copy)] +pub struct SettingsNav { + pub page: RwSignal, + pub query: RwSignal, +} + +impl SettingsNav { + pub fn new() -> Self { + Self { page: RwSignal::new(Page::General), query: RwSignal::new(String::new()) } + } + + fn searching(self) -> Option { + let q = self.query.get().trim().to_string(); + (!q.is_empty()).then_some(q) + } + + fn shows(self, id: &str) -> bool { + let Some(&(_, page, zh, en)) = SECTIONS.iter().find(|s| s.0 == id) else { + return false; + }; + match self.searching() { + None => self.page.get() == page, + Some(q) => { + let (pzh, pen, _) = page_info(page); + hit(&q, zh, en) || hit(&q, pzh, pen) + } + } + } + + /// For `class:off=` on a section: true while the section is not shown. + pub fn off(self, id: &'static str) -> impl Fn() -> bool + Copy + 'static { + move || !self.shows(id) + } + + fn no_results(self) -> bool { + self.searching().is_some() && !SECTIONS.iter().any(|s| self.shows(s.0)) + } +} + +/// Left column: search box and the page list. +#[component] +pub fn SettingsSidebar(nav: SettingsNav, i: I18n) -> impl IntoView { + view! { +
+ +
{move || i.t("设置", "Settings")}
+ {PAGES + .iter() + .map(|&(page, zh, en, icon)| { + view! { + + } + }) + .collect_view()} +
+ } +} + +/// Heading of the right column: the page name, or the search state. +#[component] +pub fn SettingsHeading(nav: SettingsNav, i: I18n) -> impl IntoView { + view! { +

+ {move || { + if nav.searching().is_some() { + i.t("搜索结果", "Search results") + } else { + let (zh, en, _) = page_info(nav.page.get()); + i.t(zh, en) + } + }} +

+ +

{move || i.t("没有匹配的设置。", "No settings match.")}

+
+ } +} diff --git a/crates/rkvm-web/src/shell/mod.rs b/crates/rkvm-web/src/shell/mod.rs new file mode 100644 index 0000000..ff0e004 --- /dev/null +++ b/crates/rkvm-web/src/shell/mod.rs @@ -0,0 +1,8 @@ +//! The Claude Desktop style frame around every page: sidebar on the left, +//! titlebar on top, content below. See docs/design-system.md, "Layout". + +mod sidebar; +mod titlebar; + +pub use sidebar::Sidebar; +pub use titlebar::Titlebar; diff --git a/crates/rkvm-web/src/shell/sidebar.rs b/crates/rkvm-web/src/shell/sidebar.rs new file mode 100644 index 0000000..1798c15 --- /dev/null +++ b/crates/rkvm-web/src/shell/sidebar.rs @@ -0,0 +1,191 @@ +use gloo_timers::callback::Timeout; +use leptos::prelude::*; + +use crate::i18n::use_i18n; +use crate::ui::{Icon, IconButton, IconName}; + +/// Left navigation, as in Claude Desktop: a 52px icon rail. Hovering it +/// peeks the full 256px sidebar over the content and leaving hides it again; +/// clicking the logo pins it open, which pushes the content over (the video +/// rescales once, not per frame). On narrow windows a pinned sidebar floats. +#[component] +pub fn Sidebar( + open: RwSignal, + #[prop(into)] connected: Signal, + show_settings: RwSignal, + show_terminal: RwSignal, + /// Connection label and dot class ("on", "warn" or ""). + #[prop(into)] + status: Signal<(&'static str, &'static str)>, +) -> impl IntoView { + let i = use_i18n(); + let host = web_sys::window().and_then(|w| w.location().host().ok()).unwrap_or_default(); + let on_desktop = move || !show_settings.get() && !show_terminal.get(); + let disabled = move || !connected.get(); + + // Hover peek with short enter/leave delays so passing the pointer over + // the rail on the way to the video does not flash the panel. + let peek = RwSignal::new(false); + let timer = StoredValue::new_local(None::); + let nav_ref = NodeRef::::new(); + let enter = move |_| { + if open.get_untracked() { + return; + } + timer.set_value(Some(Timeout::new(PEEK_IN_MS, move || peek.set(true)))); + }; + let leave = move || { + timer.set_value(Some(Timeout::new(PEEK_OUT_MS, move || { + if !keyboard_inside(nav_ref) { + peek.set(false); + } + }))); + }; + let pin = move |pinned: bool| { + timer.set_value(None); + peek.set(false); + open.set(pinned); + }; + + view! { + +
+
+ + } +} + +const PEEK_IN_MS: u32 = 120; +const PEEK_OUT_MS: u32 = 300; + +/// Whether keyboard focus is in the sidebar, so a peek stays open while the +/// user tabs through it. +fn keyboard_inside(nav: NodeRef) -> bool { + let Some(nav) = nav.get_untracked() else { return false }; + web_sys::window() + .and_then(|w| w.document()) + .and_then(|d| d.active_element()) + .is_some_and(|el| nav.contains(Some(&el)) && el.matches(":focus-visible").unwrap_or(false)) +} + +/// Product mark: a screen with a cursor, in the brand clay color. +#[component] +fn Logo() -> impl IntoView { + view! { + + } +} diff --git a/crates/rkvm-web/src/shell/titlebar.rs b/crates/rkvm-web/src/shell/titlebar.rs new file mode 100644 index 0000000..8b3eefd --- /dev/null +++ b/crates/rkvm-web/src/shell/titlebar.rs @@ -0,0 +1,22 @@ +use leptos::prelude::*; + +/// Top bar of the content column: page title and a status chip on the left, +/// page actions (the children) on the right. In focus mode CSS turns it into +/// an overlay that only shows while `peek` is set. +#[component] +pub fn Titlebar( + #[prop(into)] title: Signal<&'static str>, + #[prop(into)] chip: Signal>, + #[prop(into)] peek: Signal, + children: Children, +) -> impl IntoView { + view! { +
+
+ {move || title.get()} + {move || chip.get().map(|c| view! { {c} })} +
+
{children()}
+
+ } +} diff --git a/crates/rkvm-web/src/terminal.rs b/crates/rkvm-web/src/terminal.rs index 0dd0a12..b7c9b97 100644 --- a/crates/rkvm-web/src/terminal.rs +++ b/crates/rkvm-web/src/terminal.rs @@ -165,13 +165,18 @@ pub fn Terminal( .collect::>() }; + let i = crate::i18n::use_i18n(); view! {
- "Device shell (root)" -
); + +impl Theme { + pub fn choice(self) -> ThemeChoice { + self.0.get() + } + + pub fn set(self, choice: ThemeChoice) { + // Attributes are read only by CSS and the boot script; a failure + // leaves the old theme showing, nothing breaks. + if let Some(root) = root() { + let name = match choice { + ThemeChoice::System => { + let _ = root.remove_attribute("data-theme-choice"); + if os_dark() { "dark" } else { "light" } + } + ThemeChoice::Light => "light", + ThemeChoice::Dark => "dark", + }; + if choice != ThemeChoice::System { + let _ = root.set_attribute("data-theme-choice", name); + } + let _ = root.set_attribute("data-theme", name); + } + let stored = match choice { + ThemeChoice::System => "system", + ThemeChoice::Light => "light", + ThemeChoice::Dark => "dark", + }; + crate::util::store_set(STORAGE_KEY, stored); + self.0.set(choice); + } +} + +pub fn provide() -> Theme { + let choice = match root().and_then(|r| r.get_attribute("data-theme-choice")).as_deref() { + Some("light") => ThemeChoice::Light, + Some("dark") => ThemeChoice::Dark, + _ => ThemeChoice::System, + }; + let theme = Theme(RwSignal::new(choice)); + provide_context(theme); + theme +} + +fn root() -> Option { + web_sys::window()?.document()?.document_element() +} + +fn os_dark() -> bool { + web_sys::window() + .and_then(|w| w.match_media("(prefers-color-scheme: dark)").ok().flatten()) + .is_some_and(|m| m.matches()) +} diff --git a/crates/rkvm-web/src/ui/button.rs b/crates/rkvm-web/src/ui/button.rs new file mode 100644 index 0000000..368fe07 --- /dev/null +++ b/crates/rkvm-web/src/ui/button.rs @@ -0,0 +1,86 @@ +use leptos::prelude::*; + +use super::{Icon, IconName}; + +#[derive(Clone, Copy, PartialEq, Eq, Default)] +pub enum Variant { + /// Filled inverse: the one main action of a card. + #[default] + Primary, + /// Hairline outline: secondary actions next to a primary one. + Secondary, + /// No chrome until hover: toolbar and in-card actions. + Ghost, +} + +impl Variant { + fn class(self) -> &'static str { + match self { + Self::Primary => "btn-primary", + Self::Secondary => "btn-secondary", + Self::Ghost => "btn-ghost", + } + } +} + +/// Text button, 36px (32px with `small`). Event listeners go on the component +/// (`on:click=...`) and land on the ` + } +} + +/// Square icon-only button, 32px (28px with `small`). `label` is the tooltip +/// and the accessible name, so it is required. +#[component] +pub fn IconButton( + #[prop(into)] icon: Signal, + #[prop(into)] label: Signal<&'static str>, + #[prop(optional)] small: bool, + #[prop(optional)] danger: bool, + #[prop(into, optional)] active: MaybeProp, + #[prop(into, optional)] disabled: MaybeProp, +) -> impl IntoView { + let class = format!( + "icon-btn{}{}", + if small { " sm" } else { "" }, + if danger { " danger" } else { "" } + ); + view! { + + } +} diff --git a/crates/rkvm-web/src/ui/field.rs b/crates/rkvm-web/src/ui/field.rs new file mode 100644 index 0000000..f9de3de --- /dev/null +++ b/crates/rkvm-web/src/ui/field.rs @@ -0,0 +1,27 @@ +use leptos::prelude::*; + +use super::{Icon, IconName}; + +/// Label above a form control. The control is the child, so any input, +/// select or textarea gets the same spacing and focus ring. +#[component] +pub fn Field(#[prop(into)] label: Signal<&'static str>, children: Children) -> impl IntoView { + view! { + + } +} + +/// Tinted notice with a leading icon. Only the danger tone exists so far: the +/// no-password warning. +#[component] +pub fn Callout(#[prop(optional, into)] class: &'static str, children: Children) -> impl IntoView { + view! { + + } +} diff --git a/crates/rkvm-web/src/ui/icon.rs b/crates/rkvm-web/src/ui/icon.rs new file mode 100644 index 0000000..180f9ec --- /dev/null +++ b/crates/rkvm-web/src/ui/icon.rs @@ -0,0 +1,97 @@ +use leptos::prelude::*; + +/// Line icons on a 20x20 grid, 1.5px round strokes in `currentColor`, drawn +/// for this project to match the Claude Desktop icon weight. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum IconName { + Sidebar, + Monitor, + Terminal, + Sliders, + Maximize, + Focus, + Keyboard, + Mouse, + Volume, + VolumeOff, + ScanText, + Close, + Plus, + Minus, + Sun, + Moon, + Alert, + Search, + Globe, + Shield, + Disc, + Cpu, + Info, +} + +impl IconName { + fn path(self) -> &'static str { + match self { + Self::Sidebar => { + "M5 3.5h10a2.5 2.5 0 0 1 2.5 2.5v8a2.5 2.5 0 0 1 -2.5 2.5h-10a2.5 2.5 0 0 1 -2.5 -2.5v-8a2.5 2.5 0 0 1 2.5 -2.5zM7.5 3.5v13" + } + Self::Monitor => { + "M4.5 3.5h11a2 2 0 0 1 2 2v6.5a2 2 0 0 1 -2 2h-11a2 2 0 0 1 -2 -2v-6.5a2 2 0 0 1 2 -2zM7 16.5h6M10 14v2.5" + } + Self::Terminal => { + "M5 3.5h10a2.5 2.5 0 0 1 2.5 2.5v8a2.5 2.5 0 0 1 -2.5 2.5h-10a2.5 2.5 0 0 1 -2.5 -2.5v-8a2.5 2.5 0 0 1 2.5 -2.5zM6 8l2.5 2L6 12M10.5 12.5h3.5" + } + Self::Sliders => { + "M3.5 6.5h7M15.5 6.5h1M11 6.5a2 2 0 1 0 4 0a2 2 0 1 0 -4 0zM3.5 13.5h1M9.5 13.5h7M5 13.5a2 2 0 1 0 4 0a2 2 0 1 0 -4 0z" + } + Self::Maximize => "M3.5 7.5v-4h4M12.5 3.5h4v4M16.5 12.5v4h-4M7.5 16.5h-4v-4", + // A window with its toolbar and sidebar marked: "hide the + // chrome". Deliberately unlike Maximize's four corners. + Self::Focus => { + "M5 3.5h10a2.5 2.5 0 0 1 2.5 2.5v8a2.5 2.5 0 0 1 -2.5 2.5h-10a2.5 2.5 0 0 1 -2.5 -2.5v-8a2.5 2.5 0 0 1 2.5 -2.5zM2.5 7h15M6.5 7v9.5M10 10.5h4.5" + } + Self::Keyboard => { + "M4.5 5h11a2 2 0 0 1 2 2v6a2 2 0 0 1 -2 2h-11a2 2 0 0 1 -2 -2v-6a2 2 0 0 1 2 -2zM6 8.5h.01M8.67 8.5h.01M11.33 8.5h.01M14 8.5h.01M7 12h6" + } + Self::Mouse => { + "M10 2.5h0a4 4 0 0 1 4 4v7a4 4 0 0 1 -4 4h0a4 4 0 0 1 -4 -4v-7a4 4 0 0 1 4 -4zM10 5.5v3" + } + Self::Volume => { + "M3.5 8v4h3l4 3.5v-11l-4 3.5zM13.5 7.5a3.5 3.5 0 0 1 0 5M15.5 5a7 7 0 0 1 0 10" + } + Self::VolumeOff => "M3.5 8v4h3l4 3.5v-11l-4 3.5zM13.5 8l4 4M17.5 8l-4 4", + Self::ScanText => { + "M3.5 6.5v-1a2 2 0 0 1 2-2h1M13.5 3.5h1a2 2 0 0 1 2 2v1M16.5 13.5v1a2 2 0 0 1-2 2h-1M6.5 16.5h-1a2 2 0 0 1-2-2v-1M7 8h6M7 10.5h6M7 13h3.5" + } + Self::Close => "M5.5 5.5l9 9M14.5 5.5l-9 9", + Self::Plus => "M10 4.5v11M4.5 10h11", + Self::Minus => "M4.5 10h11", + Self::Sun => { + "M7 10a3 3 0 1 0 6 0a3 3 0 1 0 -6 0zM10 2.5v1.5M10 16v1.5M2.5 10h1.5M16 10h1.5M4.7 4.7l1.06 1.06M14.24 14.24l1.06 1.06M4.7 15.3l1.06-1.06M14.24 5.76l1.06-1.06" + } + Self::Moon => "M16 12.3A6.5 6.5 0 0 1 7.7 4a6.5 6.5 0 1 0 8.3 8.3z", + Self::Alert => "M10 3.5l7 12.5H3zM10 8.5v3M10 13.75h.01", + Self::Search => "M3.5 9a5.5 5.5 0 1 0 11 0a5.5 5.5 0 1 0 -11 0zM13 13l3.5 3.5", + Self::Globe => { + "M3 10a7 7 0 1 0 14 0a7 7 0 1 0 -14 0zM3 10h14M10 3c1.9 2 2.8 4.4 2.8 7s-.9 5-2.8 7M10 3c-1.9 2-2.8 4.4-2.8 7s.9 5 2.8 7" + } + Self::Shield => "M10 2.8l6 2.2v4.5c0 3.8-2.5 6.4-6 7.7c-3.5-1.3-6-3.9-6-7.7V5z", + Self::Disc => { + "M3 10a7 7 0 1 0 14 0a7 7 0 1 0 -14 0zM8.5 10a1.5 1.5 0 1 0 3 0a1.5 1.5 0 1 0 -3 0z" + } + Self::Cpu => { + "M6.5 5h7a1.5 1.5 0 0 1 1.5 1.5v7a1.5 1.5 0 0 1 -1.5 1.5h-7a1.5 1.5 0 0 1 -1.5 -1.5v-7a1.5 1.5 0 0 1 1.5 -1.5zM8.5 2.5V5M11.5 2.5V5M8.5 15v2.5M11.5 15v2.5M2.5 8.5H5M2.5 11.5H5M15 8.5h2.5M15 11.5h2.5" + } + Self::Info => "M3 10a7 7 0 1 0 14 0a7 7 0 1 0 -14 0zM10 9v4.5M10 6.5h.01", + } + } +} + +#[component] +pub fn Icon(name: IconName) -> impl IntoView { + view! { + + } +} diff --git a/crates/rkvm-web/src/ui/mod.rs b/crates/rkvm-web/src/ui/mod.rs new file mode 100644 index 0000000..be2f618 --- /dev/null +++ b/crates/rkvm-web/src/ui/mod.rs @@ -0,0 +1,11 @@ +//! Base components of the design system (docs/design-system.md). Views build +//! from these instead of hand-rolled buttons and inputs, so every page shares +//! the same sizes, states and focus rings. + +mod button; +mod field; +mod icon; + +pub use button::{Button, IconButton, Variant}; +pub use field::{Callout, Field}; +pub use icon::{Icon, IconName}; diff --git a/crates/rkvm-web/src/util.rs b/crates/rkvm-web/src/util.rs index 44ddc69..a7d83ca 100644 --- a/crates/rkvm-web/src/util.rs +++ b/crates/rkvm-web/src/util.rs @@ -8,18 +8,53 @@ use wasm_bindgen::{JsCast, JsValue}; use wasm_bindgen_futures::JsFuture; use web_sys::HtmlTextAreaElement; +use crate::i18n::tr; use crate::rpc::RpcClient; pub type Rpc = StoredValue>, LocalStorage>; -pub fn fire(rpc: Rpc, method: &'static str, params: Value) { - if let Some(client) = rpc.get_value() { - spawn_local(async move { - let _ = client.call(method, params).await; - }); +thread_local! { + static ERROR_SINK: std::cell::Cell>>> = + const { std::cell::Cell::new(None) }; +} + +/// Registers the app's toast signal; [`report_error`] writes into it. +pub fn set_error_sink(sink: RwSignal>) { + ERROR_SINK.set(Some(sink)); +} + +/// Shows `msg` in the bottom-center toast (above dialogs). +pub fn report_error(msg: String) { + if let Some(sink) = ERROR_SINK.get() { + sink.set(Some(msg)); } } +/// Sends a change the UI already shows. If the device refuses it, the toast +/// says so; use [`fire_revert`] when the control must also snap back. +pub fn fire(rpc: Rpc, method: &'static str, params: Value) { + fire_revert(rpc, method, params, || ()); +} + +/// Optimistic update: the caller has already set the new value; on failure +/// `revert` restores the old one and the toast explains why. +pub fn fire_revert(rpc: Rpc, method: &'static str, params: Value, revert: impl FnOnce() + 'static) { + let Some(client) = rpc.get_value() else { + revert(); + report_error(tr("未连接设备", "Not connected to the device").to_string()); + return; + }; + spawn_local(async move { + if let Err(e) = client.call(method, params).await { + revert(); + report_error(format!( + "{}{e}", + tr("设备未接受更改:", "The device refused the change: ") + )); + } + }); +} + /// Like `fire`, but writes the outcome into `status` so the user can see /// whether the device accepted the change. pub fn fire_status( @@ -30,14 +65,14 @@ pub fn fire_status( ok: &'static str, ) { let Some(client) = rpc.get_value() else { - status.set("Not connected to the device".to_string()); + status.set(tr("未连接设备", "Not connected to the device").to_string()); return; }; - status.set("Saving…".to_string()); + status.set(tr("正在保存…", "Saving…").to_string()); spawn_local(async move { match client.call(method, params).await { Ok(_) => status.set(ok.to_string()), - Err(e) => status.set(format!("Failed: {e}")), + Err(e) => status.set(format!("{}{e}", tr("失败:", "Failed: "))), } }); } @@ -83,11 +118,17 @@ pub async fn toggle_fullscreen() -> Result<(), String> { let el = doc.document_element().ok_or("no document element")?; call_method(el.as_ref(), "requestFullscreen", &Array::new()) }; - let value = result.map_err(|e| format!("Fullscreen unavailable: {}", js_message(&e)))?; + let value = result.map_err(|e| { + format!("{}{}", tr("无法全屏:", "Fullscreen unavailable: "), js_message(&e)) + })?; if let Ok(promise) = value.dyn_into::() { - JsFuture::from(promise) - .await - .map_err(|e| format!("Fullscreen blocked by the browser: {}", js_message(&e)))?; + JsFuture::from(promise).await.map_err(|e| { + format!( + "{}{}", + tr("浏览器阻止了全屏:", "Fullscreen blocked by the browser: "), + js_message(&e) + ) + })?; } Ok(()) } @@ -124,6 +165,24 @@ pub async fn copy_text(text: &str) -> Result<(), String> { if copied { Ok(()) } else { - Err("Copy failed: select the text and copy it manually".to_string()) + Err(tr( + "复制失败:请选中文字后手动复制", + "Copy failed: select the text and copy it manually", + ) + .to_string()) + } +} + +/// Read a per-browser preference. Storage can be missing or throw (private +/// windows, blocked site data); that reads as "not set". +pub fn store_get(key: &str) -> Option { + web_sys::window()?.local_storage().ok()??.get_item(key).ok()? +} + +/// Save a per-browser preference. Losing it only means the default comes back +/// on the next load, so failures are ignored. +pub fn store_set(key: &str, value: &str) { + if let Some(storage) = web_sys::window().and_then(|w| w.local_storage().ok().flatten()) { + let _ = storage.set_item(key, value); } } diff --git a/crates/rkvm-web/style/base.css b/crates/rkvm-web/style/base.css new file mode 100644 index 0000000..0a6c05f --- /dev/null +++ b/crates/rkvm-web/style/base.css @@ -0,0 +1,28 @@ +/* Element defaults. Only tokens from tokens.css appear here. */ + +* { box-sizing: border-box; } +html, body { + margin: 0; height: 100%; overflow: hidden; + background: var(--bg-secondary); color: var(--text-primary); + font: 400 var(--text-sm)/var(--lh-sm) var(--font-sans); + -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; +} +button, input, select, textarea { font: inherit; color: inherit; } +:focus-visible { outline: none; box-shadow: var(--focus-ring); } +::selection { background: var(--bg-info); } +::placeholder { color: var(--text-tertiary); } + +.muted { color: var(--text-tertiary); font-size: var(--text-xs); line-height: var(--lh-xs); + overflow-wrap: anywhere; } +.mono { font-family: var(--font-mono); } + +* { scrollbar-width: thin; scrollbar-color: var(--border-secondary) transparent; } + +@keyframes spin { to { transform: rotate(360deg); } } +@keyframes fade { from { opacity: 0; } to { opacity: 1; } } +@keyframes pop { from { opacity: 0; transform: translateY(6px) scale(.98); } to { opacity: 1; transform: none; } } +@keyframes slideUp { from { transform: translateY(100%); } to { transform: none; } } +@keyframes toastIn { from { opacity: 0; transform: translate(-50%, 8px); } to { opacity: 1; transform: translate(-50%, 0); } } +@media (prefers-reduced-motion: reduce) { + * { animation-duration: .01ms !important; transition-duration: .01ms !important; } +} diff --git a/crates/rkvm-web/style/components.css b/crates/rkvm-web/style/components.css new file mode 100644 index 0000000..f78cf68 --- /dev/null +++ b/crates/rkvm-web/style/components.css @@ -0,0 +1,142 @@ +/* Base components. Each class here has a matching Rust component in src/ui + (or is a plain-HTML pattern documented in docs/design-system.md). One-border + rule: a bordered surface never contains another bordered box; controls + inside cards are ghost or filled. */ + +/* Icon: 20px box, stroke follows currentColor. */ +.icon { width: 20px; height: 20px; flex: none; display: block; fill: none; + stroke: currentColor; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; } +.icon.sm { width: 16px; height: 16px; } + +/* Button */ +.btn { display: inline-flex; align-items: center; justify-content: center; gap: var(--space-2); + height: 36px; padding: 0 var(--space-4); border: 0; border-radius: var(--radius-md); + font-size: var(--text-sm); font-weight: 600; line-height: 1; white-space: nowrap; + cursor: pointer; user-select: none; + transition: background var(--dur) var(--ease), box-shadow var(--dur) var(--ease), + transform var(--dur) var(--ease), opacity var(--dur) var(--ease); } +.btn:not(:disabled):active { transform: scale(.98); } +.btn:disabled { opacity: .5; cursor: default; } +.btn.sm { height: 32px; padding: 0 var(--space-3); border-radius: var(--radius-sm); font-size: var(--text-xs); } +.btn.block { width: 100%; } +.btn-primary { background: var(--bg-inverse); color: var(--text-inverse); } +.btn-primary:not(:disabled):hover { opacity: .9; } +.btn-primary:focus-visible { box-shadow: 0 0 0 2px var(--bg-primary), 0 0 0 3.5px var(--ring-primary); } +.btn-secondary { background: transparent; color: var(--text-primary); + box-shadow: inset 0 0 0 var(--hairline) var(--border-primary); } +.btn-secondary:not(:disabled):hover { background: var(--bg-hover); } +.btn-ghost { background: transparent; color: var(--text-secondary); } +.btn-ghost:not(:disabled):hover { background: var(--bg-hover); color: var(--text-primary); } +.btn.on { background: var(--bg-selected); color: var(--text-primary); } + +/* Icon button: square, 32px (28px small). */ +.icon-btn { display: inline-grid; place-items: center; flex: none; width: 32px; height: 32px; + padding: 0; border: 0; border-radius: var(--radius-md); background: transparent; + color: var(--text-secondary); cursor: pointer; + transition: background var(--dur) var(--ease), color var(--dur) var(--ease), transform var(--dur) var(--ease); } +.icon-btn.sm { width: 28px; height: 28px; border-radius: var(--radius-sm); } +.icon-btn:not(:disabled):hover { background: var(--bg-hover); color: var(--text-primary); } +.icon-btn:not(:disabled):active { transform: scale(.96); background: var(--bg-press); } +.icon-btn:disabled { opacity: .5; cursor: default; } +.icon-btn.on { background: var(--bg-selected); color: var(--text-primary); } +.icon-btn.danger { color: var(--text-danger-strong); } +.icon-btn.danger:not(:disabled):hover { background: var(--bg-danger-soft); color: var(--text-danger-strong); } + +/* Text input, select, textarea */ +.input, .field input, .field select, .field textarea { + display: block; width: 100%; height: 40px; padding: 0 var(--space-3); + background: var(--bg-primary); border: 1px solid var(--border-secondary); border-radius: var(--radius-md); + font-size: var(--text-sm); transition: border-color var(--dur) var(--ease), box-shadow var(--dur) var(--ease); } +.field textarea { height: auto; padding: var(--space-2) var(--space-3); font-family: var(--font-mono); resize: vertical; } +.input:focus, .field input:focus, .field select:focus, .field textarea:focus { + outline: none; border-color: var(--border-primary); box-shadow: var(--focus-ring); } + +/* Field: label above control, optional hint below. */ +.field { display: flex; flex-direction: column; gap: 6px; margin: 0 0 var(--space-4); } +.field > .field-label { font-size: var(--text-xs); line-height: var(--lh-xs); font-weight: 500; + color: var(--text-secondary); } + +/* Callout: tinted, single hairline, leading icon. */ +.callout { display: flex; gap: var(--space-2); align-items: flex-start; padding: var(--space-3); + border-radius: var(--radius-lg); font-size: var(--text-xs); line-height: var(--lh-xs); } +.callout > .icon { width: 16px; height: 16px; margin-top: 1px; } +.callout > .callout-body { flex: 1; } +.callout-danger { background: var(--bg-danger-soft); color: var(--text-danger); + box-shadow: inset 0 0 0 var(--hairline) var(--border-danger); } +.callout-danger > .icon { color: var(--text-danger-strong); } + +/* Card: the one bordered surface of a gate or dialog. */ +.card { width: min(380px, 100%); padding: var(--space-6); background: var(--bg-primary); + border-radius: var(--radius-2xl); box-shadow: var(--shadow-lg); animation: pop .2s var(--ease); } +.card-title { margin: 0 0 var(--space-1); font-size: var(--heading-lg); line-height: 25px; font-weight: 600; + letter-spacing: -0.01em; } +.card-sub { margin: 0 0 var(--space-5); color: var(--text-tertiary); } +.card .callout { margin-bottom: var(--space-4); } +.card .actions { display: flex; gap: var(--space-2); justify-content: flex-end; margin-top: var(--space-4); } +.err { color: var(--text-danger-strong); margin-top: var(--space-3); min-height: var(--lh-xs); + font-size: var(--text-xs); line-height: var(--lh-xs); } +.err:empty { display: none; } + +/* Status dot and badge */ +.dot { display: inline-block; width: 8px; height: 8px; flex: none; border-radius: 50%; background: var(--dot-off); } +.dot.on { background: var(--dot-on); } +.dot.warn { background: var(--dot-warn); } +.badge { display: inline-flex; align-items: center; height: 20px; padding: 0 6px; border-radius: var(--radius-xs); + font: 600 10px/1 var(--font-mono); letter-spacing: .03em; color: var(--text-tertiary); + box-shadow: inset 0 0 0 var(--hairline) var(--border-tertiary); transition: all var(--dur) var(--ease); } +.badge.on { background: var(--bg-success); color: var(--text-success); box-shadow: inset 0 0 0 var(--hairline) var(--border-success); } +.chip { display: inline-flex; align-items: center; gap: 6px; height: 24px; padding: 0 var(--space-2); + border-radius: var(--radius-full); background: var(--bg-hover); color: var(--text-tertiary); + font-size: var(--text-xs); font-variant-numeric: tabular-nums; white-space: nowrap; } + +/* Switch: checkbox inside label.toggle. Off: tertiary track with hairline; + on: info blue. 36x20, 16px knob. */ +.toggle { display: flex; align-items: center; gap: var(--space-3); margin: var(--space-2) 0; cursor: pointer; + font-size: var(--text-sm); } +.toggle input[type=checkbox] { appearance: none; -webkit-appearance: none; flex: none; position: relative; + width: 36px; height: 20px; margin: 0; border-radius: var(--radius-full); cursor: pointer; + background: var(--border-tertiary); box-shadow: inset 0 0 0 var(--hairline) var(--border-secondary); + transition: background var(--dur) var(--ease); } +.toggle input[type=checkbox]::after { content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; + border-radius: 50%; background: #fff; box-shadow: 0 1px 2px rgba(0,0,0,.2); + transition: transform var(--dur) var(--ease); } +.toggle input[type=checkbox]:checked { background: var(--border-info); box-shadow: none; } +.toggle input[type=checkbox]:checked::after { transform: translateX(16px); } +.toggle:hover input[type=checkbox]:not(:checked):not(:disabled) { background: var(--border-secondary); } +.toggle:hover input[type=checkbox]:checked:not(:disabled) { filter: brightness(.94); } +/* Press: the knob stretches toward where it is going. */ +.toggle input[type=checkbox]:active:not(:disabled)::after { width: 20px; } +.toggle input[type=checkbox]:checked:active:not(:disabled)::after { transform: translateX(12px); } +.toggle input[type=checkbox]:focus-visible { box-shadow: var(--focus-ring); } +.toggle:has(input:disabled) { opacity: .5; cursor: default; } + +/* Segmented control (radio group): the selected segment is a raised chip, + the rest are ghost. Used for theme, language, codec, rotation. */ +.seg { display: inline-flex; flex-wrap: wrap; gap: 2px; padding: 2px; border-radius: var(--radius-md); + background: var(--bg-hover); } +.seg button { display: inline-grid; place-items: center; min-width: 32px; height: 28px; padding: 0 var(--space-3); + border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--text-secondary); + font: inherit; font-size: var(--text-xs); font-weight: 500; cursor: pointer; box-shadow: none; + transition: background var(--dur) var(--ease), color var(--dur) var(--ease), transform var(--dur) var(--ease); } +.seg button .icon { width: 16px; height: 16px; } +.seg button:not(.on):not(:disabled):hover { background: var(--bg-hover); color: var(--text-primary); } +.seg button:not(:disabled):active { transform: scale(.97); } +.seg button.on { background: var(--bg-primary); color: var(--text-primary); + box-shadow: 0 0 0 var(--hairline) var(--border-tertiary), var(--shadow-sm); } +.seg button:focus-visible { outline: none; box-shadow: var(--focus-ring); } +.seg button:disabled { opacity: .5; cursor: default; } + +.spinner { width: 14px; height: 14px; flex: none; border: 2px solid currentColor; border-right-color: transparent; + border-radius: 50%; display: inline-block; opacity: .8; animation: spin .7s linear infinite; } + +/* Separator inside a toolbar row */ +.vsep { width: var(--hairline); align-self: stretch; margin: 8px var(--space-1); background: var(--border-secondary); flex: none; } + +/* Toast: bottom-center, solid surface (never blurred over video). */ +.toast { position: fixed; left: 50%; bottom: var(--space-6); transform: translateX(-50%); z-index: 60; + display: flex; align-items: center; gap: var(--space-2); max-width: min(560px, calc(100% - 32px)); + padding: var(--space-2) var(--space-2) var(--space-2) var(--space-3); border-radius: var(--radius-xl); + background: var(--bg-primary); color: var(--text-primary); box-shadow: var(--shadow-popover); + font-size: var(--text-sm); animation: toastIn .2s var(--ease); } +.toast > .icon { color: var(--text-danger-strong); width: 16px; height: 16px; } +.toast > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } diff --git a/crates/rkvm-web/style/panels.css b/crates/rkvm-web/style/panels.css new file mode 100644 index 0000000..091603a --- /dev/null +++ b/crates/rkvm-web/style/panels.css @@ -0,0 +1,115 @@ +/* Overlays: the settings dialog, the device terminal and the OCR dialog. */ + +.scrim { position: fixed; inset: 0 0 0 var(--sidebar-rail); z-index: 40; background: var(--bg-scrim); animation: fade .18s var(--ease); } +.dialog { position: fixed; inset: 0; z-index: 41; display: grid; place-items: center; padding: var(--space-4); } +.dialog .card { width: min(560px, 100%); } +.dialog textarea { width: 100%; min-height: 220px; padding: var(--space-3); border-radius: var(--radius-md); + background: var(--bg-tertiary); border: 1px solid var(--border-tertiary); font: 13px/1.5 var(--font-mono); + resize: vertical; } +.dialog textarea:focus { outline: none; box-shadow: var(--focus-ring); } + +/* Settings dialog (Claude Desktop): a centered panel over a scrim that also + covers the sidebar. Left column: search and page list; right column: the + page's sections, close button top right. Full screen below 720px. */ +.settings-scrim { position: fixed; inset: 0; z-index: 50; background: var(--bg-scrim); animation: fade .18s var(--ease); } +.settings-dialog { position: fixed; inset: 0; z-index: 51; display: grid; place-items: center; padding: var(--space-6); } +.settings { display: grid; grid-template-columns: 240px minmax(0, 1fr); width: min(960px, 100%); height: min(720px, 100%); + overflow: hidden; background: var(--bg-primary); border-radius: var(--radius-2xl); box-shadow: var(--shadow-lg); + animation: pop .2s var(--ease); } +.settings:focus { outline: none; } +.set-nav { display: flex; flex-direction: column; gap: 2px; padding: var(--space-4) var(--space-3); overflow-y: auto; + background: var(--bg-sidebar); border-right: var(--hairline) solid var(--border-tertiary); } +.set-search { display: flex; align-items: center; gap: var(--space-2); height: 36px; padding: 0 var(--space-3); + margin-bottom: var(--space-2); border-radius: var(--radius-md); background: var(--bg-primary); + box-shadow: inset 0 0 0 var(--hairline) var(--border-secondary); color: var(--text-tertiary); } +.set-search:focus-within { box-shadow: var(--focus-ring); } +.set-search input { flex: 1; min-width: 0; height: 100%; padding: 0; border: 0; background: transparent; + color: var(--text-primary); font-size: var(--text-sm); outline: none; } +.set-group { margin: var(--space-3) var(--space-2) var(--space-1); font-size: var(--text-xs); color: var(--text-tertiary); } +.set-item { display: flex; align-items: center; gap: var(--space-3); flex: none; height: 36px; padding: 0 var(--space-2); + border: 0; border-radius: var(--radius-md); background: transparent; color: var(--text-secondary); + font-size: var(--text-sm); text-align: left; white-space: nowrap; cursor: pointer; + transition: background var(--dur) var(--ease), color var(--dur) var(--ease); } +.set-item:hover { background: var(--bg-hover); color: var(--text-primary); } +.set-item.on { background: var(--bg-selected); color: var(--text-primary); font-weight: 500; } +.set-body { position: relative; overflow-y: auto; padding: var(--space-6) var(--space-8) var(--space-8); } +.set-title { margin: var(--space-1) 0 var(--space-2); font-size: var(--text-md); font-weight: 600; } +.set-empty { color: var(--text-tertiary); font-size: var(--text-sm); } +.settings .close { position: absolute; top: var(--space-4); right: var(--space-4); } +.settings h3 { margin: 0 0 var(--space-3); font-size: var(--text-sm); font-weight: 600; color: var(--text-primary); } +.settings section { padding: var(--space-3) 0; } +.settings section.off { display: none; } +/* Claude-style row: label and hint left, control right. */ +.set-row { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); } +.set-row h3 { margin-bottom: 2px; } +.set-row .hint { margin: 0; } +.settings .toggle { justify-content: space-between; flex-direction: row-reverse; min-height: 32px; margin: 0; } +.settings .close { width: 32px; height: 32px; display: grid; place-items: center; padding: 0; border: 0; + border-radius: var(--radius-md); background: transparent; color: var(--text-secondary); cursor: pointer; } +.settings .close:hover { background: var(--bg-hover); color: var(--text-primary); } +@media (max-width: 720px) { + .settings-dialog { padding: 0; } + .settings { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); width: 100%; height: 100%; + border-radius: 0; } + .set-nav { flex-direction: row; flex-wrap: nowrap; overflow-x: auto; overflow-y: hidden; padding: var(--space-2); + padding-right: 52px; border-right: 0; border-bottom: var(--hairline) solid var(--border-tertiary); } + .set-search { flex: none; width: 150px; margin: 0; } + .set-search input { width: 100%; } + .set-group { display: none; } + .set-body { padding: var(--space-4); } + .settings .close { position: fixed; top: var(--space-2); right: var(--space-2); z-index: 1; background: var(--bg-sidebar); } +} +.settings .row { display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; margin: var(--space-2) 0; } +/* Plain action buttons in sections. `:where` keeps this at class weight so + segmented controls and `.tb-danger` override it without !important. */ +.set-body :where(button:not(.close):not([role=radio])) { height: 32px; padding: 0 var(--space-3); border: 0; + border-radius: var(--radius-sm); background: transparent; color: var(--text-primary); font-size: var(--text-xs); + font-weight: 600; cursor: pointer; box-shadow: inset 0 0 0 var(--hairline) var(--border-primary); + transition: background var(--dur) var(--ease), transform var(--dur) var(--ease); } +.set-body :where(button:not(.close):not([role=radio]):not(:disabled)):hover { background: var(--bg-hover); } +.set-body :where(button:not(.close):not([role=radio]):not(:disabled)):active { transform: scale(.98); background: var(--bg-press); } +.set-body :where(button:not(.close):not([role=radio])):focus-visible { outline: none; box-shadow: var(--focus-ring); } +.settings button:disabled { opacity: .5; cursor: default; } +.settings button.tb-danger { color: var(--text-danger-strong); box-shadow: inset 0 0 0 var(--hairline) var(--border-danger); } +.settings button.tb-danger:not(:disabled):hover { background: var(--bg-danger-soft); } +.settings button.tb-danger:focus-visible { box-shadow: 0 0 0 2px var(--bg-primary), 0 0 0 3.5px var(--ring-danger); } +.settings select, .settings input[type=text], .settings input[type=password], .settings input[type=url], +.settings input[type=number], .settings textarea { + display: block; width: 100%; min-height: 32px; padding: 6px var(--space-3); margin: 6px 0; + background: var(--bg-primary); border: 1px solid var(--border-secondary); border-radius: var(--radius-md); + font-size: var(--text-sm); transition: border-color var(--dur) var(--ease), box-shadow var(--dur) var(--ease); } +.settings textarea { font-family: var(--font-mono); font-size: var(--text-xs); resize: vertical; } +.settings select:focus, .settings input:focus, .settings textarea:focus { outline: none; border-color: var(--border-primary); + box-shadow: var(--focus-ring); } +.settings input[type=range] { flex: 1; width: auto; accent-color: var(--border-info); margin: 0; } +.settings input[type=file] { font-size: var(--text-xs); } +.settings .field { display: block; margin: var(--space-2) 0 0; } +.settings .field > span { display: block; font-size: var(--text-xs); color: var(--text-secondary); font-weight: 500; } +.settings .hint { color: var(--text-tertiary); font-size: var(--text-xs); line-height: var(--lh-xs); margin: 2px 0 6px; } +.settings .kv { display: grid; grid-template-columns: auto 1fr; gap: 6px var(--space-4); margin: 0 0 var(--space-2); + font-size: var(--text-xs); } +.settings .kv dt { color: var(--text-tertiary); } +.settings .kv dd { margin: 0; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; } +.settings .dot { margin-right: 6px; vertical-align: 0; } +.settings ul.plain { margin: var(--space-1) 0; padding: 0; list-style: none; font: 400 var(--text-xs) var(--font-mono); } +.settings ul.plain li { padding: 2px 0; overflow-wrap: anywhere; } + +/* Device terminal: docked to the bottom of the stage, always dark. */ +.term { position: absolute; left: 0; right: 0; bottom: 0; z-index: 18; height: min(340px, 60%); + display: flex; flex-direction: column; background: var(--term-bg); color: var(--term-text); + border-top: var(--hairline) solid rgba(222, 220, 209, 0.15); animation: slideUp .2s var(--ease); } +.term header { display: flex; align-items: center; justify-content: space-between; height: 36px; flex: none; + padding: 0 var(--space-2) 0 var(--space-4); font-size: var(--text-xs); font-weight: 500; color: #9c9a92; } +.term header button { display: grid; place-items: center; width: 28px; height: 28px; padding: 0; border: 0; border-radius: var(--radius-sm); + background: transparent; color: #9c9a92; cursor: pointer; } +.term header button:hover { background: rgba(222, 220, 209, 0.08); color: #faf9f5; } +.term-screen { flex: 1; margin: 0; padding: var(--space-2) var(--space-4) var(--space-3); overflow: hidden; outline: none; + white-space: pre; font: 13px/1.4 var(--font-mono); cursor: text; user-select: text; } +.term-screen:focus { box-shadow: inset 0 0 0 1px rgba(128, 170, 221, 0.35); } +.trow { height: 1.4em; } +.term-cursor { background: var(--accent-soft); color: #141413; border-radius: 1px; animation: termblink 1.06s steps(1) infinite; } +.term-screen:not(:focus) .term-cursor { animation: none; background: transparent; color: inherit; + box-shadow: inset 0 0 0 1px var(--accent-soft); } +@keyframes termblink { 0%, 50% { background: var(--accent-soft); color: #141413; } + 50.01%, 100% { background: transparent; color: inherit; } } +.term header .icon, .settings .close .icon { width: 16px; height: 16px; } diff --git a/crates/rkvm-web/style/shell.css b/crates/rkvm-web/style/shell.css new file mode 100644 index 0000000..9ce2494 --- /dev/null +++ b/crates/rkvm-web/style/shell.css @@ -0,0 +1,114 @@ +/* App shell: Claude Desktop layout. A 52px sidebar rail on the left, a 48px + titlebar over the content, and the video stage filling the rest. The + pinned sidebar (256px, logo click) pushes the content, as in Claude + Desktop, so the titlebar chip and actions are never covered; the column + switches in one step, so the video rescales once, not every frame. + Hovering the rail peeks the sidebar over the content without moving it. + Below 720px a pinned sidebar floats too. */ + +.app { position: fixed; inset: 0; display: grid; grid-template-columns: var(--sidebar-rail) minmax(0, 1fr); } +/* The sidebar leaves the grid entirely, so main must be the only track. */ +.app.sb-open { grid-template-columns: var(--sidebar-open) minmax(0, 1fr); } +.app.focus { grid-template-columns: minmax(0, 1fr); } + +/* Sidebar */ +/* Above the sheets' scrims (z 40-41) so theme, language and nav stay + clickable while settings is open. */ +.sidebar { position: relative; z-index: 45; min-width: 0; } +.app.focus .sidebar { display: none; } +.app.focus .scrim { left: 0; } +.sb-panel { position: absolute; inset: 0 auto 0 0; width: var(--sidebar-rail); display: flex; flex-direction: column; + padding: 0 var(--space-2) var(--space-3); background: var(--bg-sidebar); + border-right: var(--hairline) solid var(--border-secondary); overflow: hidden; } +.sidebar.expanded .sb-panel { width: var(--sidebar-open); } +/* Hover peek: floats over the content and never moves the grid. */ +.sidebar.peek .sb-panel { box-shadow: var(--shadow-lg); transition: width .18s var(--ease), box-shadow .18s var(--ease); } +.sb-scrim { display: none; } +@media (max-width: 720px) { + .app.sb-open { grid-template-columns: var(--sidebar-rail) minmax(0, 1fr); } + .sb-panel { transition: width .2s var(--ease), box-shadow .2s var(--ease); } + .sidebar.open .sb-panel { box-shadow: var(--shadow-lg); } + .sb-scrim { display: block; position: fixed; inset: 0; z-index: 44; background: transparent; } +} +.sb-top { display: flex; align-items: center; gap: var(--space-2); height: var(--titlebar-h); flex: none; } +.sb-logo { display: grid; place-items: center; width: 36px; height: 36px; flex: none; padding: 0; border: 0; + border-radius: var(--radius-md); background: transparent; color: var(--accent); cursor: pointer; + transition: background var(--dur) var(--ease); } +.sb-logo:hover { background: var(--bg-hover); } +.sb-logo svg { width: 22px; height: 22px; } +.sb-brand { font-size: var(--text-md); font-weight: 600; letter-spacing: -0.01em; white-space: nowrap; } +.sb-top .icon-btn { margin-left: auto; } +.sb-nav { display: flex; flex-direction: column; gap: 2px; margin-top: var(--space-2); } +.sb-item { display: flex; align-items: center; gap: var(--space-3); width: 100%; height: 36px; padding: 0 var(--space-2); + border: 0; border-radius: var(--radius-md); background: transparent; color: var(--text-secondary); + font-size: var(--text-sm); text-align: left; white-space: nowrap; cursor: pointer; + transition: background var(--dur) var(--ease), color var(--dur) var(--ease); } +.sb-item:not(:disabled):hover { background: var(--bg-hover); color: var(--text-primary); } +.sb-item.on { background: var(--bg-selected); color: var(--text-primary); font-weight: 500; } +.sb-item:disabled { opacity: .45; cursor: default; } +.sb-item .sb-meta { margin-left: auto; font-size: var(--text-xs); color: var(--text-tertiary); } +.sb-foot { margin-top: auto; display: flex; flex-direction: column; gap: 2px; } +.sb-status { display: flex; align-items: center; gap: var(--space-3); height: 45px; padding: 0 var(--space-2); + margin-top: var(--space-1); border-top: var(--hairline) solid var(--border-tertiary); white-space: nowrap; } +.sb-status .dot { margin: 0 6px; } +.sb-status .sb-host { display: flex; flex-direction: column; min-width: 0; line-height: 1.25; } +.sb-status .sb-host small { color: var(--text-tertiary); font-size: var(--text-xs); overflow: hidden; text-overflow: ellipsis; } +.sidebar:not(.expanded) .sb-label { display: none; } +.sidebar:not(.expanded) .sidebar:not(.expanded) .sb-top .icon-btn { display: none; } + +/* Main column */ +.main { position: relative; display: flex; flex-direction: column; min-width: 0; min-height: 0; } + +/* Titlebar */ +.titlebar { display: flex; align-items: center; gap: var(--space-2); height: var(--titlebar-h); flex: none; + padding: 0 var(--space-2) 0 var(--space-4); background: var(--bg-sidebar); + border-bottom: var(--hairline) solid var(--border-tertiary); min-width: 0; } +.tb-title { font-size: var(--text-md); font-weight: 500; white-space: nowrap; } +.tb-left { display: flex; align-items: center; gap: var(--space-3); min-width: 0; } +.tb-right { display: flex; align-items: center; gap: 2px; margin-left: auto; min-width: 0; + overflow-x: auto; scrollbar-width: none; } +.tb-right::-webkit-scrollbar { display: none; } +.tb-leds { display: inline-flex; gap: var(--space-1); margin-right: var(--space-2); } +.tb-quality { display: inline-flex; align-items: center; } +.tb-quality > span { min-width: 40px; text-align: center; font-size: var(--text-xs); color: var(--text-secondary); + font-variant-numeric: tabular-nums; } + +/* Focus mode: sidebar and titlebar leave the layout; a small handle at the + top center peeks the titlebar in as an overlay. */ +.app.focus .titlebar { position: absolute; top: 0; left: 50%; z-index: 25; width: max-content; + max-width: calc(100% - 16px); border: 0; border-radius: 0 0 var(--radius-xl) var(--radius-xl); + box-shadow: var(--shadow-popover); transform: translate(-50%, -100%); opacity: 0; + transition: transform .22s var(--ease), opacity .18s var(--ease); } +.app.focus .titlebar.peek { transform: translate(-50%, 0); opacity: 1; } +.focus-handle { position: absolute; top: 0; left: 50%; transform: translateX(-50%); z-index: 24; + width: 56px; height: 14px; border: 0; border-radius: 0 0 var(--radius-md) var(--radius-md); + background: var(--bg-primary); color: var(--text-tertiary); box-shadow: var(--shadow-popover); + display: grid; place-items: center; cursor: pointer; opacity: .7; } +.focus-handle:hover { opacity: 1; } +.focus-handle::before { content: ""; width: 20px; height: 3px; border-radius: 2px; background: currentColor; } + +/* Stage: the video is always mounted here so its MediaStream survives every + overlay change; gates and dialogs sit on top of it. */ +.stage { position: relative; flex: 1; min-height: 0; background: var(--stage); overflow: hidden; } +#kvm-video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; + background: var(--stage); display: block; outline: none; } + +/* Gate: setup / sign-in / connect, centered over the stage. */ +.gate { position: absolute; inset: 0; z-index: 10; display: grid; place-items: center; padding: var(--space-4); + background: var(--bg-secondary); animation: fade .2s var(--ease); overflow-y: auto; } +.gate .card .btn.block { margin-top: var(--space-2); } + +/* No-password warning over the stage. */ +.stage-callout { position: absolute; top: var(--space-3); left: var(--space-3); z-index: 15; + max-width: min(480px, calc(100% - 24px)); background: var(--bg-primary); box-shadow: var(--shadow-popover); } +.stage-callout .icon-btn { margin: -6px -6px -6px 0; color: inherit; } + +/* Touch: every target at least 44px (WCAG 2.5.5). The titlebar row scrolls + sideways when the actions no longer fit. */ +@media (pointer: coarse) { + .icon-btn, .icon-btn.sm { width: 44px; height: 44px; } + .btn.sm { height: 44px; } + .sb-item, .set-item { height: 44px; } + .seg button { height: 40px; } + .focus-handle { width: 88px; height: 24px; } +} diff --git a/crates/rkvm-web/style/tokens.css b/crates/rkvm-web/style/tokens.css new file mode 100644 index 0000000..c9484b3 --- /dev/null +++ b/crates/rkvm-web/style/tokens.css @@ -0,0 +1,159 @@ +/* Design tokens. The one place colors, type, spacing, radii, shadows and motion + are defined; everything else references these variables. Values follow the + Claude Desktop design kit (docs/design-system.md). Light is the default; + :root[data-theme="dark"] swaps the palette. The inline script in index.html + sets data-theme before first paint from the saved choice or the OS setting. */ + +:root { + color-scheme: light; + + /* Backgrounds */ + --bg-primary: #ffffff; + --bg-secondary: #f5f4ed; + --bg-tertiary: #faf9f5; + --bg-inverse: #141413; + --bg-sidebar: #f9f9f7; + --bg-selected: #f0efec; + --bg-hover: rgba(31, 30, 29, 0.05); + --bg-press: rgba(31, 30, 29, 0.08); + --bg-info: #d6e4f6; + --bg-danger: #b53333; + --bg-danger-soft: rgba(181, 51, 51, 0.08); + --bg-success: #e9f1dc; + --bg-warning: #f6eedf; + --bg-scrim: rgba(20, 20, 19, 0.32); + + /* Text */ + --text-primary: #141413; + --text-secondary: #3d3d3a; + --text-tertiary: #73726c; + --text-ghost: rgba(115, 114, 108, 0.5); + --text-inverse: #ffffff; + --text-info: #3266ad; + --text-danger: #7f2c28; + --text-danger-strong: #b53333; + --text-success: #265b19; + --text-warning: #5a4815; + + /* Borders (0.5px hairlines unless noted) */ + --border-primary: rgba(31, 30, 29, 0.4); + --border-secondary: rgba(31, 30, 29, 0.3); + --border-tertiary: rgba(31, 30, 29, 0.15); + --border-info: #4682d5; + --border-danger: #a73d39; + --border-success: #437426; + --border-warning: #805c1f; + --hairline: 0.5px; + + /* Focus rings */ + --ring-primary: rgba(20, 20, 19, 0.7); + --ring-info: rgba(50, 102, 173, 0.5); + --ring-danger: rgba(167, 61, 57, 0.5); + --focus-ring: 0 0 0 2px var(--bg-primary), 0 0 0 3.5px var(--ring-info); + + /* Brand accent and status dots */ + --accent: #c6613f; + --accent-soft: #d97757; + --dot-on: #599130; + --dot-off: #a8a69d; + --dot-warn: #d1a041; + + /* The video stage is always near-black so the remote screen reads the same + in both themes. */ + --stage: #141413; + --term-bg: #141413; + --term-text: #e8e6dc; + + /* Shadows */ + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04); + --shadow-md: 0 0.25rem 1.25rem rgba(0, 0, 0, 0.035), 0 0 0 0.5px var(--border-tertiary); + --shadow-lg: 0 8px 28px rgba(0, 0, 0, 0.08), 0 0 0 0.5px var(--border-tertiary); + --shadow-popover: 0 4px 16px rgba(0, 0, 0, 0.08), 0 0 0 0.5px var(--border-tertiary); + + /* Typography */ + --font-sans: "Anthropic Sans", ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", + "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", system-ui, sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace; + --text-xs: 12px; --lh-xs: 16.8px; + --text-sm: 14px; --lh-sm: 19.6px; + --text-md: 16px; --lh-md: 22.4px; + --text-lg: 18px; --lh-lg: 25px; + --heading-lg: 20px; + --heading-xl: 24px; + --heading-3xl: 36px; + + /* Spacing (4px grid) */ + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-8: 32px; + + /* Radii */ + --radius-xs: 4px; + --radius-sm: 6px; + --radius-md: 8px; + --radius-lg: 10px; + --radius-xl: 12px; + --radius-2xl: 16px; + --radius-full: 999px; + + /* Layout */ + --sidebar-rail: 52px; + --sidebar-open: 256px; + --titlebar-h: 48px; + + /* Motion */ + --ease: cubic-bezier(0.16, 1, 0.3, 1); + --dur: 150ms; +} + +:root[data-theme="dark"] { + color-scheme: dark; + + --bg-primary: #30302e; + --bg-secondary: #262624; + --bg-tertiary: #141413; + --bg-inverse: #faf9f5; + --bg-sidebar: #1f1e1d; + --bg-selected: #30302e; + --bg-hover: rgba(222, 220, 209, 0.06); + --bg-press: rgba(222, 220, 209, 0.1); + --bg-info: #253e5f; + --bg-danger: #dd5353; + --bg-danger-soft: rgba(221, 83, 83, 0.12); + --bg-success: #1b4614; + --bg-warning: #483a0f; + --bg-scrim: rgba(0, 0, 0, 0.5); + + --text-primary: #faf9f5; + --text-secondary: #c2c0b6; + --text-tertiary: #9c9a92; + --text-ghost: rgba(156, 154, 146, 0.5); + --text-inverse: #141413; + --text-info: #80aadd; + --text-danger: #ee8884; + --text-danger-strong: #ee8884; + --text-success: #7ab948; + --text-warning: #d1a041; + + --border-primary: rgba(222, 220, 209, 0.4); + --border-secondary: rgba(222, 220, 209, 0.3); + --border-tertiary: rgba(222, 220, 209, 0.15); + --border-danger: #cd5c58; + --border-success: #599130; + --border-warning: #a87829; + + --ring-primary: rgba(250, 249, 245, 0.7); + --ring-info: rgba(128, 170, 221, 0.5); + --ring-danger: rgba(205, 92, 88, 0.5); + + --dot-off: #6b6a65; + + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2); + --shadow-md: 0 0.25rem 1.25rem rgba(0, 0, 0, 0.25), 0 0 0 0.5px var(--border-tertiary); + --shadow-lg: 0 8px 28px rgba(0, 0, 0, 0.4), 0 0 0 0.5px var(--border-tertiary); + --shadow-popover: 0 4px 16px rgba(0, 0, 0, 0.4), inset 0 0 0 1px rgba(255, 255, 255, 0.1); +} diff --git a/docs/design-system.md b/docs/design-system.md new file mode 100644 index 0000000..d829e17 --- /dev/null +++ b/docs/design-system.md @@ -0,0 +1,255 @@ +# rustkvm web UI design system + +The web UI follows the layout and visual language of Claude Desktop: a quiet, +warm, low-contrast frame around the one thing that matters, the remote screen. +This document is the rule book. Any change to `crates/rkvm-web` follows it; if +a change needs something it does not cover, extend this file in the same PR. + +Sources: the Claude Desktop design kit in Figma (colors, type, borders, buttons, +inputs, switch, sidebar, titlebar) and the design notes of +[open-claude-ui](https://github.com/Caho1/open-claude-ui) (philosophy only, no +code taken). + +## Principles + +1. **The video comes first.** Nothing may cost the video path a frame. The + `