Add configurable native glass window backgrounds

This commit is contained in:
karl committed 2026-10-13 10:12:25 +02:00
1 parent 19d949fd13
commit 981991bac0
14 files changed
+687 -5

No files matched your search

+5 -1
View File
@@ -62,7 +62,11 @@ like an explanation, expand the small **Why this result?** detail.
Light and dark themes follow your system preference. Keyboard navigation,
short transitions and reduced-motion support keep the interface familiar
and comfortable.
and comfortable. In **Settings → Appearance**, choose a solid, glass or
frosted-glass window background and adjust its tint. Reading surfaces stay
clear. Native glass currently requires **Windows 11 22H2 or later**. macOS,
Linux, unsupported Windows versions and high-contrast mode currently use a
solid background; your appearance preference is retained.
![searchi finding and previewing a mountain lake photograph](docs/screenshots/media-search.png)
+2
View File
@@ -3823,6 +3823,8 @@ dependencies = [
"tauri-plugin-opener",
"tempfile",
"uuid",
"window-vibrancy",
"windows-sys 0.61.2",
]
[[package]]
+4
View File
@@ -30,3 +30,7 @@ searchi-media = { path = "../../../crates/searchi-media" }
searchi-ai = { path = "../../../crates/searchi-ai" }
uuid = { version = "1", features = ["v4"] }
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "bmp"] }
[target.'cfg(windows)'.dependencies]
window-vibrancy = "=0.8.1"
windows-sys = { version = "0.61", features = ["Win32_UI_Accessibility", "Win32_System_SystemInformation", "Wdk_System_SystemServices", "Win32_Graphics_Dwm", "Win32_UI_WindowsAndMessaging"] }
+128
View File
@@ -0,0 +1,128 @@
//! Native materials are applied on the UI thread. Unsupported systems stay
//! opaque; stored preferences are retained for the user's next supported session.
use crate::settings::BackgroundMaterial;
use serde::Serialize;
use tauri::WebviewWindow;
#[derive(Clone, Debug, Serialize)]
pub struct AppearanceStatus {
pub requested_material: BackgroundMaterial,
pub effective_material: BackgroundMaterial,
pub opacity: u8,
pub native_effect: bool,
pub fallback_reason: Option<String>,
}
pub fn apply(
window: &WebviewWindow,
material: BackgroundMaterial,
opacity: u8,
high_contrast: bool,
) -> AppearanceStatus {
let mut status = AppearanceStatus {
requested_material: material,
effective_material: BackgroundMaterial::Solid,
opacity,
native_effect: false,
fallback_reason: None,
};
if high_contrast || material == BackgroundMaterial::Solid || opacity == 100 {
#[cfg(windows)]
{
let _ = window_vibrancy::clear_acrylic(window);
}
if high_contrast {
status.fallback_reason = Some("High contrast uses an opaque background.".into());
}
return status;
}
#[cfg(windows)]
{
if !native_backdrop_available() {
status.fallback_reason = Some("Glass requires Windows 11 22H2 or newer with desktop composition enabled and high contrast off. Using a solid background.".into());
return status;
}
let _ = window_vibrancy::clear_acrylic(window);
// Keep the webview clear; the frontend draws a theme-aware readable tint.
let result = window
.set_background_color(Some(tauri::window::Color(0, 0, 0, 0)))
.map_err(|error| error.to_string())
.and_then(|()| {
match material {
BackgroundMaterial::Glass => {
window_vibrancy::apply_acrylic(window, Some((0, 0, 0, 0)))
}
BackgroundMaterial::Frosted => {
window_vibrancy::apply_acrylic(window, Some((0, 0, 0, 0)))
}
BackgroundMaterial::Solid => unreachable!(),
}
.map_err(|error| error.to_string())
});
match result {
Ok(()) => {
status.effective_material = material;
status.native_effect = true;
}
Err(_) => {
status.fallback_reason =
Some("Native transparency is unavailable. Using a solid background.".into());
}
}
}
#[cfg(not(windows))]
{
let _ = window;
status.fallback_reason = Some(
if cfg!(target_os = "macos") {
"Native glass is unavailable in this macOS build. Using a solid background."
} else {
"Native glass is unavailable on this desktop. Using a solid background."
}
.into(),
);
}
status
}
#[cfg(windows)]
fn native_backdrop_available() -> bool {
use windows_sys::Win32::UI::Accessibility::{HCF_HIGHCONTRASTON, HIGHCONTRASTW};
use windows_sys::Win32::{
Graphics::Dwm::DwmIsCompositionEnabled,
UI::WindowsAndMessaging::{SystemParametersInfoW, SPI_GETHIGHCONTRAST},
};
let mut composition = 0;
let mut contrast = HIGHCONTRASTW {
cbSize: std::mem::size_of::<HIGHCONTRASTW>() as u32,
dwFlags: 0,
lpszDefaultScheme: std::ptr::null_mut(),
};
// Failure to query accessibility/composition is treated conservatively.
unsafe {
if DwmIsCompositionEnabled(&mut composition) < 0
|| composition == 0
|| SystemParametersInfoW(
SPI_GETHIGHCONTRAST,
contrast.cbSize,
&mut contrast as *mut _ as *mut _,
0,
) == 0
|| contrast.dwFlags & HCF_HIGHCONTRASTON != 0
{
return false;
}
}
// The established crate checks the HRESULT of the public backdrop API on
// Windows 11 22H2+. Its older undocumented path cannot confirm success.
let mut version = windows_sys::Win32::System::SystemInformation::OSVERSIONINFOW {
dwOSVersionInfoSize: std::mem::size_of::<
windows_sys::Win32::System::SystemInformation::OSVERSIONINFOW,
>() as u32,
..unsafe { std::mem::zeroed() }
};
unsafe {
windows_sys::Wdk::System::SystemServices::RtlGetVersion(&mut version) >= 0
&& version.dwBuildNumber >= 22621
}
}
+99
View File
@@ -4,6 +4,7 @@
compile_error!("Release desktop builds require --features custom-protocol to embed the frontend");
mod ai_commands;
mod appearance;
mod data_directory;
mod model_download;
mod semantic_commands;
@@ -32,6 +33,7 @@ use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState};
use tauri_plugin_opener::OpenerExt;
struct AppState {
appearance: Arc<Mutex<appearance::AppearanceStatus>>,
ai: Arc<AiSession>,
database: PathBuf,
lexical: Arc<Mutex<SearchIndex>>,
@@ -85,6 +87,7 @@ fn measured_outcome(
#[derive(Serialize)]
struct DesktopStatus {
appearance: appearance::AppearanceStatus,
settings: Settings,
indexing: bool,
shortcut_status: String,
@@ -94,6 +97,11 @@ struct DesktopStatus {
#[tauri::command]
fn desktop_status(state: State<'_, AppState>) -> Result<DesktopStatus, String> {
Ok(DesktopStatus {
appearance: state
.appearance
.lock()
.map_err(|_| "Appearance is unavailable.")?
.clone(),
settings: state
.settings
.lock()
@@ -124,6 +132,92 @@ fn set_cpu_threads(threads: usize, state: State<'_, AppState>) -> Result<Setting
Ok(store.value.clone())
}
#[tauri::command]
async fn set_appearance(
material: settings::BackgroundMaterial,
opacity: u8,
high_contrast: bool,
app: tauri::AppHandle,
state: State<'_, AppState>,
) -> Result<appearance::AppearanceStatus, String> {
{
let mut store = state
.settings
.lock()
.map_err(|_| "Settings are unavailable.")?;
let mut next = store.value.clone();
next.background_material = material;
next.background_opacity = opacity;
store.save(next)?;
}
apply_saved_appearance(
app,
state.appearance.clone(),
material,
opacity,
high_contrast,
)
.await
}
#[tauri::command]
async fn apply_appearance(
high_contrast: bool,
app: tauri::AppHandle,
state: State<'_, AppState>,
) -> Result<appearance::AppearanceStatus, String> {
let (material, opacity) = {
let store = state
.settings
.lock()
.map_err(|_| "Settings are unavailable.")?;
(
store.value.background_material,
store.value.background_opacity,
)
};
apply_saved_appearance(
app,
state.appearance.clone(),
material,
opacity,
high_contrast,
)
.await
}
async fn apply_saved_appearance(
app: tauri::AppHandle,
current: Arc<Mutex<appearance::AppearanceStatus>>,
material: settings::BackgroundMaterial,
opacity: u8,
high_contrast: bool,
) -> Result<appearance::AppearanceStatus, String> {
let window = app
.get_webview_window("main")
.ok_or("Main window is unavailable.")?;
let (sender, receiver) = std::sync::mpsc::sync_channel(1);
app.run_on_main_thread(move || {
let status = appearance::apply(&window, material, opacity, high_contrast);
let result = current
.lock()
.map_err(|_| "Appearance is unavailable.".to_string())
.map(|mut current| {
*current = status.clone();
status
});
let _ = sender.send(result);
})
.map_err(|error| error.to_string())?;
tauri::async_runtime::spawn_blocking(move || {
receiver
.recv_timeout(std::time::Duration::from_secs(5))
.map_err(|_| "Appearance update was cancelled.".to_string())?
})
.await
.map_err(|error| error.to_string())?
}
#[derive(Default, Deserialize)]
struct Filters {
#[serde(default)]
@@ -557,7 +651,10 @@ fn main() {
Err(error) => format!("Global shortcut unavailable: {error}"),
}
};
let window = app.get_webview_window("main").ok_or_else(|| std::io::Error::other("Main window is unavailable."))?;
let appearance = appearance::apply(&window, settings.value.background_material, settings.value.background_opacity, false);
app.manage(AppState {
appearance: Arc::new(Mutex::new(appearance)),
ai: Arc::new(AiSession::default()),
database,
lexical,
@@ -582,6 +679,8 @@ fn main() {
prepare_media_preview,
desktop_status,
set_cpu_threads,
set_appearance,
apply_appearance,
semantic_status,
setup_model,
index_embeddings,
+50
View File
@@ -7,6 +7,19 @@ use std::{
path::{Path, PathBuf},
};
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum BackgroundMaterial {
Solid,
Glass,
#[default]
Frosted,
}
fn default_background_opacity() -> u8 {
90
}
const SETTINGS_VERSION: u32 = 2;
const MAX_SETTINGS_BYTES: u64 = 128 * 1024;
@@ -14,6 +27,10 @@ const MAX_SETTINGS_BYTES: u64 = 128 * 1024;
#[serde(deny_unknown_fields)]
pub struct Settings {
pub version: u32,
#[serde(default)]
pub background_material: BackgroundMaterial,
#[serde(default = "default_background_opacity")]
pub background_opacity: u8,
pub roots: Vec<PathBuf>,
pub cpu_threads: usize,
/// Managed local Google EmbeddingGemma2 artifacts. Older settings default
@@ -26,6 +43,8 @@ impl Default for Settings {
fn default() -> Self {
Self {
version: SETTINGS_VERSION,
background_material: BackgroundMaterial::default(),
background_opacity: default_background_opacity(),
roots: Vec::new(),
cpu_threads: 2,
model_directory: None,
@@ -38,6 +57,9 @@ impl Settings {
if self.version != SETTINGS_VERSION {
return Err(format!("Unsupported settings version {}", self.version));
}
if !(70..=100).contains(&self.background_opacity) {
return Err("Background opacity must be between 70 and 100 percent.".into());
}
if !(1..=8).contains(&self.cpu_threads) {
return Err("CPU threads must be between 1 and 8.".into());
}
@@ -229,3 +251,31 @@ mod budget_migration_tests {
assert_eq!(SettingsStore::load(path).unwrap().value.cpu_threads, 8);
}
}
#[cfg(test)]
mod appearance_tests {
use super::*;
#[test]
fn legacy_defaults_and_saved_appearance_round_trip() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("settings.json");
fs::write(&path, r#"{"version":2,"roots":[],"cpu_threads":2}"#).unwrap();
let mut store = SettingsStore::load(path.clone()).unwrap();
assert_eq!(store.value.background_material, BackgroundMaterial::Frosted);
assert_eq!(store.value.background_opacity, 90);
let mut next = store.value.clone();
next.background_material = BackgroundMaterial::Glass;
next.background_opacity = 70;
store.save(next.clone()).unwrap();
assert_eq!(SettingsStore::load(path.clone()).unwrap().value, next);
let bytes = fs::read(&path).unwrap();
for opacity in [0, 69, 101, 255] {
let mut invalid = next.clone();
invalid.background_opacity = opacity;
assert!(store.save(invalid).is_err());
assert_eq!(store.value, next);
assert_eq!(fs::read(&path).unwrap(), bytes);
}
assert!(serde_json::from_str::<BackgroundMaterial>("\"other\"").is_err());
}
}
@@ -0,0 +1 @@
{"app":{"windows":[{"title":"searchi","width":1080,"height":760,"minWidth":680,"minHeight":540,"transparent":true}]}}
+91
View File
@@ -0,0 +1,91 @@
/** Only a confirmed native compositor effect may expose the desktop behind the shell.
* Reading surfaces retain their opaque backgrounds independently of this material. */
export type BackgroundMaterial = "solid" | "glass" | "frosted";
export interface AppearanceStatus {
requested_material: BackgroundMaterial;
effective_material: BackgroundMaterial;
opacity: number;
native_effect: boolean;
fallback_reason: string | null;
}
export function appearanceView(
status: AppearanceStatus | undefined,
highContrast: boolean,
) {
const valid =
status &&
["glass", "frosted"].includes(status.effective_material) &&
status.native_effect;
const material: BackgroundMaterial =
!highContrast && valid ? status.effective_material : "solid";
const opacity = Number.isFinite(status?.opacity)
? Math.max(70, Math.min(100, status!.opacity))
: 90;
const message = highContrast
? "High contrast uses a solid background. Your appearance preference is retained."
: status?.fallback_reason
? `Solid background: ${status.fallback_reason}`
: material === "solid"
? "Solid background. Search results and passages stay clear."
: "The desktop shows subtly through the window. Search results and passages stay clear.";
return { material, opacity, message };
}
/** Serializes compositor/settings mutations; a waiting update supersedes earlier
* pending work, retaining an unsaved preference when contrast also changes. */
export class AppearanceUpdates {
revision = 0;
private running = false;
private pending: {
revision: number;
highContrast: boolean;
preference?: { material: BackgroundMaterial; opacity: number };
} | null = null;
private write: (update: {
highContrast: boolean;
preference?: { material: BackgroundMaterial; opacity: number };
}) => Promise<AppearanceStatus>;
private publish: (status: AppearanceStatus) => void;
private fail: (error: unknown) => void;
private settled: () => void;
constructor(
write: AppearanceUpdates["write"],
publish: AppearanceUpdates["publish"],
fail: AppearanceUpdates["fail"],
settled: AppearanceUpdates["settled"],
) {
this.write = write;
this.publish = publish;
this.fail = fail;
this.settled = settled;
}
get busy() {
return this.running || this.pending !== null;
}
submit(
highContrast: boolean,
preference?: { material: BackgroundMaterial; opacity: number },
) {
this.pending = {
revision: ++this.revision,
highContrast,
preference: preference ?? this.pending?.preference,
};
if (!this.running) void this.drain();
}
private async drain() {
this.running = true;
while (this.pending) {
const update = this.pending;
this.pending = null;
try {
const status = await this.write(update);
if (update.revision === this.revision) this.publish(status);
} catch (error) {
if (update.revision === this.revision) this.fail(error);
}
}
this.running = false;
this.settled();
}
}
+91 -2
View File
@@ -6,6 +6,8 @@ import {
isCaptureSurface,
prepareMediaURL,
} from "./desktop-api";
import { appearanceView, AppearanceUpdates } from "./appearance-state";
import type { AppearanceStatus, BackgroundMaterial } from "./appearance-state";
import { listen } from "@tauri-apps/api/event";
import {
AutomaticIndexGate,
@@ -84,11 +86,14 @@ interface Settings {
version: number;
roots: string[];
cpu_threads: number;
background_material?: BackgroundMaterial;
background_opacity?: number;
}
interface DesktopStatus {
settings: Settings;
indexing: boolean;
shortcut_status: string;
appearance?: AppearanceStatus;
watching?: {
root: string;
attached: boolean;
@@ -140,7 +145,7 @@ interface SearchRequest {
}
document.querySelector<HTMLDivElement>("#app")!.innerHTML = `
<header><div class="brand"><img class="brand-symbol" src="/searchi-symbol.png" alt=""><span class="wordmark">searchi</span></div><span id="index-status" class="index-status">Loading your index…</span><div class="header-actions"><button id="add-folder" class="quiet-button" type="button">${icon("folderAdd")}<span>Add folder</span></button><details id="filters" class="popover"><summary>${icon("filter")}<span id="scope-label">All files</span>${icon("chevron")}</summary><div class="popover-panel"><h2>Filter your search</h2><div class="filters-grid"><label>Search scope<select id="filter-scope"><option value="all">All files</option><option value="code">Code</option></select></label><label>Within folder<input id="filter-folder" placeholder="Absolute folder path"></label><label>Extension<input id="filter-extension" placeholder="pdf, md, txt…"></label><label>Minimum size (bytes)<input id="filter-min" type="number" min="0" step="1"></label><label>Maximum size (bytes)<input id="filter-max" type="number" min="0" step="1"></label><label>Minimum duration (seconds)<input id="filter-duration-min" type="number" min="0" step="0.1"></label><label>Maximum duration (seconds)<input id="filter-duration-max" type="number" min="0" step="0.1"></label><label>Modified from<input id="filter-after" type="date"></label><label>Modified through<input id="filter-before" type="date"></label></div></div></details><details id="settings" class="popover"><summary class="icon-button" aria-label="Settings" title="Settings">${icon("settings")}</summary><div class="popover-panel"><h2>Folders & resources</h2><p id="shortcut-status" class="hint"></p><label class="resource-label" for="cpu-threads">CPU thread budget</label><select id="cpu-threads">${Array.from({ length: 8 }, (_, i) => `<option value="${i + 1}">${i + 1} ${i === 1 ? "threads (default)" : i ? "threads" : "thread (low power)"}</option>`).join("")}</select><p class="hint">Search automatically uses supported acceleration with CPU fallback. Two or more threads let search run alongside indexing; one thread can delay semantic search during indexing.</p><ul id="roots" aria-label="Selected folders"></ul><label class="performance-toggle"><input id="performance-toggle" type="checkbox">Performance details</label><p class="hint">Local timing diagnostics for new searches. Queries and file paths are excluded from copied reports.</p><details id="ai-settings" class="ai-settings"><summary>Optional AI answers</summary><label class="performance-toggle"><input id="ai-enabled" type="checkbox">Enable summaries and questions</label><p class="hint">Disabled by default. Only an explicit action sends the shown passage to your configured endpoint.</p><label>OpenAI-compatible base URL<input id="ai-endpoint" type="url" placeholder="http://localhost:1234/v1" autocomplete="off" maxlength="2048"></label><label>Model<input id="ai-model" placeholder="Your model name" autocomplete="off" maxlength="200"></label><label>API key (optional, this session only)<input id="ai-key" type="password" autocomplete="off" maxlength="2048"></label><button id="ai-clear-key" type="button">Clear key</button><p class="hint">Endpoint, model and key stay in this app session. The key authenticates requests to your endpoint.</p></details></div></details></div></header>
<header><div class="brand"><img class="brand-symbol" src="/searchi-symbol.png" alt=""><span class="wordmark">searchi</span></div><span id="index-status" class="index-status">Loading your index…</span><div class="header-actions"><button id="add-folder" class="quiet-button" type="button">${icon("folderAdd")}<span>Add folder</span></button><details id="filters" class="popover"><summary>${icon("filter")}<span id="scope-label">All files</span>${icon("chevron")}</summary><div class="popover-panel"><h2>Filter your search</h2><div class="filters-grid"><label>Search scope<select id="filter-scope"><option value="all">All files</option><option value="code">Code</option></select></label><label>Within folder<input id="filter-folder" placeholder="Absolute folder path"></label><label>Extension<input id="filter-extension" placeholder="pdf, md, txt…"></label><label>Minimum size (bytes)<input id="filter-min" type="number" min="0" step="1"></label><label>Maximum size (bytes)<input id="filter-max" type="number" min="0" step="1"></label><label>Minimum duration (seconds)<input id="filter-duration-min" type="number" min="0" step="0.1"></label><label>Maximum duration (seconds)<input id="filter-duration-max" type="number" min="0" step="0.1"></label><label>Modified from<input id="filter-after" type="date"></label><label>Modified through<input id="filter-before" type="date"></label></div></div></details><details id="settings" class="popover"><summary class="icon-button" aria-label="Settings" title="Settings">${icon("settings")}</summary><div class="popover-panel"><h2>Settings</h2><section class="appearance-settings" aria-labelledby="appearance-heading"><h3 id="appearance-heading">Appearance</h3><label for="background-material">Window background</label><select id="background-material"><option value="solid">Solid</option><option value="glass">Glass</option><option value="frosted">Frosted glass</option></select><div class="appearance-opacity"><label for="background-opacity">Background tint</label><output id="background-opacity-value" for="background-opacity">90%</output></div><input id="background-opacity" type="range" min="70" max="100" step="1" value="90" aria-describedby="appearance-status"><p id="appearance-status" class="hint" role="status">A subtle window background. Search results and passages stay clear.</p></section><h3>Folders & resources</h3><p id="shortcut-status" class="hint"></p><label class="resource-label" for="cpu-threads">CPU thread budget</label><select id="cpu-threads">${Array.from({ length: 8 }, (_, i) => `<option value="${i + 1}">${i + 1} ${i === 1 ? "threads (default)" : i ? "threads" : "thread (low power)"}</option>`).join("")}</select><p class="hint">Search automatically uses supported acceleration with CPU fallback. Two or more threads let search run alongside indexing; one thread can delay semantic search during indexing.</p><ul id="roots" aria-label="Selected folders"></ul><label class="performance-toggle"><input id="performance-toggle" type="checkbox">Performance details</label><p class="hint">Local timing diagnostics for new searches. Queries and file paths are excluded from copied reports.</p><details id="ai-settings" class="ai-settings"><summary>Optional AI answers</summary><label class="performance-toggle"><input id="ai-enabled" type="checkbox">Enable summaries and questions</label><p class="hint">Disabled by default. Only an explicit action sends the shown passage to your configured endpoint.</p><label>OpenAI-compatible base URL<input id="ai-endpoint" type="url" placeholder="http://localhost:1234/v1" autocomplete="off" maxlength="2048"></label><label>Model<input id="ai-model" placeholder="Your model name" autocomplete="off" maxlength="200"></label><label>API key (optional, this session only)<input id="ai-key" type="password" autocomplete="off" maxlength="2048"></label><button id="ai-clear-key" type="button">Clear key</button><p class="hint">Endpoint, model and key stay in this app session. The key authenticates requests to your endpoint.</p></details></div></details></div></header>
<main>
<form id="search-form" role="search"><label class="sr-only" for="query">Search your files</label>${icon("search")}<input id="query" type="search" placeholder="Search a filename, phrase, or what you remember…" autocomplete="off" spellcheck="false" maxlength="1000"><kbd id="focus-shortcut">Ctrl K</kbd><button id="clear-query" class="icon-button" type="button" aria-label="Clear search" title="Clear search">${icon("dismiss")}</button></form>
<details id="semantic-panel"><summary>${icon("sparkles")}<span id="semantic-status" role="status" aria-live="polite">Preparing local semantic search…</span><span id="semantic-device" class="kind">Local model</span>${icon("chevron")}</summary><div class="semantic-content"><strong>Google EmbeddingGemma 2</strong><progress id="semantic-progress" aria-label="Local model setup progress"></progress><p id="semantic-detail" class="hint">The verified local model downloads automatically on first launch (865 MB). Your files stay on this device.</p><div class="semantic-actions"><button id="retry-model" type="button" hidden>Retry model setup</button><button id="rebuild-semantic" type="button" hidden>Rebuild semantic index</button></div></div></details>
@@ -465,8 +470,88 @@ async function refreshSemanticStatus() {
}
}
async function refreshDesktopStatus() {
const highContrast = window.matchMedia("(forced-colors: active)");
const appearanceUpdates = new AppearanceUpdates(
(update) =>
invoke<AppearanceStatus>(
update.preference ? "set_appearance" : "apply_appearance",
{ ...update.preference, highContrast: update.highContrast },
),
(status) => renderAppearance(status),
showError,
() => {
renderAppearance();
void refreshDesktopStatus(true).catch(showError);
},
);
let lastAppearance: AppearanceStatus | undefined;
function renderAppearance(
status?: AppearanceStatus,
settings?: Settings,
restoreControls = false,
) {
if (status) lastAppearance = status;
const material = element<HTMLSelectElement>("background-material");
const opacity = element<HTMLInputElement>("background-opacity");
if (
!appearanceUpdates.busy &&
settings &&
(restoreControls || document.activeElement !== opacity)
) {
material.value = settings.background_material ?? "frosted";
opacity.value = String(settings.background_opacity ?? 90);
}
const view = appearanceView(lastAppearance, highContrast.matches);
document.documentElement.dataset.backgroundMaterial = view.material;
document.documentElement.style.setProperty(
"--window-opacity",
String(view.opacity / 100),
);
element("background-opacity-value").textContent = `${opacity.value}%`;
material.disabled = !isNativeDesktop() || appearanceUpdates.busy;
opacity.disabled =
!isNativeDesktop() ||
appearanceUpdates.busy ||
material.value === "solid" ||
highContrast.matches;
element("appearance-status").textContent = view.message;
}
function saveAppearance() {
if (!isNativeDesktop()) return;
appearanceUpdates.submit(highContrast.matches, {
material: element<HTMLSelectElement>("background-material")
.value as BackgroundMaterial,
opacity: Number(element<HTMLInputElement>("background-opacity").value),
});
renderAppearance();
}
element("background-material").addEventListener("change", () => {
void saveAppearance();
});
element("background-opacity").addEventListener("input", () => {
element("background-opacity-value").textContent =
`${element<HTMLInputElement>("background-opacity").value}%`;
});
element("background-opacity").addEventListener("change", () => {
void saveAppearance();
});
highContrast.addEventListener("change", () => {
renderAppearance(); // Fail closed immediately, before the native effect changes.
if (isNativeDesktop()) appearanceUpdates.submit(highContrast.matches);
});
async function refreshDesktopStatus(restoreAppearanceControls = false) {
const appearanceRevision = appearanceUpdates.revision;
const status = await invoke<DesktopStatus>("desktop_status");
if (
!appearanceUpdates.busy &&
appearanceRevision === appearanceUpdates.revision
)
renderAppearance(
status.appearance,
status.settings,
restoreAppearanceControls,
);
indexingActive = status.indexing;
element("shortcut-status").textContent = status.shortcut_status;
element("focus-shortcut").textContent = status.shortcut_status.includes(
@@ -1433,6 +1518,10 @@ document.addEventListener("keydown", (event) => {
});
async function initializeSurface() {
await initializeDesktopSurface();
renderAppearance();
if (isNativeDesktop()) {
appearanceUpdates.submit(highContrast.matches);
}
if (isDesktopSurface()) {
if (isCaptureSurface()) {
for (const id of [
+83
View File
@@ -949,3 +949,86 @@ aside pre.code-passage {
aside pre.code-passage {
line-height: 1.6;
}
/* Native effects blur the desktop; CSS only supplies its readable shell tint.
Never use backdrop-filter as a substitute for an operating-system material. */
:root[data-background-material="glass"],
:root[data-background-material="frosted"] {
background: transparent;
}
:root[data-background-material="glass"] #app {
background: rgba(248, 250, 252, var(--window-opacity, 0.9));
}
:root[data-background-material="frosted"] #app {
background: rgba(
248,
250,
252,
calc(0.35 + var(--window-opacity, 0.9) * 0.65)
);
}
.appearance-settings {
padding-bottom: 16px;
margin-bottom: 18px;
border-bottom: 1px solid var(--line);
}
.popover-panel h3 {
margin: 0 0 12px;
font-size: 13px;
}
.appearance-settings > label,
.appearance-opacity label {
font-size: 12px;
color: var(--muted);
}
#background-material {
display: block;
width: 100%;
margin: 7px 0 15px;
}
.appearance-opacity {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
#background-opacity-value {
font-size: 12px;
font-variant-numeric: tabular-nums;
}
#background-opacity {
display: block;
width: 100%;
padding: 0;
margin: 9px 0;
accent-color: var(--brand);
}
@media (prefers-color-scheme: dark) {
:root[data-background-material="glass"] #app {
background: rgba(11, 15, 20, var(--window-opacity, 0.9));
}
:root[data-background-material="frosted"] #app {
background: rgba(
11,
15,
20,
calc(0.35 + var(--window-opacity, 0.9) * 0.65)
);
}
}
@media (forced-colors: active) {
:root,
:root[data-background-material] #app {
background: Canvas;
}
}
/* Strong shell copy remains legible even above an unusually bright/dark desktop.
Opaque reading panes retain the quieter secondary-text hierarchy. */
:root:not([data-background-material="solid"])[data-background-material] header,
:root:not([data-background-material="solid"])[data-background-material] footer,
:root:not([data-background-material="solid"])[data-background-material]
#semantic-panel
> summary {
--muted: var(--ink);
}
@@ -0,0 +1,67 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {appearanceView, AppearanceUpdates} from '../src/appearance-state.ts';
const glass = () => ({requested_material:'frosted', effective_material:'frosted', opacity:90, native_effect:true, fallback_reason:null});
test('transparency requires a confirmed native effect', () => {
assert.equal(appearanceView(undefined, false).material, 'solid');
assert.equal(appearanceView({...glass(), native_effect:false}, false).material, 'solid');
assert.equal(appearanceView(glass(), false).material, 'frosted');
assert.equal(appearanceView({...glass(), effective_material:'glass'}, false).material, 'glass');
});
test('high contrast stays opaque without losing the preference', () => {
const status = glass();
assert.equal(appearanceView(status, true).material, 'solid');
assert.match(appearanceView(status, true).message, /preference is retained/);
assert.equal(status.requested_material, 'frosted');
assert.equal(appearanceView(status, false).material, 'frosted');
});
test('fallback is honest and shell tint is bounded', () => {
const fallback = {...glass(), effective_material:'solid', native_effect:false, fallback_reason:'Native effects unavailable.'};
assert.match(appearanceView(fallback, false).message, /Solid background/);
assert.equal(appearanceView({...glass(), opacity:5}, false).opacity, 70);
assert.equal(appearanceView({...glass(), opacity:110}, false).opacity, 100);
assert.equal(appearanceView({...glass(), opacity:NaN}, false).opacity, 90);
});
test('rapid pending changes serialize and only publish the latest response', async () => {
const writes = [], published = [], errors = [];
let resolve;
let settled;
const done = new Promise(r => settled = r);
const queue = new AppearanceUpdates(update => {
writes.push(update);
if (writes.length === 1) return new Promise(r => resolve = r);
return Promise.resolve({...glass(), opacity:update.preference.opacity});
}, status => published.push(status), error => errors.push(error), settled);
queue.submit(false, {material:'glass', opacity:80});
queue.submit(false, {material:'frosted', opacity:95});
queue.submit(true); // Preserve the pending saved preference when contrast toggles.
assert.equal(writes.length, 1);
assert.equal(queue.busy, true);
resolve({...glass(), effective_material:'glass', opacity:80});
await done;
assert.equal(writes.length, 2);
assert.equal(writes[1].highContrast, true);
assert.deepEqual(writes[1].preference, {material:'frosted', opacity:95});
assert.equal(published.length, 1);
assert.equal(published[0].opacity, 95);
assert.equal(queue.busy, false);
assert.deepEqual(errors, []);
});
test('failed stale mutation cannot suppress a queued contrast restore', async () => {
let reject, settled;
const done = new Promise(r => settled = r);
const writes = [], errors = [], published = [];
const queue = new AppearanceUpdates(update => {
writes.push(update);
return writes.length === 1 ? new Promise((_,r) => reject=r) : Promise.resolve(glass());
}, status => published.push(status), error => errors.push(error), settled);
queue.submit(true);
queue.submit(false);
reject(new Error('owned stale failure'));
await done;
assert.equal(writes.length,2);
assert.equal(writes[1].highContrast,false);
assert.equal(published.length,1);
assert.deepEqual(errors,[]);
});
+16
View File
@@ -44,3 +44,19 @@ The brand board's example `searchi winter.pdf` is illustrative. Documentation
must show the actual supported CLI syntax. Model downloads require networking;
file extraction, inference, and search remain local. Never turn visual marketing
copy into unsupported performance or production-readiness claims.
## Window materials
Appearance settings offer Solid, Glass and Frosted glass, with a bounded
70–100% tint-strength control (90% by default). Frosted glass layers an extra
35% milk tint beneath that control; Glass has the lighter direct tint. Both
currently share Windows native acrylic blur. Native compositor effects expose and soften
the actual desktop; CSS transparency alone cannot blur other windows. If the
platform cannot apply its native effect, use an honest opaque fallback. Forced
colors always make the shell opaque while retaining the saved preference.
Search input, results, previews and settings keep their opaque reading surfaces;
never lower text opacity or introduce decorative wallpaper simulations.
Native acrylic currently requires Windows 11 22H2 or later with a compatible
compositor and system transparency settings. macOS, Linux and older Windows
currently use opaque fallback. Native material appearance and compositor
performance still need actual OS validation.
+8 -2
View File
@@ -36,6 +36,10 @@ Status: implementation started; no production release yet. Windows VM tests use
see [incremental semantic design](INCREMENTAL-SEMANTIC.md).
## 3. Desktop
- [x] Persisted solid/glass/frosted appearance and bounded background tint;
opaque reading surfaces, serialized settings updates and high-contrast fallback.
- [ ] Verify native glass visually on Windows 11 22H2+; macOS/Linux currently
use an explicit opaque fallback. Native compositor performance remains open.
- [ ] Tauri shell, accessible keyboard search, progressive results and previews (shell built; UI interaction verification pending).
- [ ] Folder selection, exclusion controls, resource budgets and indexing status.
- [ ] Global shortcut, opening/revealing source files, PDF page navigation.
@@ -197,8 +201,10 @@ Audit and measured priorities: [EMBEDDINGGEMMA2-AUDIT.md](EMBEDDINGGEMMA2-AUDIT.
media retry status across engine/desktop; genuine CPU promotion reuses
identical document vectors with zero document re-encodes.
See [early text setup](EARLY-TEXT-SETUP.md).
- [ ] Verify native first use during genuine delayed media download,
failure/retry and promotion; record actual first-input timing separately.
- [x] Native Linux CPU first use during genuine delayed media download,
failure/retry and promotion; actual first-input timing recorded separately.
Focused activation retest preserves selection and query process, records
fresh timing/current generation after idle. Windows/macOS checks stay open.
- [ ] Buffered bounded media IPC and linear UTF-8 source-window offset processing.
- [ ] Evaluate 256d MRL and content-specific vision budgets on independent data.
- [ ] Validate GPU precision, synchronization-aware timings, parity and fallback
+42
View File
@@ -0,0 +1,42 @@
# Configurable window backgrounds
Settings → Appearance offers Solid, Glass and Frosted glass. Preferences are
saved locally. The tint-strength control ranges from 70 to 100%; the default
is Frosted glass at 90%. Glass uses the direct tint; Frosted glass adds a 35%
milk layer beneath it. This changes background treatment, never text opacity.
Search input, result and preview panes and settings retain opaque reading
surfaces. Shell secondary text becomes stronger over translucent backgrounds.
High contrast immediately forces an opaque surface without changing the saved
preference. Appearance writes are serialized; obsolete replies cannot overwrite
the latest preference or accessibility state.
## Platform support
Windows 11 22H2 and newer use a transparent WebView and the established
window-vibrancy crate's native acrylic implementation. Glass and Frosted glass
share that native blur; their frontend milk tint differs. Desktop composition
and OS high-contrast checks precede application, and native HRESULT failure
returns an explicit solid fallback. Native window chrome remains enabled.
Older Windows, macOS and Linux currently use a solid fallback. This macOS build
does not enable private WebView transparency APIs. CSS backdrop-filter is not
used to pretend the desktop is blurred. The settings report the effective
material and explain the fallback.
## Recorded checks, 2026-10-13
- 57 frontend tests passed, including missing native capability, high contrast,
bounded tint, serialized pending preferences and stale failure handling.
- Frontend type checking, production build and formatting passed.
- 33 desktop Rust tests passed with one test thread; settings checks cover legacy
defaults, persisted preferences and invalid-value rejection preserving the
previous file and memory. An earlier parallel run hit a transient Windows
AccessDenied in an existing atomic-settings replacement test.
- Desktop Rust formatting and strict all-target Clippy passed.
Actual native material appearance and resize/compositor performance remain
unverified. Enabled browser inventory was empty; native Windows capture/input
access was unavailable. These checks establish implementation and persistence,
not visual quality on release hardware. macOS/Linux native materials remain
future work.