diff --git a/.gitignore b/.gitignore index 62d7b33..9c620fe 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ /src-tauri/target/ /src-tauri/gen/ # The bundled LLeMbas CLI: fetched and verified by scripts/fetch-cli.sh, never committed +/src-tauri/binaries/lembas /src-tauri/binaries/lembas-* diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f4d3f9..67de7a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ All notable changes to LLeMbas Desktop. The format follows ## [Unreleased] +### Added + +- The window is LLeMbas's own: a title bar in the LLeMbas look with the server's address, the + agent's state, a menu (the tray's items, plus Reload) and the window buttons. Closing still keeps + the app in the tray. The server's pages stay in a view of their own, with no access to the app. + +### Fixed + +- The app icon is the leaf alone, on a transparent background, instead of a dark square tile. +- The window shows the app's icon instead of a generic one (the window, the menu entry and the + package now share one name, `llembas-desktop`). +- The package is called `llembas-desktop` (it was `l-le-mbas`) and replaces the old one. +- The bundled LLeMbas CLI is installed in the app's own directory instead of as `/usr/bin/lembas`, + where it could hide, or be hidden by, a `lembas` installed by hand. +- `SHA256SUMS` on the GitHub release no longer lists itself. + ## [0.1.0] — 2026-10-14 The first alpha, for Linux. It carries LLeMbas CLI 1.1.0. diff --git a/README.md b/README.md index 0014364..277d204 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,9 @@ Your [LLeMbas](https://llembas.eu) server in a window of its own, with the agent - **The agent, built in.** LLeMbas Desktop carries the [LLeMbas CLI](https://github.com/LLeMbas/LLeMbas-CLI) and runs it in the background as this computer's device, so agent chats started on the server can work in your projects here, without a terminal. -- **In the tray**: close the window and the agent keeps running; the tray menu starts and stops it, - links the computer, and changes the server. +- **Its own window**: a LLeMbas title bar with the agent's state and a menu (start and stop the + agent, link the computer, reload, change server, quit). Close the window and the agent keeps + running in the tray, which has the same menu. > **Alpha.** Linux first (`.deb` and AppImage, x86-64). Windows and macOS later. @@ -19,12 +20,12 @@ Download the `.deb` or the AppImage from the `SHA256SUMS` in the same release. ```sh -sudo apt install ./LLeMbas_*_amd64.deb # Debian, Ubuntu -chmod +x LLeMbas_*.AppImage && ./LLeMbas_*.AppImage +sudo apt install ./llembas-desktop_*_amd64.deb # Debian, Ubuntu (replaces 0.1.0's l-le-mbas) +chmod +x llembas-desktop_*.AppImage && ./llembas-desktop_*.AppImage ``` -On GNOME the tray needs the *AppIndicator* extension; without it, closing the window hides the -app, and starting it again brings the window back. +On GNOME the tray needs the *AppIndicator* extension; without it, the title bar's menu has +everything, and starting the app again brings a hidden window back. ## Use @@ -33,7 +34,8 @@ app, and starting it again brings the window back. 3. The agent starts. In the server's new-chat screen, choose this computer for an agent chat. The agent is the LLeMbas CLI's `lembas service`, using the CLI's own configuration -(`~/.config/lembas`). If you also installed the CLI and run `lembas service` yourself, the app sees +(`~/.config/lembas`). The app's copy of the CLI lives in its own directory +(`/usr/lib/llembas-desktop/`), not on your `PATH`. If you also installed the CLI and run `lembas service` yourself, the app sees it and does not start a second one: they are the same device. ## Build diff --git a/scripts/fetch-cli.sh b/scripts/fetch-cli.sh index cbd2f8f..a0dbd02 100755 --- a/scripts/fetch-cli.sh +++ b/scripts/fetch-cli.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Put the LLeMbas CLI that LLeMbas Desktop carries where Tauri's bundler wants it: -# src-tauri/binaries/lembas- +# Put the LLeMbas CLI that LLeMbas Desktop carries where the bundle takes it from: +# src-tauri/binaries/lembas (a resource: installed in the app's own directory, never on PATH) # # scripts/fetch-cli.sh [target-triple] (default: this machine's, from rustc) # @@ -39,6 +39,7 @@ ssh-keygen -Y verify -f "$work/allowed" -I lembas-release -n lembas-release \ || { echo "fetch-cli: $ASSET.gz does not match SHA256SUMS" >&2; exit 1; } mkdir -p src-tauri/binaries -gunzip -c "$work/$ASSET.gz" > "src-tauri/binaries/lembas-$TRIPLE" -chmod 755 "src-tauri/binaries/lembas-$TRIPLE" -echo "fetch-cli: LLeMbas CLI $VERSION ($ASSET) -> src-tauri/binaries/lembas-$TRIPLE" +gunzip -c "$work/$ASSET.gz" > src-tauri/binaries/lembas +chmod 755 src-tauri/binaries/lembas +rm -f src-tauri/binaries/lembas-* # where an earlier version of this script put it +echo "fetch-cli: LLeMbas CLI $VERSION ($ASSET) -> src-tauri/binaries/lembas" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index bb49beb..3c4b644 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1987,7 +1987,7 @@ checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "llembas-desktop" -version = "0.1.0" +version = "0.2.0" dependencies = [ "libc", "serde", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e36623d..64fbb71 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "llembas-desktop" -version = "0.1.0" +version = "0.2.0" description = "LLeMbas Desktop: your LLeMbas server in a window, with the agent on this computer" authors = ["Jaroslav Beneš"] license = "MIT" @@ -17,7 +17,7 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = ["tray-icon"] } +tauri = { version = "2", features = ["tray-icon", "unstable"] } tauri-plugin-dialog = "2" tauri-plugin-opener = "2" tauri-plugin-single-instance = "2" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 93aae47..8b91ffa 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -1,7 +1,15 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", - "description": "The app's own pages (the first-run page). The server's web UI is a remote page and gets no IPC at all: this capability lists no remote URLs.", + "description": "The app's own pages: the title bar and the first-run page. The server's web UI is a remote page in the same window and gets no IPC at all: this capability lists no remote URL. The window permissions are the title bar's: moving, resizing, minimising, maximising and closing (which hides).", "windows": ["main"], - "permissions": ["core:default"] + "permissions": [ + "core:default", + "core:window:allow-start-dragging", + "core:window:allow-start-resize-dragging", + "core:window:allow-internal-toggle-maximize", + "core:window:allow-minimize", + "core:window:allow-toggle-maximize", + "core:window:allow-close" + ] } diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png index 644ca60..a189b0c 100644 Binary files a/src-tauri/icons/128x128.png and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png index a74062a..5e172a9 100644 Binary files a/src-tauri/icons/128x128@2x.png and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png index 486f608..e274c08 100644 Binary files a/src-tauri/icons/32x32.png and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico index 1a3a49f..049459f 100644 Binary files a/src-tauri/icons/icon.ico and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png index 8760565..ea9cb87 100644 Binary files a/src-tauri/icons/icon.png and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/llembas-desktop.desktop b/src-tauri/llembas-desktop.desktop new file mode 100644 index 0000000..c4b4c9b --- /dev/null +++ b/src-tauri/llembas-desktop.desktop @@ -0,0 +1,12 @@ +[Desktop Entry] +Categories={{categories}} +{{#if comment}} +Comment={{comment}} +{{/if}} +Exec={{exec}} +StartupWMClass={{exec}} +Icon={{icon}} +Name=LLeMbas +GenericName=LLM workspace +Terminal=false +Type=Application diff --git a/src-tauri/src/cli.rs b/src-tauri/src/cli.rs index ee71ecc..c446dc0 100644 --- a/src-tauri/src/cli.rs +++ b/src-tauri/src/cli.rs @@ -6,22 +6,38 @@ //! installed by hand on the same computer are one device to the server, not two. use std::path::{Path, PathBuf}; +use std::sync::OnceLock; -/// The bundled binary: Tauri puts an `externalBin` beside the app's own executable. -/// `LLEMBAS_DESKTOP_CLI` points elsewhere, for development. +static BUNDLED: OnceLock = OnceLock::new(); + +/// Where the bundle put the CLI: the app's resource directory (`/usr/lib/llembas-desktop/` from +/// the .deb, inside the AppImage otherwise) — the app's own copy, never on PATH, so it neither +/// shadows nor is shadowed by a `lembas` installed by hand. Set once at start. +pub fn set_bundled(resource_dir: &Path) { + let _ = BUNDLED.set(resource_dir.join(exe_name())); +} + +fn exe_name() -> &'static str { + if cfg!(windows) { + "lembas.exe" + } else { + "lembas" + } +} + +/// The CLI to run: `LLEMBAS_DESKTOP_CLI` (development), else the bundled one, else one beside +/// the app's executable (`cargo tauri dev`). pub fn binary() -> PathBuf { if let Some(p) = std::env::var_os("LLEMBAS_DESKTOP_CLI") { return PathBuf::from(p); } - let name = if cfg!(windows) { - "lembas.exe" - } else { - "lembas" - }; + if let Some(p) = BUNDLED.get().filter(|p| p.exists()) { + return p.clone(); + } std::env::current_exe() .ok() - .and_then(|e| e.parent().map(|d| d.join(name))) - .unwrap_or_else(|| PathBuf::from(name)) + .and_then(|e| e.parent().map(|d| d.join(exe_name()))) + .unwrap_or_else(|| PathBuf::from(exe_name())) } fn home() -> PathBuf { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3416adc..98e9f89 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,8 +1,11 @@ //! LLeMbas Desktop: the server's web UI in a window of its own, and the LLeMbas CLI's agent //! running on this computer, so agent chats can work in its projects without a terminal. //! -//! - **One window, `main`.** Before a server is chosen it shows the app's own first-run page -//! (`ui/`); after, the server itself. Closing it hides it; the tray brings it back. +//! - **One window, `main`, drawn by the app**: no system decoration, the app's own title bar (a +//! local page, `ui/titlebar.html`) on top and the content below it — before a server is chosen +//! the first-run page (`ui/index.html`), after it the server itself. Two webviews in one window, +//! so the server's pages never share a document with the app's controls. Closing hides the +//! window; the tray, the title bar's menu or starting the app again bring it back. //! - **The server's pages get no IPC.** They are remote content, and the one capability lists no //! remote URL. Links that leave the server open in the system browser. //! - **The agent is the CLI's**: `lembas login --json` links this computer, and @@ -20,8 +23,11 @@ use std::sync::Mutex; use serde::Serialize; use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; use tauri::tray::TrayIconBuilder; +use tauri::webview::WebviewBuilder; +use tauri::window::{Color, WindowBuilder}; use tauri::{ - AppHandle, Emitter, Manager, RunEvent, Url, WebviewUrl, WebviewWindowBuilder, WindowEvent, Wry, + AppHandle, Emitter, LogicalPosition, LogicalSize, Manager, PhysicalPosition, PhysicalSize, + RunEvent, Url, Webview, WebviewUrl, Window, WindowEvent, Wry, }; use tauri_plugin_dialog::{DialogExt, MessageDialogKind}; use tauri_plugin_opener::OpenerExt; @@ -30,6 +36,13 @@ use agent::{Agent, State as AgentState}; use settings::Settings; const MAIN: &str = "main"; +const TITLEBAR: &str = "titlebar"; +const CONTENT: &str = "content"; +/// The title bar's height, in logical pixels: `--titlebar-h` in `ui/titlebar.css`. +const TITLE_H: f64 = 36.0; +/// The frame left around the webviews while the window is not maximized: the window's own +/// surface, where the pointer resizes it (a webview over the edge would take the events). +const FRAME: f64 = 4.0; struct App { config_dir: PathBuf, @@ -156,35 +169,92 @@ fn local_page() -> Url { Url::parse(base).expect("a fixed URL") } +fn content(handle: &AppHandle) -> Option { + handle.get_webview(CONTENT) +} + +fn main_window(handle: &AppHandle) -> Option { + handle.get_window(MAIN) +} + /// Show the main window on `server`, or on the first-run page; made when it does not exist. fn open_main(handle: &AppHandle, server: Option<&str>) -> Result<(), String> { let target = match server { Some(s) => Some(Url::parse(s).map_err(|e| format!("{s}: {e}"))?), None => None, }; - if let Some(w) = handle.get_webview_window(MAIN) { - w.navigate(target.unwrap_or_else(local_page)) + if let (Some(w), Some(c)) = (main_window(handle), content(handle)) { + c.navigate(target.unwrap_or_else(local_page)) .map_err(|e| e.to_string())?; let _ = w.show(); let _ = w.unminimize(); let _ = w.set_focus(); return Ok(()); } + build_main(handle, target).map_err(|e| e.to_string()) +} + +fn build_main(handle: &AppHandle, target: Option) -> tauri::Result<()> { + let window = WindowBuilder::new(handle, MAIN) + .title("LLeMbas") + .inner_size(1280.0, 840.0) + .min_inner_size(420.0, 560.0) + .decorations(false) + // The frame's colour: the Moria border, as `--border` in the pages. + .background_color(Color(0x26, 0x30, 0x3A, 0xFF)) + .build()?; + let size = window + .inner_size()? + .to_logical::(window.scale_factor()?); + window.add_child( + WebviewBuilder::new(TITLEBAR, WebviewUrl::App("titlebar.html".into())), + LogicalPosition::new(FRAME, FRAME), + LogicalSize::new(size.width - 2.0 * FRAME, TITLE_H), + )?; let url = match target { Some(u) => WebviewUrl::External(u), None => WebviewUrl::App("index.html".into()), }; let nav = handle.clone(); - WebviewWindowBuilder::new(handle, MAIN, url) - .title("LLeMbas") - .inner_size(1280.0, 840.0) - .min_inner_size(420.0, 560.0) - .on_navigation(move |url| allowed(&nav, url)) - .build() - .map_err(|e| e.to_string())?; + window.add_child( + WebviewBuilder::new(CONTENT, url).on_navigation(move |url| allowed(&nav, url)), + LogicalPosition::new(FRAME, FRAME + TITLE_H), + LogicalSize::new( + size.width - 2.0 * FRAME, + (size.height - TITLE_H - 2.0 * FRAME).max(0.0), + ), + )?; Ok(()) } +/// Lay the two webviews out in the window: the title bar on top, the content under it, and the +/// frame around both unless the window is maximized (then there is nothing to resize). +fn layout(window: &Window) { + let handle = window.app_handle(); + let (Some(bar), Some(body)) = (handle.get_webview(TITLEBAR), handle.get_webview(CONTENT)) + else { + return; + }; + let (Ok(size), Ok(scale)) = (window.inner_size(), window.scale_factor()) else { + return; + }; + let frame = if window.is_maximized().unwrap_or(false) || window.is_fullscreen().unwrap_or(false) + { + 0 + } else { + (FRAME * scale).round() as u32 + }; + let title = (TITLE_H * scale).round() as u32; + let width = size.width.saturating_sub(2 * frame); + let _ = bar.set_position(PhysicalPosition::new(frame, frame)); + let _ = bar.set_size(PhysicalSize::new(width, title)); + let _ = body.set_position(PhysicalPosition::new(frame, frame + title)); + let _ = body.set_size(PhysicalSize::new( + width, + size.height.saturating_sub(title + 2 * frame), + )); +} + /// The window may show the app's own pages and the server; anything else opens in the browser. fn allowed(handle: &AppHandle, url: &Url) -> bool { let local = matches!(url.scheme(), "tauri" | "asset") @@ -250,8 +320,10 @@ fn start_link(handle: &AppHandle) -> Result<(), String> { .and_then(|v| v.as_str()) .unwrap_or(""); if let Ok(url) = Url::parse(link) { - if let Some(w) = handle.get_webview_window(MAIN) { - let _ = w.navigate(url); + if let Some(c) = content(&handle) { + let _ = c.navigate(url); + } + if let Some(w) = main_window(&handle) { let _ = w.show(); let _ = w.set_focus(); } @@ -304,7 +376,7 @@ fn status_of(handle: &AppHandle) -> Status { fn show(handle: &AppHandle) { let server = handle.state::().settings().server; - if let Some(w) = handle.get_webview_window(MAIN) { + if let Some(w) = main_window(handle) { let _ = w.show(); let _ = w.unminimize(); let _ = w.set_focus(); @@ -341,35 +413,7 @@ fn build_tray(handle: &AppHandle) -> tauri::Result { )?; let mut tray = TrayIconBuilder::with_id("tray") .tooltip("LLeMbas") - .menu(&menu) - .on_menu_event(|h, ev| match ev.id().as_ref() { - "open" => show(h), - "toggle" => { - let on = !h.state::().agent.wanted(); - if let Err(e) = set_agent_wanted(h, on) { - h.dialog() - .message(e) - .kind(MessageDialogKind::Error) - .show(|_| {}); - } - } - "link" => { - if let Err(e) = start_link(h) { - h.dialog() - .message(e) - .kind(MessageDialogKind::Warning) - .show(|_| {}); - } - } - "server" => { - let _ = open_main(h, None); - } - "quit" => { - h.state::().agent.stop(); - h.exit(0); - } - _ => {} - }); + .menu(&menu); if let Some(icon) = handle.default_window_icon() { tray = tray.icon(icon.clone()); } @@ -377,6 +421,85 @@ fn build_tray(handle: &AppHandle) -> tauri::Result { Ok(TrayItems { status, toggle }) } +/// A menu item chosen, from the tray or from the title bar's menu: the same ids in both. +fn on_menu(h: &AppHandle, id: &str) { + match id { + "open" => show(h), + "toggle" => { + let on = !h.state::().agent.wanted(); + if let Err(e) = set_agent_wanted(h, on) { + h.dialog() + .message(e) + .kind(MessageDialogKind::Error) + .show(|_| {}); + } + } + "link" => { + if let Err(e) = start_link(h) { + h.dialog() + .message(e) + .kind(MessageDialogKind::Warning) + .show(|_| {}); + } + } + "reload" => { + if let Some(c) = content(h) { + if let Ok(url) = c.url() { + let _ = c.navigate(url); + } + } + } + "server" => { + let _ = open_main(h, None); + } + "quit" => { + h.state::().agent.stop(); + h.exit(0); + } + _ => {} + } +} + +/// The title bar's menu: the tray's items, where the window is (a desktop without a tray — +/// GNOME without the AppIndicator extension — has no other way to reach them). +#[tauri::command] +fn titlebar_menu(window: Window) -> Result<(), String> { + let h = window.app_handle(); + let app = h.state::(); + let m = |id: &str, text: &str, on: bool| MenuItem::with_id(h, id, text, on, None::<&str>); + let build = || -> tauri::Result> { + let status = m("status", &app.agent.state().label(), false)?; + let toggle = m( + "toggle", + if app.agent.wanted() { + "Stop the agent" + } else { + "Start the agent" + }, + true, + )?; + let link = m("link", "Link this computer…", true)?; + let reload = m("reload", "Reload", true)?; + let server = m("server", "Change server…", true)?; + let quit = m("quit", "Quit LLeMbas", true)?; + Menu::with_items( + h, + &[ + &status, + &toggle, + &link, + &PredefinedMenuItem::separator(h)?, + &reload, + &server, + &PredefinedMenuItem::separator(h)?, + &quit, + ], + ) + }; + let menu = build().map_err(|e| e.to_string())?; + window.popup_menu(&menu).map_err(|e| e.to_string()) +} + fn on_agent_state(handle: &AppHandle, state: &AgentState) { let app = handle.state::(); if let Some(t) = app.tray.lock().unwrap().as_ref() { @@ -399,11 +522,16 @@ pub fn run() { get_status, set_server, set_agent, - link_computer + link_computer, + titlebar_menu ]) + .on_menu_event(|h, ev| on_menu(h, ev.id().as_ref())) .setup(|app| { let handle = app.handle().clone(); let config_dir = app.path().app_config_dir()?; + if let Ok(dir) = app.path().resource_dir() { + cli::set_bundled(&dir); + } let log = app.path().app_log_dir()?.join("agent.log"); let notify_handle = handle.clone(); let agent = Agent::new(log, move |s| { @@ -428,14 +556,23 @@ pub fn run() { } Ok(()) }) - .on_window_event(|window, event| { + .on_window_event(|window, event| match event { // Closing the window keeps the app (and the agent) in the tray. - if let WindowEvent::CloseRequested { api, .. } = event { - if window.label() == MAIN { - let _ = window.hide(); - api.prevent_close(); - } + WindowEvent::CloseRequested { api, .. } if window.label() == MAIN => { + let _ = window.hide(); + api.prevent_close(); } + WindowEvent::Resized(_) | WindowEvent::ScaleFactorChanged { .. } + if window.label() == MAIN => + { + layout(window); + let _ = window.emit_to( + TITLEBAR, + "maximized", + window.is_maximized().unwrap_or(false), + ); + } + _ => {} }) .build(tauri::generate_context!()) .expect("LLeMbas Desktop could not start") diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 069ea5a..b0d0688 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,6 +1,6 @@ { "$schema": "https://schema.tauri.app/config/2", - "productName": "LLeMbas", + "productName": "llembas-desktop", "mainBinaryName": "llembas-desktop", "identifier": "eu.llembas.desktop", "build": { @@ -15,7 +15,10 @@ }, "bundle": { "active": true, - "targets": ["deb", "appimage"], + "targets": [ + "deb", + "appimage" + ], "category": "Productivity", "shortDescription": "Your LLeMbas server in a window, with the agent on this computer", "longDescription": "LLeMbas Desktop opens your LLeMbas server's web UI in a window of its own and runs the LLeMbas CLI agent on this computer, so agent chats can work in your projects here without a terminal.", @@ -30,6 +33,19 @@ "icons/icon.png", "icons/icon.ico" ], - "externalBin": ["binaries/lembas"] + "resources": { + "binaries/lembas": "lembas" + }, + "linux": { + "deb": { + "desktopTemplate": "llembas-desktop.desktop", + "conflicts": [ + "l-le-mbas" + ], + "replaces": [ + "l-le-mbas" + ] + } + } } } diff --git a/ui/app.css b/ui/app.css index c58ab8f..28ccd05 100644 --- a/ui/app.css +++ b/ui/app.css @@ -1,55 +1,4 @@ -/* The first-run page: the LLeMbas look (Moria dark, Shire light), no build step. */ -:root { - --font-ui: ui-monospace, "JetBrains Mono", "SF Mono", "Cascadia Code", Menlo, Consolas, monospace; - --font-body: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; - --text-sm: 0.8125rem; - --text-base: 0.9375rem; - --text-xl: 1.5rem; - --sp-2: 0.5rem; - --sp-3: 0.75rem; - --sp-4: 1rem; - --sp-6: 1.5rem; - --sp-8: 2rem; - --radius: 2px; - --press: 2px; - --control-h: 2.5rem; - --card-max: 34rem; - - color-scheme: dark; - --bg: #0B0D10; - --surface: #11151A; - --surface-hover: #1D232B; - --border: #26303A; - --border-strong: #5C6976; - --ink: #E2E8EC; - --ink-muted: #A6B2BC; - --accent: #8FD8E8; - --accent-hover: #B4E6F1; - --accent-ink: #061418; - --leaf: #7EE081; - --warning: #F0A840; - --shadow-card: 4px 4px 0 rgba(0, 0, 0, 0.55); -} - -@media (prefers-color-scheme: light) { - :root { - color-scheme: light; - --bg: #F1EAD6; - --surface: #F8F3E5; - --surface-hover: #ECE4CF; - --border: #CBBF9D; - --border-strong: #2A2E22; - --ink: #1F2418; - --ink-muted: #474D3B; - --accent: #285F3C; - --accent-hover: #1F4D30; - --accent-ink: #F8F3E5; - --leaf: #3D6B1C; - --warning: #7E5200; - --shadow-card: 4px 4px 0 var(--border-strong); - } -} - +/* The first-run page. Tokens in tokens.css. */ * { box-sizing: border-box; } html, body { margin: 0; min-height: 100%; } diff --git a/ui/index.html b/ui/index.html index e21ddad..cb23648 100644 --- a/ui/index.html +++ b/ui/index.html @@ -5,6 +5,7 @@ LLeMbas + diff --git a/ui/titlebar.css b/ui/titlebar.css new file mode 100644 index 0000000..c1ad606 --- /dev/null +++ b/ui/titlebar.css @@ -0,0 +1,83 @@ +/* The title bar: one row, fixed height, every slot a fixed or shared track so nothing in it + moves when the host name or the agent's state changes length. */ +* { box-sizing: border-box; } + +html, body { + margin: 0; + height: var(--titlebar-h); + overflow: hidden; + background: var(--bg); + color: var(--ink); + user-select: none; + -webkit-user-select: none; + cursor: default; +} + +.bar { + display: grid; + /* leaf · name · host (takes the rest, and is the drag area) · agent · menu · sep · 3 controls */ + grid-template-columns: var(--titlebar-h) auto minmax(0, 1fr) auto var(--titlebar-h) 1px repeat(3, 2.875rem); + align-items: center; + height: var(--titlebar-h); + border-bottom: 1px solid var(--border); + font: 600 var(--text-sm) / 1 var(--font-ui); +} + +.bar__leaf { justify-self: center; width: 18px; height: 18px; image-rendering: pixelated; } +.bar__name b { color: var(--leaf); font-weight: 700; } +.bar__host { + padding: 0 var(--sp-3); + color: var(--ink-muted); + font-weight: 400; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + align-self: stretch; + display: flex; + align-items: center; +} + +button { + height: var(--titlebar-h); + margin: 0; + padding: 0; + font: inherit; + color: var(--ink-muted); + background: transparent; + border: 0; + border-radius: 0; + cursor: pointer; +} +button:hover { color: var(--ink); background: var(--surface-hover); } +button:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } + +.bar__agent { + display: grid; + grid-template-columns: 0.5rem auto; + gap: var(--sp-2); + align-items: center; + padding: 0 var(--sp-3); + font-weight: 400; +} +.dot { width: 0.5rem; height: 0.5rem; background: var(--ink-muted); } +.bar__agent[data-state="running"] .dot, +.bar__agent[data-state="elsewhere"] .dot { background: var(--leaf); } +.bar__agent[data-state="starting"] .dot { background: var(--accent); } +.bar__agent[data-state="failed"] .dot, +.bar__agent[data-state="not_linked"] .dot { background: var(--warning); } + +.bar__sep { width: 1px; height: 1.25rem; background: var(--border); } + +.bar__btn { display: grid; place-items: center; } +.bar__btn svg { width: 16px; height: 16px; } +.bar__btn--close:hover { color: #fff; background: var(--danger); } + +#maximize .i-restore { display: none; } +#maximize[data-maximized] .i-max { display: none; } +#maximize[data-maximized] .i-restore { display: block; } + +/* Narrow windows: the host goes first, then the agent's words (its dot stays). */ +@media (max-width: 34rem) { + .bar__host { visibility: hidden; } + #agent-text { display: none; } +} diff --git a/ui/titlebar.html b/ui/titlebar.html new file mode 100644 index 0000000..5923777 --- /dev/null +++ b/ui/titlebar.html @@ -0,0 +1,36 @@ + + + + + +LLeMbas + + + + + +
+ + LLeMbas + + + + + + + + +
+ + diff --git a/ui/titlebar.js b/ui/titlebar.js new file mode 100644 index 0000000..d0deac2 --- /dev/null +++ b/ui/titlebar.js @@ -0,0 +1,50 @@ +// The title bar. Moving and double-click maximising come from `data-tauri-drag-region`; the +// buttons call the window directly; the menu and the agent pill open the app's native menu (the +// tray's items), which can reach past this 36-pixel-high page. +"use strict" + +const { invoke } = window.__TAURI__.core +const { listen } = window.__TAURI__.event +const win = window.__TAURI__.window.getCurrentWindow() + +const $ = (id) => document.getElementById(id) + +const WORDS = { + off: "agent off", + not_linked: "not linked", + starting: "agent starting…", + running: "agent running", + elsewhere: "agent running (service)", + failed: "agent stopped", +} + +function render(s) { + let host = "" + try { + host = s.server ? new URL(s.server).host : "" + } catch {} + $("host").textContent = host + $("host").title = s.server || "" + const state = s.agent.state + $("agent").dataset.state = state + $("agent-text").textContent = WORDS[state] ?? state + $("agent").title = state === "failed" ? `Agent stopped: ${s.agent.detail}` : "Agent" +} + +function maximized(on) { + const b = $("maximize") + b.toggleAttribute("data-maximized", on) + b.setAttribute("aria-label", on ? "Restore" : "Maximise") + b.title = on ? "Restore" : "Maximise" +} + +$("minimize").addEventListener("click", () => win.minimize()) +$("maximize").addEventListener("click", () => win.toggleMaximize()) +$("close").addEventListener("click", () => win.close()) +$("menu").addEventListener("click", () => invoke("titlebar_menu")) +$("agent").addEventListener("click", () => invoke("titlebar_menu")) + +listen("status", (e) => render(e.payload)) +listen("maximized", (e) => maximized(!!e.payload)) +invoke("get_status").then(render) +win.isMaximized().then(maximized) diff --git a/ui/tokens.css b/ui/tokens.css new file mode 100644 index 0000000..9c21d2c --- /dev/null +++ b/ui/tokens.css @@ -0,0 +1,54 @@ +/* LLeMbas Desktop's own pages: one set of tokens (Moria dark, Shire light), as on llembas.eu. */ +:root { + --font-ui: ui-monospace, "JetBrains Mono", "SF Mono", "Cascadia Code", Menlo, Consolas, monospace; + --font-body: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + --text-sm: 0.8125rem; + --text-base: 0.9375rem; + --text-xl: 1.5rem; + --sp-2: 0.5rem; + --sp-3: 0.75rem; + --sp-4: 1rem; + --sp-6: 1.5rem; + --sp-8: 2rem; + --radius: 2px; + --press: 2px; + --control-h: 2.5rem; + --card-max: 34rem; + + color-scheme: dark; + --bg: #0B0D10; + --surface: #11151A; + --surface-hover: #1D232B; + --border: #26303A; + --border-strong: #5C6976; + --ink: #E2E8EC; + --ink-muted: #A6B2BC; + --accent: #8FD8E8; + --accent-hover: #B4E6F1; + --accent-ink: #061418; + --leaf: #7EE081; + --warning: #F0A840; + --shadow-card: 4px 4px 0 rgba(0, 0, 0, 0.55); + --danger: #E5534B; + --titlebar-h: 36px; +} + +@media (prefers-color-scheme: light) { + :root { + color-scheme: light; + --bg: #F1EAD6; + --surface: #F8F3E5; + --surface-hover: #ECE4CF; + --border: #CBBF9D; + --border-strong: #2A2E22; + --ink: #1F2418; + --ink-muted: #474D3B; + --accent: #285F3C; + --accent-hover: #1F4D30; + --accent-ink: #F8F3E5; + --leaf: #3D6B1C; + --warning: #7E5200; + --shadow-card: 4px 4px 0 var(--border-strong); + --danger: #B3261E; + } +}