diff --git a/docs/architecture.md b/docs/architecture.md index 972ec667..82e14e8c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -73,6 +73,7 @@ Tauri 在 `src-tauri/src/coordinator.rs` 构造 Core,`core_adapters.rs` 组装 | 服务凭据 | Core `CredentialStore` 合同,Tauri keyring/Android Keystore 或 Linux `credentials.rs` 适配 | | 云端 ASR / LLM | Core provider 目录、选择与传输模块;平台本地引擎位于 `src-tauri/src/asr/local/` 或 Linux Host | | 风格包市场 | Core `marketplace.rs` 管理 HTTP、GitHub device flow 与本地安装;地址由 `MarketplaceConfig` 注入,内置默认值在该模块 | +| 风格图标 | React `src/lib/stylePackIcon.ts` 清理上传的 SVG 并转成 PNG;`set_style_pack_icon` / `read_style_pack_icon` 经 Core `style_pack_store.rs` 保存资源、校验读取范围并返回图片 data URL。图标沿用 ZIP 的 64 KiB 限制,与风格包一起导出 | | 局域网手机输入 | Core `remote_input_service.rs` 定义共享业务,Tauri `remote_server/` 提供本机网络入口和网页资源 | 应用不会把普通听写交给风格包市场后端。市场安装完成后使用本地风格包;官网也不参与应用的业务调用。长期参考数据、训练准备和历史快照不是 Core 的在线训练服务。 diff --git a/openless-all/app/crates/openless-core/src/api.rs b/openless-all/app/crates/openless-core/src/api.rs index 67b0b41a..924a0947 100644 --- a/openless-all/app/crates/openless-core/src/api.rs +++ b/openless-all/app/crates/openless-core/src/api.rs @@ -3944,6 +3944,20 @@ impl OpenLessBackend { Ok(pack) } + pub fn set_style_pack_icon( + &self, + id: &str, + png: Option<&[u8]>, + ) -> Result { + let pack = self.style_packs.update_icon(id, png)?; + self.publish_style_packs_changed(); + Ok(pack) + } + + pub fn read_style_pack_icon(&self, id: &str) -> Result, BackendError> { + self.style_packs.icon_data_url(id) + } + pub fn set_style_pack_enabled( &self, id: &str, diff --git a/openless-all/app/crates/openless-core/src/style_pack_archive.rs b/openless-all/app/crates/openless-core/src/style_pack_archive.rs index d45a164f..201d0d49 100644 --- a/openless-all/app/crates/openless-core/src/style_pack_archive.rs +++ b/openless-all/app/crates/openless-core/src/style_pack_archive.rs @@ -614,7 +614,7 @@ pub(super) fn read_stream_bounded( Ok(bytes) } -fn validate_icon_content(extension: &str, bytes: &[u8]) -> Result<()> { +pub(super) fn validate_icon_content(extension: &str, bytes: &[u8]) -> Result<()> { let (width, height) = match extension { "png" => validate_png(bytes)?, "jpg" | "jpeg" => validate_jpeg(bytes)?, diff --git a/openless-all/app/crates/openless-core/src/style_pack_store.rs b/openless-all/app/crates/openless-core/src/style_pack_store.rs index b61b0d09..8852f1a6 100644 --- a/openless-all/app/crates/openless-core/src/style_pack_store.rs +++ b/openless-all/app/crates/openless-core/src/style_pack_store.rs @@ -1,7 +1,8 @@ //! Shared style-pack repository and lifecycle rules. +use base64::Engine; use std::fs; -use std::io::{Cursor, Write}; +use std::io::{Cursor, Read, Write}; use std::path::{Path, PathBuf}; use std::sync::Mutex; @@ -10,7 +11,8 @@ use crate::persistence::{atomic_write, persistence_error, read_or_default}; use crate::shared_types::UserPreferences; use crate::style_pack_archive::{ cleanup_style_pack_asset_dir, persist_style_pack_icon, read_style_pack_archive, - read_style_pack_archive_bytes, ParsedStylePackArchive, StylePackArchiveManifest, + read_style_pack_archive_bytes, validate_icon_content, ParsedStylePackArchive, + StylePackArchiveManifest, MAX_ICON_BYTES, }; use crate::style_packs::{ builtin_style_pack_for_mode, builtin_style_pack_id, builtin_style_packs, @@ -171,6 +173,128 @@ impl StylePackStore { Ok(updated) } + /// Store a rasterized user icon without accepting caller-supplied file paths. + pub fn update_icon(&self, id: &str, png: Option<&[u8]>) -> Result { + if id.is_empty() + || id == "." + || id == ".." + || !id + .bytes() + .all(|c| c.is_ascii_alphanumeric() || b"._-".contains(&c)) + { + return Err(invalid_icon("invalid style pack id")); + } + let mut packs = self.lock()?; + let index = packs + .iter() + .position(|pack| pack.id == id) + .ok_or_else(|| not_found(id))?; + let old_path = packs[index].icon_path.clone(); + let new_path = if let Some(bytes) = png { + if bytes.len() > MAX_ICON_BYTES { + return Err(invalid_icon("style pack icon exceeds 64 KiB")); + } + validate_icon_content("png", bytes).map_err(archive_error)?; + let root = self + .asset_root + .as_ref() + .filter(|root| !root.as_os_str().is_empty()) + .ok_or_else(|| invalid_icon("style pack asset root unavailable"))?; + fs::create_dir_all(root) + .map_err(|_| persistence_error("create style pack asset root"))?; + let root = root + .canonicalize() + .map_err(|_| persistence_error("resolve style pack asset root"))?; + let directory = root.join(id); + fs::create_dir_all(&directory) + .map_err(|_| persistence_error("create style pack icon directory"))?; + let directory = directory + .canonicalize() + .map_err(|_| persistence_error("resolve style pack icon directory"))?; + if !directory.starts_with(&root) { + return Err(invalid_icon( + "style pack icon directory is outside its asset root", + )); + } + // A new filename keeps the previous image valid until metadata commits. + let target = directory.join(format!("icon-{}.png", uuid::Uuid::new_v4().simple())); + atomic_write(&target, bytes)?; + Some(target) + } else { + None + }; + let mut next = packs.clone(); + next[index].icon_path = new_path + .as_ref() + .map(|path| path.to_string_lossy().into_owned()); + next[index].updated_at = Some(chrono::Utc::now().to_rfc3339()); + if let Err(error) = self.persist_locked(&next) { + if let Some(path) = new_path { + let _ = fs::remove_file(path); + } + return Err(error); + } + let saved = next[index].clone(); + *packs = next; + if let (Some(root), Some(old)) = (&self.asset_root, old_path) { + let old = Path::new(&old); + if let (Ok(owned), Some(parent)) = (root.join(id).canonicalize(), old.parent()) { + if parent.canonicalize().ok().as_ref() == Some(&owned) { + let _ = fs::remove_file(old); + } + } + } + Ok(saved) + } + + /// Return only bounded, validated image data from this repository's assets. + pub fn icon_data_url(&self, id: &str) -> Result, BackendError> { + let pack = self.get(id)?; + let Some(path) = pack.icon_path else { + return Ok(None); + }; + let root = self + .asset_root + .as_ref() + .ok_or_else(|| invalid_icon("style pack asset root unavailable"))?; + let root = root + .canonicalize() + .map_err(|_| persistence_error("resolve style pack asset root"))?; + let source = match Path::new(&path).canonicalize() { + Ok(path) => path, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(_) => return Err(persistence_error("resolve style pack icon")), + }; + if !source.starts_with(&root) || !source.is_file() { + return Err(invalid_icon("style pack icon is outside its asset root")); + } + let extension = source + .extension() + .and_then(|value| value.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + let mime = match extension.as_str() { + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "webp" => "image/webp", + _ => return Err(invalid_icon("unsupported style pack icon type")), + }; + let mut bytes = Vec::new(); + fs::File::open(&source) + .map_err(|_| persistence_error("open style pack icon"))? + .take((MAX_ICON_BYTES + 1) as u64) + .read_to_end(&mut bytes) + .map_err(|_| persistence_error("read style pack icon"))?; + if bytes.len() > MAX_ICON_BYTES { + return Err(invalid_icon("style pack icon exceeds 64 KiB")); + } + validate_icon_content(&extension, &bytes).map_err(archive_error)?; + Ok(Some(format!( + "data:{mime};base64,{}", + base64::engine::general_purpose::STANDARD.encode(bytes) + ))) + } + pub fn set_origin( &self, id: &str, @@ -724,6 +848,10 @@ fn required_text(value: &str, field: &str) -> Result { } } +fn invalid_icon(message: &str) -> BackendError { + BackendError::new(BackendErrorCode::InvalidArgument, message) +} + fn unique_imported_id(packs: &[StylePack], requested: &str) -> String { let mut base = requested .trim() diff --git a/openless-all/app/crates/openless-core/tests/style_icon_contract.rs b/openless-all/app/crates/openless-core/tests/style_icon_contract.rs new file mode 100644 index 00000000..400c0299 --- /dev/null +++ b/openless-all/app/crates/openless-core/tests/style_icon_contract.rs @@ -0,0 +1,100 @@ +use base64::Engine; +use openless_core::{builtin_style_pack_for_mode, PolishMode, StylePackStore}; +use std::path::PathBuf; + +struct Fixture(PathBuf); +impl Fixture { + fn new() -> Self { + let path = + std::env::temp_dir().join(format!("openless-style-icon-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&path).unwrap(); + Self(path) + } + fn store(&self) -> StylePackStore { + StylePackStore::at_data_dir(&self.0).unwrap() + } +} +impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} +fn png() -> Vec { + base64::engine::general_purpose::STANDARD.decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO3c3b0AAAAASUVORK5CYII=").unwrap() +} + +#[test] +fn uploaded_icon_survives_restart_export_and_clear() { + let fixture = Fixture::new(); + let store = fixture.store(); + let saved = store.update_icon("builtin.light", Some(&png())).unwrap(); + let path = PathBuf::from(saved.icon_path.unwrap()); + assert!(path.is_file()); + let expected = format!( + "data:image/png;base64,{}", + base64::engine::general_purpose::STANDARD.encode(png()) + ); + let reopened = fixture.store(); + assert_eq!( + reopened.icon_data_url("builtin.light").unwrap(), + Some(expected.clone()) + ); + let exported = reopened.export_zip_bytes("builtin.light").unwrap(); + let imported = reopened.import_from_zip_bytes(&exported).unwrap(); + assert_eq!( + reopened.icon_data_url(&imported.id).unwrap(), + Some(expected) + ); + reopened.update_icon("builtin.light", None).unwrap(); + assert!(!path.exists()); + assert_eq!( + fixture.store().icon_data_url("builtin.light").unwrap(), + None + ); + assert!(reopened.icon_data_url(&imported.id).unwrap().is_some()); +} + +#[test] +fn invalid_icon_preserves_the_previous_image() { + let fixture = Fixture::new(); + let store = fixture.store(); + let saved = store.update_icon("builtin.light", Some(&png())).unwrap(); + for invalid in [b"".to_vec(), vec![0; 65_537]] { + assert!(store.update_icon("builtin.light", Some(&invalid)).is_err()); + assert_eq!( + store.get("builtin.light").unwrap().icon_path, + saved.icon_path + ); + assert!(PathBuf::from(saved.icon_path.as_ref().unwrap()).is_file()); + } +} + +#[test] +fn icon_reader_cannot_read_paths_outside_the_asset_directory() { + let fixture = Fixture::new(); + let store = fixture.store(); + let outside = fixture.0.join("outside.png"); + std::fs::write(&outside, png()).unwrap(); + let mut template = builtin_style_pack_for_mode(PolishMode::Light); + template.icon_path = Some(outside.to_string_lossy().into_owned()); + let created = store.create(template).unwrap(); + assert!(store.icon_data_url(&created.id).is_err()); + store.update_icon(&created.id, None).unwrap(); + assert!(outside.is_file()); +} + +#[test] +fn failed_metadata_write_preserves_old_image_and_memory() { + let fixture = Fixture::new(); + let store = fixture.store(); + let saved = store.update_icon("builtin.light", Some(&png())).unwrap(); + let path = fixture.0.join("style-packs.json"); + std::fs::remove_file(&path).unwrap(); + std::fs::create_dir(&path).unwrap(); + assert!(store.update_icon("builtin.light", Some(&png())).is_err()); + assert_eq!( + store.get("builtin.light").unwrap().icon_path, + saved.icon_path + ); + assert!(PathBuf::from(saved.icon_path.unwrap()).is_file()); +} diff --git a/openless-all/app/scripts/less-computer-opencode-contract.test.mjs b/openless-all/app/scripts/less-computer-opencode-contract.test.mjs index 338ce741..7eae6c29 100644 --- a/openless-all/app/scripts/less-computer-opencode-contract.test.mjs +++ b/openless-all/app/scripts/less-computer-opencode-contract.test.mjs @@ -11,7 +11,9 @@ const agentSettings = await read('src/pages/settings/CodingAgentSection.tsx'); const settingsTabs = await read('src/pages/settings/tabs.tsx'); assert(!/if \(os === 'win'/.test(agentSettings), 'Windows must expose Less Computer configuration and its text entry point'); assert(shortcuts.includes("(os === 'mac' || os === 'win') && ("), 'Windows must expose the Less Computer voice shortcut'); -assert(settingsTabs.includes("(os === 'mac' || os === 'win') && "), 'the settings tab must mount Less Computer on Windows'); +// Platform visibility is covered by navigation.test.ts; the detail route must +// still mount the existing settings consumer. +assert(settingsTabs.includes("item.id === 'lessComputer' && "), 'the Less Computer subpage must mount its settings consumer'); const [settings, ipc, opencode, dictation, lib, onboarding, lessComputerIpc, qaCommands, credentialCommands, miscCommands, coreAdapters, coordinatorHost, coreApi] = await Promise.all([ read('src/pages/settings/CodingAgentSection.tsx'), diff --git a/openless-all/app/src-tauri/src/commands/style_packs.rs b/openless-all/app/src-tauri/src/commands/style_packs.rs index d5b82d1f..2b7ce30b 100644 --- a/openless-all/app/src-tauri/src/commands/style_packs.rs +++ b/openless-all/app/src-tauri/src/commands/style_packs.rs @@ -104,6 +104,22 @@ pub fn preview_style_pack_runtime( Ok(core.preview_style_pack_runtime(&style_pack)) } +#[tauri::command] +pub fn set_style_pack_icon( + core: CoreState<'_>, + id: String, + png: Option>, +) -> Result { + core.set_style_pack_icon(&id, png.as_deref()) + .map_err(|error| error.to_string()) +} + +#[tauri::command] +pub fn read_style_pack_icon(core: CoreState<'_>, id: String) -> Result, String> { + core.read_style_pack_icon(&id) + .map_err(|error| error.to_string()) +} + #[tauri::command] pub fn set_active_style_pack( coord: CoordinatorState<'_>, diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index e32c3e1a..c5bb0fa2 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -268,6 +268,8 @@ macro_rules! app_invoke_handler_desktop { commands::list_style_packs, commands::create_style_pack_from_template, commands::save_style_pack, + commands::set_style_pack_icon, + commands::read_style_pack_icon, commands::preview_style_pack_runtime, commands::set_active_style_pack, commands::set_style_pack_enabled, @@ -493,6 +495,8 @@ macro_rules! app_invoke_handler_mobile { $crate::commands::list_style_packs, $crate::commands::create_style_pack_from_template, $crate::commands::save_style_pack, + $crate::commands::set_style_pack_icon, + $crate::commands::read_style_pack_icon, $crate::commands::preview_style_pack_runtime, $crate::commands::set_active_style_pack, $crate::commands::set_style_pack_enabled, diff --git a/openless-all/app/src/components/Capsule.tsx b/openless-all/app/src/components/Capsule.tsx index af89d389..4919baf5 100644 --- a/openless-all/app/src/components/Capsule.tsx +++ b/openless-all/app/src/components/Capsule.tsx @@ -1,3 +1,4 @@ +import { Icon } from './Icon'; import { memo, useCallback, @@ -432,15 +433,7 @@ const CircleButton = memo(function CircleButton({ variant, enabled, onClick }: C transition: 'opacity 0.18s var(--ol-motion-soft), background 0.16s var(--ol-motion-quick), transform 0.12s var(--ol-motion-quick)', }} > - {isCancel ? ( - - - - ) : ( - - - - )} + ); }); diff --git a/openless-all/app/src/components/FloatingShell.tsx b/openless-all/app/src/components/FloatingShell.tsx index dab16a9b..f72b1dcc 100644 --- a/openless-all/app/src/components/FloatingShell.tsx +++ b/openless-all/app/src/components/FloatingShell.tsx @@ -345,16 +345,14 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia > {t(`nav.group.${node.key}`)} - - - + /> {/* 子项:grid-rows 0fr↔1fr 过渡实现无高度硬编码的展开动画。 */}
- + {displayTab === 'selectionAsk' ? ( + openSettings('shortcuts')} /> + ) : ( + + )}
)} diff --git a/openless-all/app/src/components/Icon.tsx b/openless-all/app/src/components/Icon.tsx index a5631c7d..15971173 100644 --- a/openless-all/app/src/components/Icon.tsx +++ b/openless-all/app/src/components/Icon.tsx @@ -1,61 +1,72 @@ -// Icon.tsx — minimal stroke icons (1.5 stroke). Matches the black/blue aesthetic. -// Usage: - +// Shared interface icons use one 24px grid and a consistent rounded stroke. import type { CSSProperties } from 'react'; +import { + Archive, ChartNoAxesColumn, Check, ChevronDown, ChevronLeft, ChevronRight, + CircleHelp, Clock3, Cloud, CodeXml, Command, Copy, CornerDownLeft, Download, + Ellipsis, ExternalLink, Eye, Feather, FileText, Hash, History, Info, Languages, + Link, ListFilter, Mail, Maximize2, MessageSquareText, Mic, Minimize2, Minus, + Monitor, Option, PanelLeft, PanelsTopLeft, Pencil, Play, Plus, RefreshCw, + Search, Settings2, ShieldCheck, SlidersHorizontal, Sparkles, Square, Tag, Trash2, Upload, + UserRound, X, Zap, BookOpenText, type LucideIcon, +} from 'lucide-react'; -export const ICONS: Record = { - overview: 'M3 3v18h18M18 17V9M13 17V5M8 17v-3', // Simplified bar chart - history: 'M12 8v4l3 3m6-3a9 9 0 1 1-18 0 9 9 0 0 1 18 0z', // Clock - vocab: 'M4 19.5A2.5 2.5 0 0 1 6.5 17H20M4 19.5A2.5 2.5 0 0 0 6.5 22H20V4H6.5A2.5 2.5 0 0 0 4 6.5v13Z', // Book - style: 'M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6', // Type / Font - translate: 'M12 2a10 10 0 1 0 0 20 10 10 0 1 0 0-20zM2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z', // Globe - selectionAsk: 'M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z', // Message square - settings: 'M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0z', // Settings gear - help: 'M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3M12 17h.01M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z', // Help circle - mic: 'M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3zM19 10v2a7 7 0 0 1-14 0v-2M12 19v4M8 23h8', // Mic - search: 'M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16zM21 21l-4.35-4.35', // Search - plus: 'M12 5v14M5 12h14', // Plus - check: 'M20 6L9 17l-5-5', // Check - x: 'M18 6L6 18M6 6l12 12', // X - copy: 'M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2M15 2H9a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1z', // Clipboard - eye: 'M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7zM12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z', // Eye - trash: 'M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M10 11v6M14 11v6', // Trash - refresh: 'M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8M3 3v5h5', // Refresh ccw - sparkle: 'M12 3L14.5 9.5 21 12 14.5 14.5 12 21 9.5 14.5 3 12 9.5 9.5 12 3Z', // Sparkle - bolt: 'M13 2L3 14h9l-1 8 10-12h-9l1-8z', // Zap - clock: 'M12 8v4l3 3M12 22a10 10 0 1 1 0-20 10 10 0 0 1 0 20z', // Clock standard - hash: 'M4 9h16M4 15h16M10 3L8 21M16 3l-2 18', // Hash - chevDown: 'M6 9l6 6 6-6', // Chevron down - chevRight: 'M9 18l6-6-6-6', // Chevron right - chevLeft: 'M15 18l-6-6 6-6', // Chevron left - chevLR: 'M8 5L5 12l3 7M16 5l3 7-3 7', // Code - collapse: 'M4 14h6v6M20 10h-6V4M14 10l7-7M3 21l7-7', // Shrink - expand: 'M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7', // Expand - layout: 'M3 3h18v18H3zM3 9h18M9 21V9', // Layout (Sidebar) - cmd: 'M18 3a3 3 0 0 0-3 3v12a3 3 0 0 0 3 3 3 3 0 0 0 3-3 3 3 0 0 0-3-3H6a3 3 0 0 0-3 3 3 3 0 0 0 3 3 3 3 0 0 0 3-3V6a3 3 0 0 0-3-3 3 3 0 0 0-3 3 3 3 0 0 0 3 3h12a3 3 0 0 0 3-3 3 3 0 0 0-3-3z', // Command - option: 'M3 3h6l6 18h6M14 3h7', // Option - esc: 'M10 6l-6 6 6 6M4 12h16', // Move left (escape substitute) - enter: 'M9 10L4 15l5 5M20 4v7a4 4 0 0 1-4 4H4', // Corner down left - inserted: 'M20 6L9 17l-5-5', // Check - cloud: 'M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9z', // Cloud - mac: 'M4 6a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6zm8 14v2m-4 0h8', // Monitor - win: 'M4 14.5V20l16-2v-3.5H4zm16-4V2l-16 2v4.5h16z', // Windows logo proxy (Squares) - doc: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8zM14 2v6h6', // File - link: 'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71', // Link - filter: 'M22 3H2l8 9.46V19l4 2v-8.54L22 3z', // Filter - archive: 'M21 8v13H3V8M1 3h22v5H1zM10 12h4', // Archive - tag: 'M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82zM7 7h.01', // Tag - user: 'M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z', // User - mail: 'M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2zM22 6l-10 7L2 6', // Mail - info: 'M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zM12 16v-4M12 8h.01', // Info - shield: 'M12 22s8-3 8-9V5l-8-3-8 3v8c0 6 8 9 8 9z', // Shield (privacy) - external: 'M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14L21 3', // External link - close: 'M18 6L6 18M6 6l12 12', // Close / X - more: 'M5 12h.01M12 12h.01M19 12h.01', // More (horizontal dots) - play: 'M5 3l14 9-14 9V3z', // Play - download: 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3', // Download - pencil: 'M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z', // Edit (pencil) - feather: 'M20.24 12.24a6 6 0 0 0-8.49-8.49L5 10.5V19h8.5zM16 8L2 22M17.5 15H9', // Feather (dictionary word) +export const ICONS: Record = { + overview: ChartNoAxesColumn, + history: History, + vocab: BookOpenText, + style: SlidersHorizontal, + translate: Languages, + selectionAsk: MessageSquareText, + settings: Settings2, + help: CircleHelp, + mic: Mic, + search: Search, + plus: Plus, + check: Check, + x: X, + copy: Copy, + eye: Eye, + trash: Trash2, + refresh: RefreshCw, + sparkle: Sparkles, + bolt: Zap, + clock: Clock3, + hash: Hash, + chevDown: ChevronDown, + chevRight: ChevronRight, + chevLeft: ChevronLeft, + chevLR: CodeXml, + collapse: Minimize2, + expand: Maximize2, + layout: PanelLeft, + cmd: Command, + option: Option, + esc: CornerDownLeft, + enter: CornerDownLeft, + inserted: Check, + cloud: Cloud, + mac: Monitor, + win: PanelsTopLeft, + doc: FileText, + link: Link, + filter: ListFilter, + archive: Archive, + tag: Tag, + user: UserRound, + mail: Mail, + info: Info, + shield: ShieldCheck, + external: ExternalLink, + close: X, + more: Ellipsis, + play: Play, + download: Download, + upload: Upload, + pencil: Pencil, + feather: Feather, + minimize: Minus, + maximize: Square, + restore: Copy, }; export interface IconProps { @@ -68,32 +79,8 @@ export interface IconProps { className?: string; } -export function Icon({ - name, - size = 16, - stroke = 'currentColor', - strokeWidth = 1.8, - fill = 'none', - style, - className, -}: IconProps) { - const d = ICONS[name]; - if (!d) return null; - return ( - - ); +export function Icon({ name, size = 16, stroke = 'currentColor', strokeWidth = 1.75, fill = 'none', style, className }: IconProps) { + const Glyph = ICONS[name]; + if (!Glyph) return null; + return