diff --git a/README.md b/README.md index 375f11d..4cff208 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index e422654..7ea0f22 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -3823,6 +3823,8 @@ dependencies = [ "tauri-plugin-opener", "tempfile", "uuid", + "window-vibrancy", + "windows-sys 0.61.2", ] [[package]] diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 3496321..f14edb0 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -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"] } diff --git a/apps/desktop/src-tauri/src/appearance.rs b/apps/desktop/src-tauri/src/appearance.rs new file mode 100644 index 0000000..4a3babe --- /dev/null +++ b/apps/desktop/src-tauri/src/appearance.rs @@ -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, +} + +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::() 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 + } +} diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 37bd4b7..44117f1 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -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>, ai: Arc, database: PathBuf, lexical: Arc>, @@ -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 { 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, +) -> Result { + { + 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 { + 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>, + material: settings::BackgroundMaterial, + opacity: u8, + high_contrast: bool, +) -> Result { + 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, diff --git a/apps/desktop/src-tauri/src/settings.rs b/apps/desktop/src-tauri/src/settings.rs index 172b51e..3b2b56e 100644 --- a/apps/desktop/src-tauri/src/settings.rs +++ b/apps/desktop/src-tauri/src/settings.rs @@ -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, 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::("\"other\"").is_err()); + } +} diff --git a/apps/desktop/src-tauri/tauri.windows.conf.json b/apps/desktop/src-tauri/tauri.windows.conf.json new file mode 100644 index 0000000..5afb37a --- /dev/null +++ b/apps/desktop/src-tauri/tauri.windows.conf.json @@ -0,0 +1 @@ +{"app":{"windows":[{"title":"searchi","width":1080,"height":760,"minWidth":680,"minHeight":540,"transparent":true}]}} diff --git a/apps/desktop/src/appearance-state.ts b/apps/desktop/src/appearance-state.ts new file mode 100644 index 0000000..db4d0d4 --- /dev/null +++ b/apps/desktop/src/appearance-state.ts @@ -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; + 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(); + } +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 74ff8cc..9db6679 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -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("#app")!.innerHTML = ` -
searchi
Loading your index…
${icon("filter")}All files${icon("chevron")}

Filter your search

${icon("settings")}

Folders & resources

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.

    Local timing diagnostics for new searches. Queries and file paths are excluded from copied reports.

    Optional AI answers

    Disabled by default. Only an explicit action sends the shown passage to your configured endpoint.

    Endpoint, model and key stay in this app session. The key authenticates requests to your endpoint.

    +
    searchi
    Loading your index…
    ${icon("filter")}All files${icon("chevron")}

    Filter your search

    ${icon("settings")}

    Settings

    Appearance

    90%

    A subtle window background. Search results and passages stay clear.

    Folders & resources

    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.

      Local timing diagnostics for new searches. Queries and file paths are excluded from copied reports.

      Optional AI answers

      Disabled by default. Only an explicit action sends the shown passage to your configured endpoint.

      Endpoint, model and key stay in this app session. The key authenticates requests to your endpoint.

      ${icon("sparkles")}Preparing local semantic search…Local model${icon("chevron")}
      Google EmbeddingGemma 2

      The verified local model downloads automatically on first launch (865 MB). Your files stay on this device.

      @@ -465,8 +470,88 @@ async function refreshSemanticStatus() { } } -async function refreshDesktopStatus() { +const highContrast = window.matchMedia("(forced-colors: active)"); +const appearanceUpdates = new AppearanceUpdates( + (update) => + invoke( + 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("background-material"); + const opacity = element("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("background-material") + .value as BackgroundMaterial, + opacity: Number(element("background-opacity").value), + }); + renderAppearance(); +} +element("background-material").addEventListener("change", () => { + void saveAppearance(); +}); +element("background-opacity").addEventListener("input", () => { + element("background-opacity-value").textContent = + `${element("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("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 [ diff --git a/apps/desktop/src/style.css b/apps/desktop/src/style.css index 60e69a1..5d26540 100644 --- a/apps/desktop/src/style.css +++ b/apps/desktop/src/style.css @@ -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); +} diff --git a/apps/desktop/tests/appearance-state.test.mjs b/apps/desktop/tests/appearance-state.test.mjs new file mode 100644 index 0000000..ca1a12d --- /dev/null +++ b/apps/desktop/tests/appearance-state.test.mjs @@ -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,[]); +}); diff --git a/docs/BRAND.md b/docs/BRAND.md index 0eb52cf..6647ceb 100644 --- a/docs/BRAND.md +++ b/docs/BRAND.md @@ -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. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index bf5278b..6220f7f 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 diff --git a/docs/WINDOW-MATERIALS.md b/docs/WINDOW-MATERIALS.md new file mode 100644 index 0000000..120366d --- /dev/null +++ b/docs/WINDOW-MATERIALS.md @@ -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.