3 Commits
Author SHA1 Message Date
HomerandClaude Opus 5.5 971e1ddc9f LLeMbas Desktop 0.2.0
ci / check (push) Failing after 3s
release / linux (push) Failing after 2s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:56:46 +00:00
HomerandClaude Opus 5.5 daf2cf31ce LLeMbas's own window, the leaf icon, one name for the app
ci / check (push) Failing after 3s
- The window has no system decoration: a local title bar webview (agent
  state, a native menu with the tray's items, window buttons) above the
  content webview, which stays remote with no IPC. Laid out on resize,
  with a frame to resize by unless maximized.
- Icons from the transparent pixel leaf, not the server's tiled icon.
- productName llembas-desktop: package, .desktop file and the window's
  app id now match (KWin showed a generic icon); the visible name stays
  LLeMbas through a desktop template. Replaces the 0.1.0 package l-le-mbas.
- The bundled CLI is a resource in the app's own directory, not
  /usr/bin/lembas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:54:55 +00:00
HomerandClaude Opus 5.5 5045ba025a release: SHA256SUMS no longer lists itself
ci / check (push) Failing after 3s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:36:11 +00:00
23 changed files with 519 additions and 133 deletions

No files matched your search

+3 -1
View File
@@ -53,7 +53,9 @@ jobs:
mkdir dist
cp src-tauri/target/release/bundle/deb/*.deb src-tauri/target/release/bundle/appimage/*.AppImage dist/
cd dist
{ echo "# llembas-desktop $VERSION"; sha256sum -- *; } > SHA256SUMS
# Written beside the folder first: inside it, the glob would list SHA256SUMS itself.
{ echo "# llembas-desktop $VERSION"; sha256sum -- *; } > ../SHA256SUMS
mv ../SHA256SUMS .
cat SHA256SUMS
- name: Publish the GitHub Release
env:
+1
View File
@@ -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-*
+18
View File
@@ -6,6 +6,24 @@ All notable changes to LLeMbas Desktop. The format follows
## [Unreleased]
## [0.2.0] — 2026-10-14
### 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.
+9 -7
View File
@@ -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
+6 -5
View File
@@ -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-<target triple>
# 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"
+1 -1
View File
@@ -1987,7 +1987,7 @@ checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
[[package]]
name = "llembas-desktop"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"libc",
"serde",
+2 -2
View File
@@ -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"
+10 -2
View File
@@ -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"
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 827 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 515 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

+12
View File
@@ -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
+25 -9
View File
@@ -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<PathBuf> = 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 {
+188 -51
View File
@@ -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<Webview> {
handle.get_webview(CONTENT)
}
fn main_window(handle: &AppHandle) -> Option<Window> {
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<Url>) -> 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::<f64>(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::<App>().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<TrayItems> {
)?;
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::<App>().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::<App>().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<TrayItems> {
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::<App>().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::<App>().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::<App>();
let m = |id: &str, text: &str, on: bool| MenuItem::with_id(h, id, text, on, None::<&str>);
let build = || -> tauri::Result<Menu<Wry>> {
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::<App>();
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")
+19 -3
View File
@@ -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"
]
}
}
}
}
+1 -52
View File
@@ -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%; }
+1
View File
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark light">
<title>LLeMbas</title>
<link rel="stylesheet" href="tokens.css">
<link rel="stylesheet" href="app.css">
<script src="app.js" defer></script>
</head>
+83
View File
@@ -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; }
}
+36
View File
@@ -0,0 +1,36 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="color-scheme" content="dark light">
<title>LLeMbas</title>
<link rel="stylesheet" href="tokens.css">
<link rel="stylesheet" href="titlebar.css">
<script src="titlebar.js" defer></script>
</head>
<body>
<header class="bar" data-tauri-drag-region>
<img class="bar__leaf" src="logo-mark.svg" alt="" width="18" height="18" data-tauri-drag-region>
<span class="bar__name" data-tauri-drag-region><b>LL</b>e<b>M</b>bas</span>
<span class="bar__host" id="host" data-tauri-drag-region></span>
<button class="bar__agent" id="agent" type="button" title="Agent">
<span class="dot" aria-hidden="true"></span><span id="agent-text">agent off</span>
</button>
<button class="bar__btn" id="menu" type="button" aria-label="Menu" title="Menu">
<svg viewBox="0 0 16 16" aria-hidden="true"><g fill="currentColor"><rect x="3" y="4" width="10" height="1.5"/><rect x="3" y="7.25" width="10" height="1.5"/><rect x="3" y="10.5" width="10" height="1.5"/></g></svg>
</button>
<span class="bar__sep" aria-hidden="true"></span>
<button class="bar__btn" id="minimize" type="button" aria-label="Minimise" title="Minimise">
<svg viewBox="0 0 16 16" aria-hidden="true"><rect x="4" y="10" width="8" height="1.5" fill="currentColor"/></svg>
</button>
<button class="bar__btn" id="maximize" type="button" aria-label="Maximise" title="Maximise">
<svg class="i-max" viewBox="0 0 16 16" aria-hidden="true"><path d="M4.75 4.75h6.5v6.5h-6.5z" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>
<svg class="i-restore" viewBox="0 0 16 16" aria-hidden="true"><path d="M3.75 6.25h6v6h-6z M6.25 6.25v-2.5h6v6h-2.5" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>
</button>
<button class="bar__btn bar__btn--close" id="close" type="button" aria-label="Close" title="Close (LLeMbas stays in the tray)">
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4.5 4.5l7 7M11.5 4.5l-7 7" stroke="currentColor" stroke-width="1.5"/></svg>
</button>
</header>
</body>
</html>
+50
View File
@@ -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)
+54
View File
@@ -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;
}
}