fix a bunch of issues

This commit is contained in:
Alexander Daichendt 2026-06-06 18:22:16 +02:00
parent 91f88dc9ef
commit 2c3df28c48
66 changed files with 1546 additions and 542 deletions

1
.gitignore vendored
View file

@ -1,3 +1,4 @@
/dist/ /dist/
/target/ /target/
/Cargo.lock /Cargo.lock
/tmp/

115
README.md
View file

@ -1,7 +1,114 @@
# Tauri + Leptos # Razer Linux Desktop
This template should help get you started developing with Tauri and Leptos. A Tauri + Leptos desktop app for configuring supported Razer devices on Linux.
## Recommended IDE Setup ## CachyOS / Arch Setup
[VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer). Install the Tauri Linux build dependencies:
```sh
sudo pacman -S --needed \
webkit2gtk-4.1 \
base-devel \
curl \
wget \
file \
openssl \
appmenu-gtk-module \
libappindicator-gtk3 \
librsvg \
xdotool
```
Make sure the Rust Tauri CLI and Trunk are available:
```sh
cargo install tauri-cli --locked
cargo install trunk --locked
```
## Build
From this directory:
```sh
NO_STRIP=true cargo tauri build
```
`NO_STRIP=true` avoids a known AppImage bundling failure on rolling Linux
distributions where linuxdeploy's bundled `strip` can fail on newer ELF
sections.
The AppImage is written under:
```text
target/release/bundle/appimage/
```
## Install Desktop Launcher
This installs the built AppImage into a stable per-user path and creates a
desktop launcher. The launcher continues to work after updates because the
AppImage is always copied to the same filename.
```sh
appimage="$(find target/release/bundle/appimage -maxdepth 1 -name 'razer-linux-desktop_*_amd64.AppImage' -print -quit)"
mkdir -p "$HOME/.local/opt/razer-linux-desktop"
mkdir -p "$HOME/.local/share/applications"
mkdir -p "$HOME/.local/share/icons/hicolor/128x128/apps"
install -m 0755 "$appimage" \
"$HOME/.local/opt/razer-linux-desktop/razer-linux-desktop.AppImage"
install -m 0644 src-tauri/icons/128x128.png \
"$HOME/.local/share/icons/hicolor/128x128/apps/one.daichendt.razer-linux-desktop.png"
cat > "$HOME/.local/share/applications/one.daichendt.razer-linux-desktop.desktop" <<EOF
[Desktop Entry]
Type=Application
Name=Razer Linux Desktop
Comment=Configure Razer devices on Linux
Exec=$HOME/.local/opt/razer-linux-desktop/razer-linux-desktop.AppImage
Icon=one.daichendt.razer-linux-desktop
Terminal=false
Categories=Settings;HardwareSettings;
StartupNotify=true
EOF
update-desktop-database "$HOME/.local/share/applications" 2>/dev/null || true
gtk-update-icon-cache "$HOME/.local/share/icons/hicolor" 2>/dev/null || true
kbuildsycoca6 2>/dev/null || true
```
After this, the app should appear in the desktop environment launcher as
`Razer Linux Desktop`.
## Update Installed App
Pull the latest source, rebuild, and replace the stable AppImage:
```sh
git pull --ff-only
NO_STRIP=true cargo tauri build
appimage="$(find target/release/bundle/appimage -maxdepth 1 -name 'razer-linux-desktop_*_amd64.AppImage' -print -quit)"
install -m 0755 "$appimage" \
"$HOME/.local/opt/razer-linux-desktop/razer-linux-desktop.AppImage"
```
The desktop launcher does not need to be recreated unless the launcher metadata
or icon changes.
## Uninstall
```sh
rm -f "$HOME/.local/share/applications/one.daichendt.razer-linux-desktop.desktop"
rm -f "$HOME/.local/share/icons/hicolor/128x128/apps/one.daichendt.razer-linux-desktop.png"
rm -rf "$HOME/.local/opt/razer-linux-desktop"
update-desktop-database "$HOME/.local/share/applications" 2>/dev/null || true
gtk-update-icon-cache "$HOME/.local/share/icons/hicolor" 2>/dev/null || true
kbuildsycoca6 2>/dev/null || true
```

View file

@ -2,6 +2,7 @@
<html> <html>
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="color-scheme" content="light dark" />
<title>Razer Basilisk V3 Onboard Memory Tools</title> <title>Razer Basilisk V3 Onboard Memory Tools</title>
<link data-trunk rel="css" href="styles/structure.css" /> <link data-trunk rel="css" href="styles/structure.css" />
<link data-trunk rel="css" href="styles/panels.css" /> <link data-trunk rel="css" href="styles/panels.css" />

BIN
public/app-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 589 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 974 B

After

Width:  |  Height:  |  Size: 2 KiB

Before After
Before After

BIN
src-tauri/icons/64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 903 B

After

Width:  |  Height:  |  Size: 1.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 4 KiB

Before After
Before After

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 864 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 427 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View file

@ -8,10 +8,6 @@ fn BasicPanel(
) -> impl IntoView { ) -> impl IntoView {
let basic = snapshot.basic.clone(); let basic = snapshot.basic.clone();
let initial_dpi_stages = basic.dpi_stages.clone(); let initial_dpi_stages = basic.dpi_stages.clone();
let initial_active_stage = basic.active_dpi_stage;
let stage_list_for_count = initial_dpi_stages.clone();
let stage_list_for_active = initial_dpi_stages.clone();
let stage_list_for_copy = initial_dpi_stages.clone();
let apply_dpi_stages = move |dpi_stages: Vec<[u16; 2]>, active_stage: u8| { let apply_dpi_stages = move |dpi_stages: Vec<[u16; 2]>, active_stage: u8| {
run_setting( run_setting(
@ -53,8 +49,7 @@ fn BasicPanel(
); );
}; };
let apply_polling = move |ev: Event| { let apply_polling = move |value: u8| {
let value = event_target_value(&ev).parse::<u8>().unwrap_or(1).max(1);
run_setting( run_setting(
"set_polling_rate", "set_polling_rate",
PollingRateArgs { PollingRateArgs {
@ -67,70 +62,6 @@ fn BasicPanel(
); );
}; };
let set_dpi_x = move |ev: Event| {
let x = event_target_value(&ev)
.parse::<u16>()
.unwrap_or(basic.dpi_xy[0]);
run_setting(
"set_dpi_xy",
DpiArgs {
profile: selected_profile.get_untracked(),
x,
y: basic.dpi_xy[1],
},
set_snapshot,
set_status,
set_busy,
);
};
let set_dpi_y = move |ev: Event| {
let y = event_target_value(&ev)
.parse::<u16>()
.unwrap_or(basic.dpi_xy[1]);
run_setting(
"set_dpi_xy",
DpiArgs {
profile: selected_profile.get_untracked(),
x: basic.dpi_xy[0],
y,
},
set_snapshot,
set_status,
set_busy,
);
};
let set_stage_count = move |ev: Event| {
let requested = event_target_value(&ev)
.parse::<usize>()
.unwrap_or(stage_list_for_count.len())
.clamp(1, 5);
let mut stages = stage_list_for_count.clone();
stages.truncate(requested);
while stages.len() < requested {
stages.push([800, 800]);
}
let active_stage = initial_active_stage.min(requested as u8).max(1);
apply_dpi_stages(stages, active_stage);
};
let set_active_stage = move |ev: Event| {
let active_stage = event_target_value(&ev)
.parse::<u8>()
.unwrap_or(initial_active_stage)
.clamp(1, stage_list_for_active.len() as u8);
apply_dpi_stages(stage_list_for_active.clone(), active_stage);
};
let copy_stage_y_from_x = move |_| {
let stages = stage_list_for_copy
.iter()
.map(|[x, _]| [*x, *x])
.collect::<Vec<_>>();
apply_dpi_stages(stages, initial_active_stage);
};
view! { view! {
<section class="panel-grid"> <section class="panel-grid">
<article class="panel"> <article class="panel">
@ -169,97 +100,186 @@ fn BasicPanel(
<article class="panel"> <article class="panel">
<h3>"Polling"</h3> <h3>"Polling"</h3>
<p class="subtle">{format!("Report every {} ms.", basic.polling_rate_ms)}</p> <div class="polling-options" role="group" aria-label="Polling rate">
<label class="field inline"> {[(16_u8, "63 Hz"), (8, "125 Hz"), (4, "250 Hz"), (2, "500 Hz"), (1, "1000 Hz")]
<span>"Polling rate"</span> .into_iter()
<select prop:value=basic.polling_rate_ms on:change=apply_polling> .map(|(value, label)| view! {
<option value="16">"63 Hz"</option> <button
<option value="8">"125 Hz"</option> type="button"
<option value="4">"250 Hz"</option> class:active=basic.polling_rate_ms == value
<option value="2">"500 Hz"</option> on:click=move |_| apply_polling(value)
<option value="1">"1000 Hz"</option> >
</select> {label}
</label> </button>
<p class="metric">{format!("{} Hz", 1000 / u16::from(basic.polling_rate_ms.max(1)))}</p> })
.collect_view()}
</div>
</article> </article>
<article class="panel wide"> <article class="panel wide dpi-panel">
<h3>"DPI"</h3> <div class="dpi-card-header">
<div class="dpi-editor">
<label class="field">
<span>"Current X"</span>
<input type="number" min="100" max="25600" step="100" prop:value=basic.dpi_xy[0] on:change=set_dpi_x />
</label>
<label class="field">
<span>"Current Y"</span>
<input type="number" min="100" max="25600" step="100" prop:value=basic.dpi_xy[1] on:change=set_dpi_y />
</label>
<div> <div>
<span class="subtle">"Active stage"</span> <h3>"DPI Stages"</h3>
<p class="metric">{basic.active_dpi_stage}</p> <p class="subtle">"Click a stage to make it active. Edit one DPI value for normal X/Y-linked stages."</p>
</div> </div>
</div> </div>
<div class="dpi-controls">
<label class="field inline">
<span>"Stages"</span>
<input type="number" min="1" max="5" prop:value=basic.dpi_stages.len() on:change=set_stage_count />
</label>
<label class="field inline">
<span>"Active"</span>
<input type="number" min="1" max="5" prop:value=basic.active_dpi_stage on:change=set_active_stage />
</label>
<button type="button" class="secondary-action" on:click=copy_stage_y_from_x>"Y = X"</button>
</div>
<div class="stage-list"> <div class="stage-list">
{basic.dpi_stages.iter().enumerate().map(|(index, stage)| { {basic.dpi_stages.iter().enumerate().map(|(index, stage)| {
let stage_index = index; let stage_index = index;
let stage_number = (stage_index + 1) as u8;
let label = format!("Stage {}", stage_index + 1); let label = format!("Stage {}", stage_index + 1);
let is_active = index + 1 == usize::from(basic.active_dpi_stage);
let stage_list_for_select = basic.dpi_stages.clone();
let stage_list_for_delete = basic.dpi_stages.clone();
let stage_list_for_linked = basic.dpi_stages.clone();
let stage_list_for_x = basic.dpi_stages.clone(); let stage_list_for_x = basic.dpi_stages.clone();
let stage_list_for_y = basic.dpi_stages.clone(); let stage_list_for_y = basic.dpi_stages.clone();
let active_stage_for_x = basic.active_dpi_stage; let active_stage = basic.active_dpi_stage;
let active_stage_for_y = basic.active_dpi_stage; let can_delete = basic.dpi_stages.len() > 1;
view! { view! {
<div class:active=index + 1 == usize::from(basic.active_dpi_stage)> <section
<span>{label}</span> class="dpi-stage-card"
<label class="field"> class:active=is_active
<span>"X"</span> on:click=move |_| apply_dpi_stages(stage_list_for_select.clone(), stage_number)
>
<div class="dpi-stage-card-header">
<span>{label}</span>
{if is_active {
view! { <strong>"Active"</strong> }.into_any()
} else {
view! {}.into_any()
}}
{if can_delete {
view! {
<button
type="button"
class="stage-delete"
aria-label=format!("Delete stage {}", stage_number)
title="Delete stage"
on:click=move |ev| {
ev.stop_propagation();
let mut stages = stage_list_for_delete.clone();
stages.remove(stage_index);
let next_len = stages.len() as u8;
let next_active = if active_stage == stage_number {
stage_number.min(next_len)
} else if active_stage > stage_number {
active_stage.saturating_sub(1)
} else {
active_stage
}
.max(1);
apply_dpi_stages(stages, next_active);
}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M3 6h18" />
<path d="M8 6V4h8v2" />
<path d="M6 6l1 18h10l1-18" />
<path d="M10 11v6" />
<path d="M14 11v6" />
</svg>
</button>
}.into_any()
} else {
view! {}.into_any()
}}
</div>
<label class="field" on:click=move |ev| ev.stop_propagation()>
<span>"DPI"</span>
<input <input
type="number" type="number"
min="100" min="100"
max="25600" max="25600"
step="100" step="100"
prop:value=stage[0] prop:value=stage[0]
on:click=move |ev| ev.stop_propagation()
on:change=move |ev| { on:change=move |ev| {
let mut stages = stage_list_for_x.clone(); let mut stages = stage_list_for_linked.clone();
let x = event_target_value(&ev) let dpi = event_target_value(&ev)
.parse::<u16>() .parse::<u16>()
.unwrap_or(stages[stage_index][0]); .unwrap_or(stages[stage_index][0]);
stages[stage_index][0] = x; stages[stage_index] = [dpi, dpi];
apply_dpi_stages(stages, active_stage_for_x); apply_dpi_stages(stages, active_stage);
} }
/> />
</label> </label>
<label class="field">
<span>"Y"</span> <details
<input class="stage-advanced"
type="number" prop:open=stage[0] != stage[1]
min="100" on:click=move |ev| ev.stop_propagation()
max="25600" >
step="100" <summary>"Fine tune X/Y"</summary>
prop:value=stage[1] <div class="inline-pair">
on:change=move |ev| { <label class="field">
let mut stages = stage_list_for_y.clone(); <span>"X"</span>
let y = event_target_value(&ev) <input
.parse::<u16>() type="number"
.unwrap_or(stages[stage_index][1]); min="100"
stages[stage_index][1] = y; max="25600"
apply_dpi_stages(stages, active_stage_for_y); step="100"
} prop:value=stage[0]
/> on:change=move |ev| {
</label> let mut stages = stage_list_for_x.clone();
</div> let x = event_target_value(&ev)
.parse::<u16>()
.unwrap_or(stages[stage_index][0]);
stages[stage_index][0] = x;
apply_dpi_stages(stages, active_stage);
}
/>
</label>
<label class="field">
<span>"Y"</span>
<input
type="number"
min="100"
max="25600"
step="100"
prop:value=stage[1]
on:change=move |ev| {
let mut stages = stage_list_for_y.clone();
let y = event_target_value(&ev)
.parse::<u16>()
.unwrap_or(stages[stage_index][1]);
stages[stage_index][1] = y;
apply_dpi_stages(stages, active_stage);
}
/>
</label>
</div>
</details>
</section>
} }
}).collect_view()} }).collect_view()}
{
let can_add = basic.dpi_stages.len() < 5;
let stage_template = initial_dpi_stages.clone();
let last_stage = stage_template.last().copied().unwrap_or([800, 800]);
let next_dpi = last_stage[0].max(last_stage[1]).saturating_add(400).min(25600);
view! {
<button
type="button"
class="dpi-add-stage"
class:disabled=!can_add
disabled=!can_add
on:click=move |_| {
if !can_add {
return;
}
let mut stages = stage_template.clone();
stages.push([next_dpi, next_dpi]);
apply_dpi_stages(stages.clone(), stages.len() as u8);
}
>
<span>"+"</span>
<strong>{if can_add { "Add stage" } else { "Max 5 stages" }}</strong>
</button>
}
}
</div> </div>
</article> </article>
</section> </section>

View file

@ -29,25 +29,24 @@ fn render_button_mapping_editor(
.into_any(), .into_any(),
"mouse" => view! { "mouse" => view! {
<div class="button-editor-grid"> <div class="button-editor-grid">
<label class="field"> <AppDropdown
<span>"Mouse function"</span> label="Mouse function"
<select selected=move || mouse_fn.clone()
prop:value=mouse_fn.clone() disabled=move || false
on:change=move |ev| { options=move || {
let value = event_target_value(&ev); ["left", "right", "middle", "backward", "forward", "wheel_up", "wheel_down", "wheel_left", "wheel_right"]
.into_iter()
.map(|name| SelectChoice::new(name, name))
.collect()
}
on_select=move |value| {
mapping_state.update(|state| { mapping_state.update(|state| {
if let Some(mapping) = state.as_mut() { if let Some(mapping) = state.as_mut() {
payload_object_mut(mapping).insert("fn".to_string(), Value::String(value.clone())); payload_object_mut(mapping).insert("fn".to_string(), Value::String(value.clone()));
} }
}); });
} }
> />
{["left", "right", "middle", "backward", "forward", "wheel_up", "wheel_down", "wheel_left", "wheel_right"]
.into_iter()
.map(|name| view! { <option value=name>{name}</option> })
.collect_view()}
</select>
</label>
<div class="button-radio-group"> <div class="button-radio-group">
<label class="check-row"> <label class="check-row">

View file

@ -36,25 +36,32 @@ fn render_button_mapping_editor_secondary(
/> />
</label> </label>
<label class="field"> <AppDropdown
<span>"Mode"</span> label="Mode"
<select selected={
prop:value=macro_mode.clone() let macro_mode = macro_mode.clone();
on:change=move |ev| { move || macro_mode.clone()
let value = event_target_value(&ev); }
disabled=move || false
options=move || {
[
("macro_fixed", "Fixed repeat"),
("macro_hold", "Hold"),
("macro_toggle", "Toggle"),
("macro_sequence", "Sequence"),
]
.into_iter()
.map(|(value, label)| SelectChoice::new(value, label))
.collect()
}
on_select=move |value| {
mapping_state.update(|state| { mapping_state.update(|state| {
if let Some(mapping) = state.as_mut() { if let Some(mapping) = state.as_mut() {
payload_object_mut(mapping).insert("mode".to_string(), Value::String(value.clone())); payload_object_mut(mapping).insert("mode".to_string(), Value::String(value.clone()));
} }
}); });
} }
> />
<option value="macro_fixed">"Fixed repeat"</option>
<option value="macro_hold">"Hold"</option>
<option value="macro_toggle">"Toggle"</option>
<option value="macro_sequence">"Sequence"</option>
</select>
</label>
<label class="field inline"> <label class="field inline">
<span>"Repeat count"</span> <span>"Repeat count"</span>
@ -79,12 +86,27 @@ fn render_button_mapping_editor_secondary(
.into_any(), .into_any(),
"dpi_switch" => view! { "dpi_switch" => view! {
<div class="button-editor-grid"> <div class="button-editor-grid">
<label class="field"> <AppDropdown
<span>"Action"</span> label="Action"
<select selected={
prop:value=dpi_fn.clone() let dpi_fn = dpi_fn.clone();
on:change=move |ev| { move || dpi_fn.clone()
let value = event_target_value(&ev); }
disabled=move || false
options=move || {
[
("next", "Next"),
("prev", "Previous"),
("next_loop", "Next (loop)"),
("prev_loop", "Previous (loop)"),
("fixed", "Fixed stage"),
("aim", "Aim DPI"),
]
.into_iter()
.map(|(value, label)| SelectChoice::new(value, label))
.collect()
}
on_select=move |value| {
mapping_state.update(|state| { mapping_state.update(|state| {
if let Some(mapping) = state.as_mut() { if let Some(mapping) = state.as_mut() {
let payload = payload_object_mut(mapping); let payload = payload_object_mut(mapping);
@ -92,15 +114,7 @@ fn render_button_mapping_editor_secondary(
} }
}); });
} }
> />
<option value="next">"Next"</option>
<option value="prev">"Previous"</option>
<option value="next_loop">"Next (loop)"</option>
<option value="prev_loop">"Previous (loop)"</option>
<option value="fixed">"Fixed stage"</option>
<option value="aim">"Aim DPI"</option>
</select>
</label>
<label class="field inline"> <label class="field inline">
<span>"Stage"</span> <span>"Stage"</span>
@ -166,47 +180,55 @@ fn render_button_mapping_editor_secondary(
.into_any(), .into_any(),
"profile_switch" => view! { "profile_switch" => view! {
<div class="button-editor-grid"> <div class="button-editor-grid">
<label class="field"> <AppDropdown
<span>"Action"</span> label="Action"
<select selected={
prop:value=profile_fn.clone() let profile_fn = profile_fn.clone();
on:change=move |ev| { move || profile_fn.clone()
let value = event_target_value(&ev); }
disabled=move || false
options=move || {
[
("next", "Next"),
("prev", "Previous"),
("next_loop", "Next (loop)"),
("prev_loop", "Previous (loop)"),
("fixed", "Fixed profile"),
]
.into_iter()
.map(|(value, label)| SelectChoice::new(value, label))
.collect()
}
on_select=move |value| {
mapping_state.update(|state| { mapping_state.update(|state| {
if let Some(mapping) = state.as_mut() { if let Some(mapping) = state.as_mut() {
payload_object_mut(mapping).insert("fn".to_string(), Value::String(value.clone())); payload_object_mut(mapping).insert("fn".to_string(), Value::String(value.clone()));
} }
}); });
} }
> />
<option value="next">"Next"</option>
<option value="prev">"Previous"</option>
<option value="next_loop">"Next (loop)"</option>
<option value="prev_loop">"Previous (loop)"</option>
<option value="fixed">"Fixed profile"</option>
</select>
</label>
<label class="field"> <AppDropdown
<span>"Fixed profile"</span> label="Fixed profile"
<select selected=move || fixed_profile.clone()
prop:value=fixed_profile.clone() disabled={
disabled=profile_fn != "fixed" let profile_fn = profile_fn.clone();
on:change=move |ev| { move || profile_fn != "fixed"
let value = event_target_value(&ev); }
options=move || {
["white", "red", "green", "blue", "cyan"]
.into_iter()
.map(|name| SelectChoice::new(name, name))
.collect()
}
on_select=move |value| {
mapping_state.update(|state| { mapping_state.update(|state| {
if let Some(mapping) = state.as_mut() { if let Some(mapping) = state.as_mut() {
payload_object_mut(mapping).insert("profile".to_string(), Value::String(value.clone())); payload_object_mut(mapping).insert("profile".to_string(), Value::String(value.clone()));
} }
}); });
} }
> />
{["white", "red", "green", "blue", "cyan"]
.into_iter()
.map(|name| view! { <option value=name>{name}</option> })
.collect_view()}
</select>
</label>
</div> </div>
} }
.into_any(), .into_any(),

View file

@ -1,3 +1,50 @@
#[derive(Clone, Copy)]
struct LedEffectChoice {
key: &'static str,
label: &'static str,
effect: &'static str,
mode: u8,
}
const LED_EFFECT_CHOICES: [LedEffectChoice; 6] = [
LedEffectChoice {
key: "off",
label: "Off",
effect: "off",
mode: 0,
},
LedEffectChoice {
key: "static",
label: "Static",
effect: "static",
mode: 0,
},
LedEffectChoice {
key: "spectrum",
label: "Spectrum",
effect: "spectrum",
mode: 1,
},
LedEffectChoice {
key: "wave_fixed",
label: "Wave fixed",
effect: "wave",
mode: 0,
},
LedEffectChoice {
key: "wave_clockwise",
label: "Wave clockwise",
effect: "wave",
mode: 1,
},
LedEffectChoice {
key: "wave_counter",
label: "Wave counter",
effect: "wave",
mode: 2,
},
];
#[component] #[component]
fn LedPanel( fn LedPanel(
snapshot: DeviceState, snapshot: DeviceState,
@ -8,10 +55,14 @@ fn LedPanel(
let demo_mode = snapshot.device.path.starts_with("demo://"); let demo_mode = snapshot.device.path.starts_with("demo://");
let led_state = RwSignal::new(None::<LedState>); let led_state = RwSignal::new(None::<LedState>);
let selected_region = RwSignal::new("wheel".to_string()); let selected_region = RwSignal::new("wheel".to_string());
let static_color_memory =
RwSignal::new(std::collections::BTreeMap::<String, [u8; 3]>::new());
let load_led_state = move |profile: String| { let load_led_state = move |profile: String| {
if demo_mode { if demo_mode {
led_state.set(Some(mock_led_state(profile))); let state = mock_led_state(profile);
remember_static_led_colors(static_color_memory, &state);
led_state.set(Some(state));
return; return;
} }
set_busy.set(true); set_busy.set(true);
@ -19,6 +70,7 @@ fn LedPanel(
spawn_local(async move { spawn_local(async move {
match invoke::<LedState, _>("get_led_state", &LedProfileArgs { profile }).await { match invoke::<LedState, _>("get_led_state", &LedProfileArgs { profile }).await {
Ok(state) => { Ok(state) => {
remember_static_led_colors(static_color_memory, &state);
led_state.set(Some(state)); led_state.set(Some(state));
set_status.set("LED settings loaded.".to_string()); set_status.set("LED settings loaded.".to_string());
} }
@ -39,12 +91,15 @@ fn LedPanel(
let Some(state) = led_state.get_untracked() else { let Some(state) = led_state.get_untracked() else {
return; return;
}; };
let source_name = selected_region.get_untracked();
let Some(source_region) = state let Some(source_region) = state
.regions .regions
.iter() .iter()
.find(|region| region.region == selected_region.get_untracked()) .find(|region| region.region == source_name)
.or_else(|| state.regions.first())
.cloned() .cloned()
else { else {
set_status.set("No LED regions are available.".to_string());
return; return;
}; };
if demo_mode { if demo_mode {
@ -56,6 +111,7 @@ fn LedPanel(
region.colors = source_region.colors.clone(); region.colors = source_region.colors.clone();
region.brightness = source_region.brightness; region.brightness = source_region.brightness;
} }
remember_static_led_colors(static_color_memory, &next_state);
led_state.set(Some(next_state)); led_state.set(Some(next_state));
set_status.set("Updated LED demo state.".to_string()); set_status.set("Updated LED demo state.".to_string());
} }
@ -75,6 +131,7 @@ fn LedPanel(
.await .await
{ {
Ok(state) => { Ok(state) => {
remember_static_led_colors(static_color_memory, &state);
led_state.set(Some(state)); led_state.set(Some(state));
set_status.set("LED settings applied to all regions.".to_string()); set_status.set("LED settings applied to all regions.".to_string());
} }
@ -86,210 +143,366 @@ fn LedPanel(
view! { view! {
<section class="panel-grid"> <section class="panel-grid">
<article class="panel wide"> <article class="panel wide led-panel">
<h3>"LED"</h3>
{move || { {move || {
let Some(state) = led_state.get() else { let Some(state) = led_state.get() else {
return view! { <p>"Loading LED settings..."</p> }.into_any(); return view! { <p>"Loading LED settings..."</p> }.into_any();
}; };
let led_profile_label = state.profile.clone(); if state.regions.is_empty() {
let active_region_name = selected_region.get(); return view! {
<div class="led-panel-header">
<div>
<h3>"LED"</h3>
<p class="subtle">{format!("Profile {}", state.profile)}</p>
</div>
</div>
<p class="subtle">"No LED regions reported by this device."</p>
}.into_any();
}
let selected_region_name = selected_region.get();
let active_region = state let active_region = state
.regions .regions
.iter() .iter()
.find(|region| region.region == active_region_name) .find(|region| region.region == selected_region_name)
.cloned() .cloned()
.unwrap_or_else(|| state.regions[0].clone()); .unwrap_or_else(|| state.regions[0].clone());
let region_id = active_region.region.clone(); let active_region_id = active_region.region.clone();
let effect = active_region.effect.clone(); let effect = active_region.effect.clone();
let mode = active_region.mode; let mode = active_region.mode;
let speed = active_region.speed; let speed = active_region.speed;
let colors = active_region.colors.clone(); let colors = active_region.colors.clone();
let brightness = active_region.brightness; let brightness = active_region.brightness;
let static_hex = colors let effect_key = led_effect_key(&effect, mode);
.first() let static_color = led_static_color(&active_region_id, &colors, static_color_memory);
.map(color_to_hex) let static_hex = color_to_hex(&static_color);
.unwrap_or_else(|| "#ffffff".to_string()); let control_speed = led_control_speed(speed);
let show_color = effect == "static";
let show_speed = led_effect_has_speed(&effect);
let show_brightness = effect != "off" && effect != "disabled";
view! { view! {
<p class="subtle">{format!("Editing LED settings for profile {led_profile_label}")}</p> <div class="led-panel-header">
<div class="led-region-tabs"> <div>
<h3>"LED"</h3>
<p class="subtle">{format!("Profile {}", state.profile)}</p>
</div>
<button
type="button"
class="secondary-action"
disabled=state.regions.len() < 2
on:click=apply_to_all
>
"Apply to all"
</button>
</div>
<div class="led-region-tabs" role="tablist" aria-label="LED regions">
{state.regions.iter().map(|region| { {state.regions.iter().map(|region| {
let region_name = region.region.clone(); let region_name = region.region.clone();
let region_name_active = region_name.clone(); let region_click = region_name.clone();
let region_name_click = region_name.clone(); let region_is_active = region_name == active_region_id;
let region_label = led_region_label(&region.region);
let swatch = led_region_swatch(region);
view! { view! {
<button <button
type="button" type="button"
class:active=move || selected_region.get() == region_name_active role="tab"
on:click=move |_| selected_region.set(region_name_click.clone()) class:active=region_is_active
aria-selected=region_is_active.to_string()
on:click=move |_| selected_region.set(region_click.clone())
> >
{region.region.clone()} <span class="led-region-swatch" style=format!("background: {swatch};")></span>
<strong>{region_label}</strong>
</button> </button>
} }
}).collect_view()} }).collect_view()}
</div> </div>
<div class="led-options"> <div class="led-section">
<label class="led-option"> <h4>"Effect"</h4>
<input <div class="led-effect-grid" role="group" aria-label="LED effect">
type="radio" {LED_EFFECT_CHOICES.into_iter().map(|choice| {
prop:checked=effect == "off" || effect == "disabled" let is_active = effect_key == choice.key;
on:change={ let region = active_region_id.clone();
let region = region_id.clone(); let current_speed = control_speed;
move |_| apply_led_effect_change(demo_mode, led_state, selected_profile, set_status, set_busy, region.clone(), "off".to_string(), 0, 0, vec![]) let color = static_color;
view! {
<button
type="button"
class:active=is_active
on:click=move |_| {
let colors = if choice.effect == "static" {
vec![color]
} else {
vec![]
};
let speed = if led_effect_has_speed(choice.effect) {
current_speed
} else {
0
};
apply_led_effect_change(
demo_mode,
led_state,
selected_profile,
set_status,
set_busy,
region.clone(),
choice.effect.to_string(),
choice.mode,
speed,
colors,
);
}
>
{choice.label}
</button>
} }
/> }).collect_view()}
<span>"Disabled"</span>
</label>
<label class="led-option"> {if effect == "custom" {
<input view! {
type="radio" <button type="button" class="active" disabled=true>"Custom"</button>
prop:checked=effect == "static" }.into_any()
on:change={ } else {
let region = region_id.clone(); view! {}.into_any()
move |_| apply_led_effect_change(demo_mode, led_state, selected_profile, set_status, set_busy, region.clone(), "static".to_string(), 0, 0, vec![[255, 255, 255]]) }}
} </div>
/>
<span>"Static"</span>
<input
type="color"
prop:value=static_hex
disabled=effect != "static"
on:change={
let region = region_id.clone();
move |ev| {
let color = hex_to_rgb(&event_target_value(&ev));
apply_led_effect_change(demo_mode, led_state, selected_profile, set_status, set_busy, region.clone(), "static".to_string(), 0, 0, vec![color]);
}
}
/>
</label>
<label class="led-option">
<input
type="radio"
prop:checked=effect == "spectrum"
on:change={
let region = region_id.clone();
move |_| apply_led_effect_change(demo_mode, led_state, selected_profile, set_status, set_busy, region.clone(), "spectrum".to_string(), 1, 180, vec![])
}
/>
<span>"Spectrum"</span>
</label>
<label class="led-option">
<input
type="radio"
prop:checked=effect == "wave" && mode == 0
on:change={
let region = region_id.clone();
move |_| apply_led_effect_change(demo_mode, led_state, selected_profile, set_status, set_busy, region.clone(), "wave".to_string(), 0, 180, vec![])
}
/>
<span>"Wave (Static)"</span>
</label>
<label class="led-option">
<input
type="radio"
prop:checked=effect == "wave" && mode == 1
on:change={
let region = region_id.clone();
move |_| apply_led_effect_change(demo_mode, led_state, selected_profile, set_status, set_busy, region.clone(), "wave".to_string(), 1, 180, vec![])
}
/>
<span>"Wave (Clockwise)"</span>
</label>
<label class="led-option">
<input
type="radio"
prop:checked=effect == "wave" && mode == 2
on:change={
let region = region_id.clone();
move |_| apply_led_effect_change(demo_mode, led_state, selected_profile, set_status, set_busy, region.clone(), "wave".to_string(), 2, 180, vec![])
}
/>
<span>"Wave (Counter-clockwise)"</span>
</label>
</div> </div>
<div class="slider-row"> <div class="led-controls">
<span>"Speed"</span> {if show_color {
<input view! {
type="number" <label class="led-color-control">
min="0" <span>"Color"</span>
max="255" <input
prop:value=speed type="color"
disabled=effect != "spectrum" && effect != "wave" prop:value=static_hex.clone()
on:change={ on:change={
let region = region_id.clone(); let region = active_region_id.clone();
let effect_name = effect.clone(); move |ev| {
let effect_colors = colors.clone(); let color = hex_to_rgb(&event_target_value(&ev));
move |ev| { static_color_memory.update(|items| {
let next_speed = event_target_value(&ev).parse::<u8>().unwrap_or(speed); items.insert(region.clone(), color);
apply_led_effect_change(demo_mode, led_state, selected_profile, set_status, set_busy, region.clone(), effect_name.clone(), mode, next_speed, effect_colors.clone()); });
} apply_led_effect_change(
} demo_mode,
/> led_state,
<input selected_profile,
type="range" set_status,
min="0" set_busy,
max="255" region.clone(),
prop:value=speed "static".to_string(),
disabled=effect != "spectrum" && effect != "wave" 0,
on:change={ 0,
let region = region_id.clone(); vec![color],
let effect_name = effect.clone(); );
let effect_colors = colors.clone(); }
move |ev| { }
let next_speed = event_target_value(&ev).parse::<u8>().unwrap_or(speed); />
apply_led_effect_change(demo_mode, led_state, selected_profile, set_status, set_busy, region.clone(), effect_name.clone(), mode, next_speed, effect_colors.clone()); <strong>{static_hex.to_uppercase()}</strong>
} </label>
} }.into_any()
/> } else {
</div> view! {}.into_any()
}}
<div class="slider-row"> {if show_speed {
<span>"Brightness"</span> view! {
<input <div class="led-slider-row">
type="number" <div class="led-slider-label">
min="0" <span>"Speed"</span>
max="255" <strong>{control_speed}</strong>
prop:value=brightness </div>
on:change={ <input
let region = region_id.clone(); type="range"
move |ev| { min="0"
let next_brightness = event_target_value(&ev).parse::<u8>().unwrap_or(brightness); max="255"
apply_led_brightness_change(demo_mode, led_state, selected_profile, set_status, set_busy, region.clone(), next_brightness); prop:value=control_speed
} on:change={
} let region = active_region_id.clone();
/> let effect_name = effect.clone();
<input let effect_colors = colors.clone();
type="range" move |ev| {
min="0" let next_speed = parse_led_u8(&event_target_value(&ev), control_speed);
max="255" apply_led_effect_change(
prop:value=brightness demo_mode,
on:change={ led_state,
let region = region_id.clone(); selected_profile,
move |ev| { set_status,
let next_brightness = event_target_value(&ev).parse::<u8>().unwrap_or(brightness); set_busy,
apply_led_brightness_change(demo_mode, led_state, selected_profile, set_status, set_busy, region.clone(), next_brightness); region.clone(),
} effect_name.clone(),
} mode,
/> next_speed,
</div> effect_colors.clone(),
);
}
}
/>
<input
type="number"
min="0"
max="255"
prop:value=control_speed
on:change={
let region = active_region_id.clone();
let effect_name = effect.clone();
let effect_colors = colors.clone();
move |ev| {
let next_speed = parse_led_u8(&event_target_value(&ev), control_speed);
apply_led_effect_change(
demo_mode,
led_state,
selected_profile,
set_status,
set_busy,
region.clone(),
effect_name.clone(),
mode,
next_speed,
effect_colors.clone(),
);
}
}
/>
</div>
}.into_any()
} else {
view! {}.into_any()
}}
<button type="button" class="secondary-action" on:click=apply_to_all> {if show_brightness {
"Apply To All Regions" view! {
</button> <div class="led-slider-row">
<div class="led-slider-label">
<span>"Brightness"</span>
<strong>{brightness}</strong>
</div>
<input
type="range"
min="0"
max="255"
prop:value=brightness
on:change={
let region = active_region_id.clone();
move |ev| {
let next_brightness = parse_led_u8(&event_target_value(&ev), brightness);
apply_led_brightness_change(demo_mode, led_state, selected_profile, set_status, set_busy, region.clone(), next_brightness);
}
}
/>
<input
type="number"
min="0"
max="255"
prop:value=brightness
on:change={
let region = active_region_id.clone();
move |ev| {
let next_brightness = parse_led_u8(&event_target_value(&ev), brightness);
apply_led_brightness_change(demo_mode, led_state, selected_profile, set_status, set_busy, region.clone(), next_brightness);
}
}
/>
</div>
}.into_any()
} else {
view! {}.into_any()
}}
</div>
}.into_any() }.into_any()
}} }}
</article> </article>
</section> </section>
} }
} }
fn remember_static_led_colors(
memory: RwSignal<std::collections::BTreeMap<String, [u8; 3]>>,
state: &LedState,
) {
memory.update(|items| {
for region in &state.regions {
if region.effect == "static" {
if let Some(color) = region.colors.first() {
items.insert(region.region.clone(), *color);
}
}
}
});
}
fn led_static_color(
region: &str,
colors: &[[u8; 3]],
memory: RwSignal<std::collections::BTreeMap<String, [u8; 3]>>,
) -> [u8; 3] {
colors
.first()
.copied()
.or_else(|| memory.get_untracked().get(region).copied())
.unwrap_or([105, 230, 162])
}
fn led_region_label(region: &str) -> String {
match region {
"wheel" => "Wheel".to_string(),
"logo" => "Logo".to_string(),
"strip" => "Underglow".to_string(),
other => other.replace('_', " "),
}
}
fn led_effect_key(effect: &str, mode: u8) -> String {
match (effect, mode) {
("off", _) | ("disabled", _) => "off".to_string(),
("static", _) => "static".to_string(),
("spectrum", _) => "spectrum".to_string(),
("wave", 0) => "wave_fixed".to_string(),
("wave", 1) => "wave_clockwise".to_string(),
("wave", 2) => "wave_counter".to_string(),
("custom", _) => "custom".to_string(),
_ => effect.to_string(),
}
}
fn led_effect_has_speed(effect: &str) -> bool {
effect == "spectrum" || effect == "wave"
}
fn led_control_speed(speed: u8) -> u8 {
if speed == 0 {
180
} else {
speed
}
}
fn parse_led_u8(value: &str, fallback: u8) -> u8 {
value
.parse::<u16>()
.map(|value| value.min(u16::from(u8::MAX)) as u8)
.unwrap_or(fallback)
}
fn led_region_swatch(region: &LedRegionState) -> String {
if region.effect == "static" {
return region
.colors
.first()
.map(color_to_hex)
.unwrap_or_else(|| "#69e6a2".to_string());
}
match region.effect.as_str() {
"off" | "disabled" => "var(--color-border-subtle)".to_string(),
"spectrum" => "linear-gradient(90deg, #ff4d6d, #ffd166, #69e6a2, #4cc9f0, #b983ff)"
.to_string(),
"wave" => "linear-gradient(90deg, #69e6a2, #4cc9f0)".to_string(),
_ => "var(--color-metric)".to_string(),
}
}

View file

@ -315,22 +315,19 @@ fn MacroPanel(
<h3>"Macros"</h3> <h3>"Macros"</h3>
<h4>"Edit Individual Macros"</h4> <h4>"Edit Individual Macros"</h4>
<div class="macro-toolbar"> <div class="macro-toolbar">
<label class="field inline"> <AppDropdown
<span>"Select existing"</span> label="Select existing"
<select selected=move || selected_macro_id.get().map(|value| value.to_string()).unwrap_or_default()
prop:value=move || selected_macro_id.get().map(|value| value.to_string()).unwrap_or_default() disabled=move || false
on:change=move |ev| { options=move || {
let value = event_target_value(&ev); let mut options = vec![SelectChoice::new("", "(none)")];
selected_macro_id.set(value.parse::<u16>().ok()); options.extend(macro_list.get().into_iter().map(|macro_id| {
} SelectChoice::new(macro_id.to_string(), format!("0x{macro_id:04x}"))
> }));
<option value="">"(none)"</option> options
{move || macro_list.get().into_iter().map(|macro_id| { }
let label = format!("0x{macro_id:04x}"); on_select=move |value| selected_macro_id.set(value.parse::<u16>().ok())
view! { <option value=macro_id.to_string()>{label}</option> } />
}).collect_view()}
</select>
</label>
<button type="button" class="secondary-action" on:click=load_selected>"Load"</button> <button type="button" class="secondary-action" on:click=load_selected>"Load"</button>
<button type="button" class="secondary-action danger" on:click=delete_selected>"Delete"</button> <button type="button" class="secondary-action danger" on:click=delete_selected>"Delete"</button>
</div> </div>

View file

@ -90,13 +90,6 @@ struct PollingRateArgs {
polling_rate_ms: u8, polling_rate_ms: u8,
} }
#[derive(Serialize)]
struct DpiArgs {
profile: String,
x: u16,
y: u16,
}
#[derive(Serialize)] #[derive(Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct DpiStagesArgs { struct DpiStagesArgs {

View file

@ -1,3 +1,161 @@
#[derive(Clone)]
struct SelectChoice {
value: String,
label: String,
}
impl SelectChoice {
fn new(value: impl Into<String>, label: impl Into<String>) -> Self {
Self {
value: value.into(),
label: label.into(),
}
}
}
#[component]
fn AppDropdown<OptionsFn, SelectedFn, DisabledFn, OnSelectFn>(
label: &'static str,
selected: SelectedFn,
options: OptionsFn,
disabled: DisabledFn,
on_select: OnSelectFn,
) -> impl IntoView
where
OptionsFn: Fn() -> Vec<SelectChoice> + Clone + Send + 'static,
SelectedFn: Fn() -> String + Clone + Send + 'static,
DisabledFn: Fn() -> bool + Clone + Send + 'static,
OnSelectFn: Fn(String) + Clone + Send + 'static,
{
let open = RwSignal::new(false);
let selected_for_label = selected.clone();
let options_for_label = options.clone();
let disabled_for_field = disabled.clone();
let disabled_for_trigger = disabled.clone();
let disabled_for_click_away = disabled.clone();
let disabled_for_menu = disabled.clone();
let selected_label = move || {
let selected_value = selected_for_label();
options_for_label()
.into_iter()
.find(|choice| choice.value == selected_value)
.map(|choice| choice.label)
.unwrap_or_else(|| {
if selected_value.is_empty() {
"None".to_string()
} else {
selected_value
}
})
};
view! {
<div class="field app-select-field" class:disabled=move || disabled_for_field()>
<span>{label}</span>
<div class="app-select" class:open=move || open.get()>
{move || {
if open.get() && !disabled_for_click_away() {
view! {
<button
type="button"
class="app-select-click-away"
aria-label="Close dropdown"
tabindex="-1"
on:click=move |_| open.set(false)
/>
}
.into_any()
} else {
view! {}.into_any()
}
}}
<button
type="button"
class="app-select-trigger"
aria-haspopup="listbox"
aria-expanded=move || open.get().to_string()
disabled=move || disabled_for_trigger()
on:click={
let disabled = disabled.clone();
move |_| {
if !disabled() {
open.update(|is_open| *is_open = !*is_open);
}
}
}
on:keydown={
let disabled = disabled.clone();
move |ev: KeyboardEvent| {
let key = ev.key();
match key.as_str() {
"Escape" => open.set(false),
"Enter" | " " | "ArrowDown" => {
ev.prevent_default();
if !disabled() {
open.set(true);
}
}
_ => {}
}
}
}
>
<span>{selected_label}</span>
<span class="app-select-arrow" aria-hidden="true">"v"</span>
</button>
{move || {
if !open.get() || disabled_for_menu() {
return view! {}.into_any();
}
let selected_value = selected();
view! {
<div class="app-select-menu" role="listbox">
{options().into_iter().map(|choice| {
let is_selected = choice.value == selected_value;
let value = choice.value.clone();
let on_select = on_select.clone();
view! {
<button
type="button"
role="option"
class:active=is_selected
on:click=move |_| {
on_select(value.clone());
open.set(false);
}
>
{choice.label}
</button>
}
}).collect_view()}
</div>
}.into_any()
}}
</div>
</div>
}
}
#[derive(Clone)]
struct ToastMessage {
id: u64,
message: String,
busy: bool,
}
fn should_show_toast(message: &str) -> bool {
let normalized = message.trim().to_ascii_lowercase();
!(normalized.starts_with("loading ")
|| normalized.starts_with("reloading ")
|| normalized.starts_with("writing ")
|| normalized.ends_with(" loaded.")
|| normalized.ends_with(" reloaded."))
}
#[component] #[component]
pub fn App() -> impl IntoView { pub fn App() -> impl IntoView {
let devices = RwSignal::new(Vec::<DeviceSummary>::new()); let devices = RwSignal::new(Vec::<DeviceSummary>::new());
@ -5,9 +163,36 @@ pub fn App() -> impl IntoView {
let selected_profile = RwSignal::new("direct".to_string()); let selected_profile = RwSignal::new("direct".to_string());
let device_state = RwSignal::new(None::<DeviceState>); let device_state = RwSignal::new(None::<DeviceState>);
let active_tab = RwSignal::new("basic".to_string()); let active_tab = RwSignal::new("basic".to_string());
let status = RwSignal::new("Scanning for a supported Basilisk mouse.".to_string()); let status = RwSignal::new(String::new());
let toasts = RwSignal::new(Vec::<ToastMessage>::new());
let next_toast_id = RwSignal::new(0_u64);
let busy = RwSignal::new(false); let busy = RwSignal::new(false);
Effect::new(move |_| {
let message = status.get();
if message.trim().is_empty() || !should_show_toast(&message) {
return;
}
let id = next_toast_id.get_untracked();
let busy_now = busy.get_untracked();
next_toast_id.set(id + 1);
toasts.update(|items| {
items.push(ToastMessage {
id,
message,
busy: busy_now,
});
});
set_timeout(
move || {
toasts.update(|items| items.retain(|item| item.id != id));
},
std::time::Duration::from_millis(if busy_now { 3000 } else { 4500 }),
);
});
let connect_path = move |path: String| { let connect_path = move |path: String| {
if path.is_empty() { if path.is_empty() {
status.set("Select a device path before connecting.".to_string()); status.set("Select a device path before connecting.".to_string());
@ -89,7 +274,7 @@ pub fn App() -> impl IntoView {
<main class="app-shell"> <main class="app-shell">
<aside class="sidebar"> <aside class="sidebar">
<div class="brand"> <div class="brand">
<img src="public/snakemouse.svg" alt="" /> <img src="public/app-icon.png" alt="" />
<div> <div>
<h1>"Razer Basilisk V3"</h1> <h1>"Razer Basilisk V3"</h1>
<p>"Onboard memory tools"</p> <p>"Onboard memory tools"</p>
@ -100,15 +285,13 @@ pub fn App() -> impl IntoView {
"Scan" "Scan"
</button> </button>
<label class="field"> <AppDropdown
<span>"Device"</span> label="Device"
<select selected=move || selected_path.get()
prop:value=move || selected_path.get() disabled=move || busy.get()
on:change=move |ev| selected_path.set(event_target_value(&ev)) options=move || {
disabled=move || busy.get() let mut options = vec![SelectChoice::new("", "No supported device selected")];
> options.extend(devices.get().into_iter().map(|device| {
<option value="">"No supported device selected"</option>
{move || devices.get().into_iter().map(|device| {
let label = format!( let label = format!(
"{} - {} ({:04x}:{:04x}){}", "{} - {} ({:04x}:{:04x}){}",
device.path, device.path,
@ -121,12 +304,12 @@ pub fn App() -> impl IntoView {
.map(|serial| format!(" - {serial}")) .map(|serial| format!(" - {serial}"))
.unwrap_or_default() .unwrap_or_default()
); );
view! { SelectChoice::new(device.path, label)
<option value=device.path.clone()>{label}</option> }));
} options
}).collect_view()} }
</select> on_select=move |value| selected_path.set(value)
</label> />
<button class="primary-action" type="button" on:click=connect disabled=move || busy.get() || selected_path.get().is_empty()> <button class="primary-action" type="button" on:click=connect disabled=move || busy.get() || selected_path.get().is_empty()>
"Connect" "Connect"
@ -148,9 +331,26 @@ pub fn App() -> impl IntoView {
<p class="eyebrow">"Linux desktop configuration"</p> <p class="eyebrow">"Linux desktop configuration"</p>
<h2>{move || device_state.get().map(|state| state.device.supported_name).unwrap_or_else(|| "No device connected".to_string())}</h2> <h2>{move || device_state.get().map(|state| state.device.supported_name).unwrap_or_else(|| "No device connected".to_string())}</h2>
</div> </div>
<div class="status" class:busy=move || busy.get()>{move || status.get()}</div>
</header> </header>
<div class="toast-region" aria-live="polite" aria-atomic="false">
{move || toasts.get().into_iter().map(|toast| {
let toast_id = toast.id;
view! {
<div class="toast" class:busy=toast.busy>
<span>{toast.message}</span>
<button
type="button"
aria-label="Dismiss notification"
on:click=move |_| toasts.update(|items| items.retain(|item| item.id != toast_id))
>
"x"
</button>
</div>
}
}).collect_view()}
</div>
{move || match device_state.get() { {move || match device_state.get() {
Some(snapshot) => view! { Some(snapshot) => view! {
<ConnectedView <ConnectedView

View file

@ -10,6 +10,10 @@
margin-bottom: 16px; margin-bottom: 16px;
} }
.macro-toolbar .app-select-field {
min-width: min(260px, 100%);
}
.field.inline.compact { .field.inline.compact {
grid-template-columns: 72px 140px; grid-template-columns: 72px 140px;
} }
@ -27,26 +31,26 @@
overflow: hidden; overflow: hidden;
border-radius: 6px; border-radius: 6px;
margin: 16px 0 10px; margin: 16px 0 10px;
background: #d8e0dc; background: var(--color-border-subtle);
} }
.flash-segment.used { .flash-segment.used {
background: #a64545; background: var(--color-danger);
} }
.flash-segment.available { .flash-segment.available {
background: #60d394; background: var(--color-primary);
} }
.flash-segment.recycled { .flash-segment.recycled {
background: #d4b25f; background: var(--color-warning);
} }
.flash-summary { .flash-summary {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
color: #66736f; color: var(--color-small-text);
font-size: 0.78rem; font-size: 0.78rem;
font-weight: 700; font-weight: 700;
margin-bottom: 12px; margin-bottom: 12px;
@ -56,10 +60,10 @@
min-height: 220px; min-height: 220px;
max-height: 320px; max-height: 320px;
overflow: auto; overflow: auto;
border: 1px solid #d8e0dc; border: 1px solid var(--color-border-subtle);
border-radius: 6px; border-radius: 6px;
padding: 10px; padding: 10px;
background: #f4f7f5; background: var(--color-panel-subtle);
white-space: pre-wrap; white-space: pre-wrap;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.82rem; font-size: 0.82rem;
@ -90,10 +94,10 @@
.macro-help pre { .macro-help pre {
overflow: auto; overflow: auto;
border: 1px solid #d8e0dc; border: 1px solid var(--color-border-subtle);
border-radius: 6px; border-radius: 6px;
padding: 10px; padding: 10px;
background: #f4f7f5; background: var(--color-panel-subtle);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.82rem; font-size: 0.82rem;
} }
@ -106,8 +110,8 @@
height: 18px; height: 18px;
margin-left: 6px; margin-left: 6px;
border-radius: 999px; border-radius: 999px;
color: #1f5c4c; color: var(--color-metric);
background: #dfe7e2; background: var(--color-secondary-bg);
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 700; font-weight: 700;
cursor: help; cursor: help;
@ -115,7 +119,7 @@
.field-error { .field-error {
margin: 0; margin: 0;
color: #a64545; color: var(--color-danger);
font-size: 0.82rem; font-size: 0.82rem;
font-weight: 600; font-weight: 600;
} }

View file

@ -2,49 +2,185 @@
.metric { .metric {
margin-top: 12px; margin-top: 12px;
color: #1f5c4c; color: var(--color-metric);
font-size: 1.45rem; font-size: 1.45rem;
font-weight: 800; font-weight: 800;
} }
.dpi-editor { .polling-options {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(auto-fit, minmax(88px, 1fr));
gap: 16px; gap: 8px;
align-items: end; margin-top: 14px;
} }
.dpi-controls { .polling-options button,
.led-effect-grid button {
min-height: 42px;
border-radius: 6px;
padding: 8px 12px;
color: var(--color-secondary-text);
background: var(--color-secondary-bg);
cursor: pointer;
font-weight: 700;
text-align: center;
}
.polling-options button:hover,
.polling-options button.active,
.led-effect-grid button:hover,
.led-effect-grid button.active {
color: var(--color-active-text);
background: var(--color-active);
}
.dpi-panel {
display: grid;
gap: 18px;
}
.dpi-card-header {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 12px; gap: 16px;
align-items: end; align-items: flex-start;
margin-top: 16px;
} }
.dpi-controls .field.inline { .dpi-card-header h3 {
grid-template-columns: 72px 110px; margin-bottom: 6px;
} }
.stage-list { .stage-list {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
gap: 8px; gap: 8px;
margin-top: 18px;
} }
.stage-list div { .dpi-stage-card {
display: grid; display: grid;
gap: 4px; gap: 10px;
border: 1px solid #d8e0dc; align-content: start;
border: 1px solid var(--color-border-subtle);
border-radius: 6px; border-radius: 6px;
padding: 10px; padding: 12px;
background: #f4f7f5; background: var(--color-panel-subtle);
cursor: pointer;
} }
.stage-list div.active { .dpi-add-stage {
border-color: #226957; display: grid;
background: #e3f3ec; min-height: 156px;
place-items: center;
align-content: center;
gap: 6px;
border: 1px dashed var(--color-border-subtle);
border-radius: 6px;
padding: 12px;
color: var(--color-secondary-text);
background: transparent;
cursor: pointer;
}
.dpi-add-stage:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.dpi-add-stage span {
display: grid;
place-items: center;
width: 44px;
height: 44px;
border-radius: 999px;
color: var(--color-primary-text);
background: var(--color-primary);
font-size: 2rem;
font-weight: 700;
line-height: 1;
}
.dpi-add-stage strong {
font-size: 0.9rem;
}
.dpi-stage-card:hover,
.dpi-stage-card:focus-visible,
.dpi-add-stage:hover,
.dpi-add-stage:focus-visible {
border-color: var(--color-active);
}
.dpi-stage-card.active {
border-color: var(--color-active);
background: var(--color-active-surface);
}
.dpi-stage-card-header {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
}
.dpi-stage-card-header span {
color: var(--color-small-text);
font-size: 0.78rem;
font-weight: 700;
}
.dpi-stage-card-header strong {
color: var(--color-metric);
font-size: 0.95rem;
}
.stage-delete {
display: inline-grid;
place-items: center;
width: 30px;
height: 30px;
margin-left: auto;
border-radius: 6px;
color: var(--color-danger);
background: transparent;
cursor: pointer;
opacity: 0;
}
.dpi-stage-card:hover .stage-delete,
.dpi-stage-card:focus-within .stage-delete {
opacity: 1;
}
.stage-delete:hover,
.stage-delete:focus-visible {
color: var(--color-danger-text);
background: var(--color-danger);
}
.stage-delete svg {
width: 18px;
height: 18px;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 2;
}
.stage-advanced {
display: grid;
gap: 8px;
}
.stage-advanced summary {
color: var(--color-small-text);
cursor: pointer;
font-size: 0.78rem;
font-weight: 700;
}
.stage-advanced .inline-pair {
margin-top: 8px;
} }
.stage-list input { .stage-list input {
@ -62,19 +198,19 @@
display: grid; display: grid;
gap: 6px; gap: 6px;
align-content: start; align-content: start;
border: 1px solid #d8e0dc; border: 1px solid var(--color-border-subtle);
border-radius: 6px; border-radius: 6px;
padding: 12px; padding: 12px;
background: #f4f7f5; background: var(--color-panel-subtle);
} }
.profile-admin-list div.active { .profile-admin-list div.active {
border-color: #226957; border-color: var(--color-active);
background: #e3f3ec; background: var(--color-active-surface);
} }
.profile-admin-list span { .profile-admin-list span {
color: #66736f; color: var(--color-small-text);
font-size: 0.78rem; font-size: 0.78rem;
} }
@ -106,13 +242,13 @@
gap: 4px; gap: 4px;
align-content: start; align-content: start;
padding: 12px; padding: 12px;
border: 1px solid #d8e0dc; border: 1px solid var(--color-border-subtle);
text-align: left; text-align: left;
background: #f4f7f5; background: var(--color-panel-subtle);
} }
.button-tile span { .button-tile span {
color: #66736f; color: var(--color-small-text);
font-size: 0.78rem; font-size: 0.78rem;
} }
@ -125,8 +261,8 @@
} }
.button-tile.active { .button-tile.active {
border-color: #226957; border-color: var(--color-active);
background: #e3f3ec; background: var(--color-active-surface);
} }
.button-hypershift-toggle { .button-hypershift-toggle {
@ -140,13 +276,13 @@
.category-grid button { .category-grid button {
padding: 0 12px; padding: 0 12px;
color: #25322f; color: var(--color-secondary-text);
background: #dfe7e2; background: var(--color-secondary-bg);
} }
.category-grid button.active { .category-grid button.active {
color: #f8fbf9; color: var(--color-active-text);
background: #226957; background: var(--color-active);
} }
.button-editor-grid { .button-editor-grid {
@ -192,71 +328,150 @@
min-height: 18px; min-height: 18px;
} }
.led-region-tabs { .led-panel {
display: grid;
gap: 18px;
}
.led-panel-header {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 12px;
align-items: flex-start;
justify-content: space-between;
}
.led-panel-header h3 {
margin-bottom: 4px;
}
.led-region-tabs {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 8px; gap: 8px;
margin-bottom: 16px;
} }
.led-region-tabs button { .led-region-tabs button {
min-height: 38px; display: grid;
padding: 8px 14px; grid-template-columns: 16px minmax(0, 1fr);
border-radius: 6px; gap: 10px;
color: #25322f;
background: #dfe7e2;
cursor: pointer;
display: inline-flex;
justify-content: center;
align-items: center; align-items: center;
white-space: nowrap; min-height: 42px;
text-align: center; border: 1px solid var(--color-border-subtle);
border-radius: 6px;
padding: 8px 10px;
color: var(--color-secondary-text);
background: var(--color-panel-subtle);
cursor: pointer;
text-align: left;
} }
.led-region-tabs button.active { .led-region-tabs button.active {
color: #f8fbf9; border-color: var(--color-active);
background: #226957; background: var(--color-active-surface);
} }
.led-options { .led-region-tabs strong {
display: grid; min-width: 0;
gap: 10px; overflow: hidden;
margin-bottom: 18px; text-overflow: ellipsis;
}
.led-option {
display: grid;
grid-template-columns: max-content minmax(0, max-content) max-content;
gap: 10px;
align-items: center;
justify-content: start;
}
.led-option span {
white-space: nowrap; white-space: nowrap;
font-size: 0.9rem;
} }
.led-option input[type="color"] { .led-region-swatch {
width: 52px; width: 14px;
min-width: 52px; height: 14px;
border: 1px solid var(--color-input-border);
border-radius: 999px;
}
.led-section h4,
.led-slider-label span,
.led-color-control span {
color: var(--color-small-text);
font-size: 0.78rem;
font-weight: 700;
}
.led-slider-label strong {
font-size: 0.95rem;
}
.led-section {
display: grid;
gap: 10px;
}
.led-section h4 {
margin: 0;
}
.led-effect-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
gap: 8px;
}
.led-effect-grid button {
min-height: 44px;
}
.led-effect-grid button:disabled {
cursor: default;
opacity: 1;
}
.led-controls {
display: grid;
gap: 12px;
}
.led-color-control {
display: grid;
grid-template-columns: 120px 72px minmax(92px, 1fr);
gap: 12px;
align-items: center;
}
.led-color-control input[type="color"] {
width: 64px;
min-width: 64px;
height: 42px;
min-height: 42px;
padding: 3px; padding: 3px;
} }
.slider-row { .led-color-control strong {
display: grid; color: var(--color-small-text);
grid-template-columns: 90px 110px minmax(0, 1fr); font-size: 0.82rem;
gap: 12px;
align-items: center;
margin-bottom: 12px;
} }
.slider-row input[type="range"] { .led-slider-row {
display: grid;
grid-template-columns: 120px minmax(180px, 1fr) 92px;
gap: 12px;
align-items: center;
}
.led-slider-label {
display: flex;
justify-content: space-between;
gap: 8px;
align-items: baseline;
}
.led-slider-row input[type="range"] {
width: 100%; width: 100%;
} }
.led-slider-row input[type="number"] {
min-width: 0;
}
.stage-list span, .stage-list span,
.info-list dt { .info-list dt {
color: #66736f; color: var(--color-small-text);
font-size: 0.78rem; font-size: 0.78rem;
font-weight: 700; font-weight: 700;
} }
@ -285,14 +500,16 @@
@media (max-width: 760px) { @media (max-width: 760px) {
.app-shell, .app-shell,
.panel-grid, .panel-grid,
.dpi-editor,
.field.inline, .field.inline,
.info-list, .info-list,
.inline-pair { .inline-pair,
.led-color-control,
.led-slider-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.topbar, .topbar,
.led-panel-header,
.profile-row { .profile-row {
align-items: stretch; align-items: stretch;
flex-direction: column; flex-direction: column;

View file

@ -1,6 +1,39 @@
:root { :root {
color: #18201f; color-scheme: light;
background: #eef1ee; --color-text: #18201f;
--color-page: #eef1ee;
--color-sidebar-text: #f4f7f5;
--color-sidebar-bg: #12201d;
--color-muted: #6f7f7a;
--color-sidebar-muted: #9baca6;
--color-primary-text: #09201a;
--color-primary: #60d394;
--color-secondary-text: #25322f;
--color-secondary-bg: #dfe7e2;
--color-danger-text: #fff8f8;
--color-danger: #a64545;
--color-nav-text: #dfe9e4;
--color-nav-hover: #223b36;
--color-status-border: #cbd5d0;
--color-status-text: #33413d;
--color-surface: #fbfdfb;
--color-warning: #d4b25f;
--color-warning-bg: #fff7de;
--color-label: #52605c;
--color-input-border: #bec9c4;
--color-input-bg: #ffffff;
--color-sidebar-input-text: #10201c;
--color-active-text: #f8fbf9;
--color-active: #226957;
--color-panel-border: #d5ddd8;
--color-border-subtle: #d8e0dc;
--color-panel-subtle: #f4f7f5;
--color-active-surface: #e3f3ec;
--color-metric: #1f5c4c;
--color-small-text: #66736f;
color: var(--color-text);
background: var(--color-page);
accent-color: var(--color-primary);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 16px; font-size: 16px;
font-synthesis: none; font-synthesis: none;
@ -10,6 +43,43 @@
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
@media (prefers-color-scheme: dark) {
:root {
color-scheme: dark;
--color-text: #e4ece8;
--color-page: #0f1514;
--color-sidebar-text: #f3faf6;
--color-sidebar-bg: #0b1715;
--color-muted: #9fb2ab;
--color-sidebar-muted: #9eb2ab;
--color-primary-text: #06130f;
--color-primary: #69e6a2;
--color-secondary-text: #dce7e2;
--color-secondary-bg: #22302d;
--color-danger-text: #fff8f8;
--color-danger: #d05a5a;
--color-nav-text: #d8e7e1;
--color-nav-hover: #1b332f;
--color-status-border: #37504a;
--color-status-text: #d8e7e1;
--color-surface: #161f1d;
--color-warning: #e1bd66;
--color-warning-bg: #3a2f14;
--color-label: #b3c2bd;
--color-input-border: #435750;
--color-input-bg: #101917;
--color-sidebar-input-text: #e6f0ec;
--color-active-text: #f8fbf9;
--color-active: #2f967c;
--color-panel-border: #2a3a36;
--color-border-subtle: #344942;
--color-panel-subtle: #1b2925;
--color-active-surface: #17392f;
--color-metric: #7ce3b4;
--color-small-text: #9badb0;
}
}
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
@ -42,8 +112,8 @@ button {
flex-direction: column; flex-direction: column;
gap: 18px; gap: 18px;
padding: 24px; padding: 24px;
color: #f4f7f5; color: var(--color-sidebar-text);
background: #12201d; background: var(--color-sidebar-bg);
} }
.brand { .brand {
@ -76,12 +146,12 @@ button {
.brand p, .brand p,
.eyebrow, .eyebrow,
.subtle { .subtle {
color: #6f7f7a; color: var(--color-muted);
font-size: 0.82rem; font-size: 0.82rem;
} }
.sidebar .brand p { .sidebar .brand p {
color: #9baca6; color: var(--color-sidebar-muted);
} }
.primary-action, .primary-action,
@ -94,24 +164,25 @@ nav button,
} }
.primary-action { .primary-action {
color: #09201a; color: var(--color-primary-text);
background: #60d394; background: var(--color-primary);
font-weight: 700; font-weight: 700;
} }
.secondary-action { .secondary-action {
padding: 0 14px; padding: 0 14px;
color: #25322f; color: var(--color-secondary-text);
background: #dfe7e2; background: var(--color-secondary-bg);
font-weight: 600; font-weight: 600;
} }
.secondary-action.danger { .secondary-action.danger {
color: #fff8f8; color: var(--color-danger-text);
background: #a64545; background: var(--color-danger);
} }
.primary-action:disabled, .primary-action:disabled,
.secondary-action:disabled,
nav button:disabled, nav button:disabled,
select:disabled { select:disabled {
cursor: not-allowed; cursor: not-allowed;
@ -125,7 +196,7 @@ nav {
} }
nav button { nav button {
color: #dfe9e4; color: var(--color-nav-text);
background: transparent; background: transparent;
text-align: left; text-align: left;
padding: 0 12px; padding: 0 12px;
@ -133,10 +204,11 @@ nav button {
nav button:hover, nav button:hover,
nav button.active { nav button.active {
background: #223b36; background: var(--color-nav-hover);
} }
.workspace { .workspace {
position: relative;
min-width: 0; min-width: 0;
padding: 28px; padding: 28px;
overflow-x: hidden; overflow-x: hidden;
@ -159,19 +231,50 @@ nav button.active {
line-height: 1.15; line-height: 1.15;
} }
.status { .toast-region {
max-width: 420px; position: fixed;
border: 1px solid #cbd5d0; top: 20px;
border-radius: 6px; right: 24px;
padding: 10px 12px; z-index: 20;
color: #33413d; display: grid;
background: #fbfdfb; width: min(420px, calc(100vw - 48px));
overflow-wrap: anywhere; gap: 10px;
pointer-events: none;
} }
.status.busy { .toast {
border-color: #d4b25f; display: grid;
background: #fff7de; grid-template-columns: minmax(0, 1fr) 28px;
gap: 12px;
align-items: start;
border: 1px solid var(--color-status-border);
border-radius: 6px;
padding: 10px 12px;
color: var(--color-status-text);
background: var(--color-surface);
box-shadow: 0 12px 32px rgb(0 0 0 / 18%);
overflow-wrap: anywhere;
pointer-events: auto;
}
.toast.busy {
border-color: var(--color-warning);
background: var(--color-warning-bg);
}
.toast button {
display: inline-grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: 6px;
color: inherit;
background: transparent;
cursor: pointer;
}
.toast button:hover {
background: var(--color-secondary-bg);
} }
.field { .field {
@ -186,7 +289,7 @@ nav button.active {
.field span, .field span,
.profile-row > span { .profile-row > span {
color: #52605c; color: var(--color-label);
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 700; font-weight: 700;
} }
@ -196,11 +299,121 @@ textarea,
select { select {
width: 100%; width: 100%;
min-height: 38px; min-height: 38px;
border: 1px solid #bec9c4; border: 1px solid var(--color-input-border);
border-radius: 6px; border-radius: 6px;
padding: 0 10px; padding: 0 10px;
color: #18201f; color: var(--color-text);
background: #ffffff; background: var(--color-input-bg);
}
.app-select-field {
min-width: 0;
}
.app-select {
position: relative;
width: 100%;
}
.app-select-click-away {
position: fixed;
inset: 0;
z-index: 29;
border-radius: 0;
padding: 0;
background: transparent;
cursor: default;
}
.app-select-trigger {
display: grid;
grid-template-columns: minmax(0, 1fr) 18px;
gap: 10px;
align-items: center;
width: 100%;
min-height: 38px;
border: 1px solid var(--color-input-border);
border-radius: 6px;
padding: 0 10px;
color: var(--color-text);
background: var(--color-input-bg);
cursor: pointer;
text-align: left;
}
.app-select.open .app-select-trigger {
position: relative;
z-index: 31;
}
.sidebar .app-select-trigger {
color: var(--color-sidebar-input-text);
}
.app-select-trigger:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.app-select-trigger span {
min-width: 0;
overflow: hidden;
color: inherit;
font-size: 0.9rem;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-select-trigger .app-select-arrow {
color: var(--color-label);
font-size: 0.75rem;
text-align: right;
}
.app-select-menu {
position: absolute;
z-index: 30;
top: calc(100% + 4px);
right: 0;
left: 0;
display: grid;
max-height: 260px;
overflow: auto;
border: 1px solid var(--color-input-border);
border-radius: 6px;
padding: 4px;
background: var(--color-input-bg);
box-shadow: 0 16px 32px rgb(0 0 0 / 24%);
}
.app-select-menu button {
min-height: 36px;
border-radius: 4px;
padding: 8px 10px;
color: var(--color-text);
background: transparent;
cursor: pointer;
overflow-wrap: anywhere;
text-align: left;
}
.app-select-menu button:hover,
.app-select-menu button.active {
color: var(--color-active-text);
background: var(--color-active);
}
select,
option,
optgroup {
color-scheme: light dark;
}
option,
optgroup {
color: var(--color-text);
background: var(--color-input-bg);
} }
textarea { textarea {
@ -210,7 +423,15 @@ textarea {
} }
.sidebar select { .sidebar select {
color: #10201c; color: var(--color-sidebar-input-text);
}
@media (prefers-color-scheme: dark) {
select,
option,
optgroup {
color-scheme: dark;
}
} }
.profile-row { .profile-row {
@ -228,13 +449,13 @@ textarea {
.segments button { .segments button {
padding: 0 14px; padding: 0 14px;
color: #25322f; color: var(--color-secondary-text);
background: #dfe7e2; background: var(--color-secondary-bg);
} }
.segments button.active { .segments button.active {
color: #f8fbf9; color: var(--color-active-text);
background: #226957; background: var(--color-active);
} }
.panel-grid { .panel-grid {
@ -245,10 +466,10 @@ textarea {
.panel, .panel,
.empty-state { .empty-state {
border: 1px solid #d5ddd8; border: 1px solid var(--color-panel-border);
border-radius: 8px; border-radius: 8px;
padding: 20px; padding: 20px;
background: #fbfdfb; background: var(--color-surface);
} }
.panel.wide, .panel.wide,