From 387312c4a26eb8fb58ac01f3c87c352776a4a8bc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 08:27:42 +0000 Subject: [PATCH 1/7] web: Claude Desktop design system and app shell Adds a token-based design system and rebuilds the page frame in the Claude Desktop layout: - style/tokens.css holds every color, type size, radius, shadow and motion value from the Claude Desktop Figma kit, light and dark. An inline boot script applies the saved or OS theme before first paint. - src/ui: Icon (own 20px line glyphs), Button, IconButton, Field, Callout. src/shell: 52px sidebar rail that expands as an overlay, and a 48px titlebar with the remote-desktop actions. - The video stays mounted in a stage; setup, sign-in and connect are cards over it. Errors become a toast; focus mode hides the chrome and peeks the titlebar from a top handle. No blur over the video. - Video uses object-fit: contain; absolute pointer positions map through the letterboxed picture (hid::content_rect). - zh/en switch (Chinese by default) for the shell; settings sheet, terminal and OCR dialog restyled onto the tokens. - docs/design-system.md is the rule book for later UI work. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EtYBDrZbatHFGiL5uUvyY1 --- AGENTS.md | 8 +- crates/rkvm-web/Cargo.toml | 1 + crates/rkvm-web/index.html | 242 +---------- crates/rkvm-web/src/app.rs | 559 +++++++++++++++++--------- crates/rkvm-web/src/hid/mod.rs | 22 +- crates/rkvm-web/src/i18n.rs | 98 +++++ crates/rkvm-web/src/main.rs | 4 + crates/rkvm-web/src/settings.rs | 4 +- crates/rkvm-web/src/shell/mod.rs | 8 + crates/rkvm-web/src/shell/sidebar.rs | 150 +++++++ crates/rkvm-web/src/shell/titlebar.rs | 22 + crates/rkvm-web/src/terminal.rs | 11 +- crates/rkvm-web/src/theme.rs | 41 ++ crates/rkvm-web/src/ui/button.rs | 86 ++++ crates/rkvm-web/src/ui/field.rs | 27 ++ crates/rkvm-web/src/ui/icon.rs | 83 ++++ crates/rkvm-web/src/ui/mod.rs | 11 + crates/rkvm-web/src/util.rs | 14 + crates/rkvm-web/style/base.css | 29 ++ crates/rkvm-web/style/components.css | 123 ++++++ crates/rkvm-web/style/panels.css | 80 ++++ crates/rkvm-web/style/shell.css | 92 +++++ crates/rkvm-web/style/tokens.css | 159 ++++++++ docs/design-system.md | 185 +++++++++ 24 files changed, 1634 insertions(+), 425 deletions(-) create mode 100644 crates/rkvm-web/src/i18n.rs create mode 100644 crates/rkvm-web/src/shell/mod.rs create mode 100644 crates/rkvm-web/src/shell/sidebar.rs create mode 100644 crates/rkvm-web/src/shell/titlebar.rs create mode 100644 crates/rkvm-web/src/theme.rs create mode 100644 crates/rkvm-web/src/ui/button.rs create mode 100644 crates/rkvm-web/src/ui/field.rs create mode 100644 crates/rkvm-web/src/ui/icon.rs create mode 100644 crates/rkvm-web/src/ui/mod.rs create mode 100644 crates/rkvm-web/style/base.css create mode 100644 crates/rkvm-web/style/components.css create mode 100644 crates/rkvm-web/style/panels.css create mode 100644 crates/rkvm-web/style/shell.css create mode 100644 crates/rkvm-web/style/tokens.css create mode 100644 docs/design-system.md diff --git a/AGENTS.md b/AGENTS.md index d828426..ad65c0e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -193,7 +193,11 @@ 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) @@ -203,6 +207,8 @@ main.rs # WASM entry: panic hook + tracing-wasm + mount Leptos └── 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..c61c38f 100644 --- a/crates/rkvm-web/Cargo.toml +++ b/crates/rkvm-web/Cargo.toml @@ -70,6 +70,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..ae8be80 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); @@ -50,7 +54,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,8 +254,11 @@ 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())), } @@ -256,7 +267,7 @@ pub fn App() -> impl IntoView { let do_reboot = move |_| { let confirmed = web_sys::window() - .and_then(|w| w.confirm_with_message("Reboot the device?").ok()) + .and_then(|w| w.confirm_with_message(i.t_now("重启设备?", "Reboot the device?")).ok()) .unwrap_or(false); if !confirmed { return; @@ -285,14 +296,18 @@ 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: ") + ))), } }); }; @@ -302,13 +317,13 @@ pub fn App() -> impl IntoView { 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 +343,357 @@ 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()} - -
- - - +
+
} } diff --git a/crates/rkvm-web/src/settings_advanced.rs b/crates/rkvm-web/src/settings_advanced.rs index 378fae2..a7dd6fb 100644 --- a/crates/rkvm-web/src/settings_advanced.rs +++ b/crates/rkvm-web/src/settings_advanced.rs @@ -6,6 +6,7 @@ use serde_json::{Value, json}; use crate::i18n::{tr, use_i18n}; use crate::rpc::RpcClient; +use crate::settings_nav::SettingsNav; use crate::util::{Rpc, as_string, fire, fire_status, json_str, json_string_list}; /// Extensions the device knows how to mount (`setActiveExtension`). @@ -16,7 +17,7 @@ const EXTENSIONS: &[(&str, &str, &str)] = &[ ]; #[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()); @@ -289,7 +290,7 @@ pub fn AdvancedSettings(open: RwSignal, rpc: Rpc) -> impl IntoView { }; view! { -
+

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

-
+

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

{move || i.t("状态", "Status")}
@@ -379,7 +380,7 @@ pub fn AdvancedSettings(open: RwSignal, rpc: Rpc) -> impl IntoView {
{move || cloud_msg.get()}
-
+

"Tailscale"

-
+

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

-
+

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

-
+

"HTTPS"

-
+

{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 @@ -483,13 +484,13 @@ pub fn AdvancedSettings(open: RwSignal, rpc: Rpc) -> impl IntoView {
-
+

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

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

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

{move || i.t("还没有宏。", "No macros yet.")}
@@ -546,7 +547,7 @@ pub fn AdvancedSettings(open: RwSignal, rpc: Rpc) -> impl IntoView {
{move || macro_msg.get()}
-
+

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

+ } + }) + .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/sidebar.rs b/crates/rkvm-web/src/shell/sidebar.rs index b8f3d20..372abc6 100644 --- a/crates/rkvm-web/src/shell/sidebar.rs +++ b/crates/rkvm-web/src/shell/sidebar.rs @@ -1,12 +1,14 @@ +use gloo_timers::callback::Timeout; use leptos::prelude::*; use crate::i18n::{Lang, use_i18n}; use crate::theme::Theme; use crate::ui::{Icon, IconButton, IconName}; -/// Left navigation, as in Claude Desktop: a 52px icon rail that expands to -/// 256px and pushes the content over (the video rescales once; it is not -/// re-laid out per frame). On narrow windows it floats over the content. +/// 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, @@ -24,6 +26,30 @@ pub fn Sidebar( 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! {
@@ -31,7 +57,22 @@ pub fn Sidebar(