diff --git a/OpenBitFun-Installer/AGENTS-CN.md b/OpenBitFun-Installer/AGENTS-CN.md index 77cc658076..09caffe14b 100644 --- a/OpenBitFun-Installer/AGENTS-CN.md +++ b/OpenBitFun-Installer/AGENTS-CN.md @@ -33,6 +33,7 @@ Language Select → Options → Progress → Model Setup → Theme Setup ```bash pnpm --dir OpenBitFun-Installer run installer:dev pnpm --dir OpenBitFun-Installer run tauri:dev +pnpm --dir OpenBitFun-Installer run tauri:preview # 原生界面预览,不执行安装 pnpm --dir OpenBitFun-Installer run type-check pnpm --dir OpenBitFun-Installer run build # React build / 复现 CI pnpm --dir OpenBitFun-Installer run installer:build # 仅打包场景 @@ -49,6 +50,12 @@ pnpm --dir OpenBitFun-Installer run type-check # 前 cargo check --manifest-path OpenBitFun-Installer/src-tauri/Cargo.toml # Tauri/Rust 改动 ``` +修改原生 `--preview` 命令边界时,运行聚焦策略测试: + +```bash +cargo test --manifest-path OpenBitFun-Installer/src-tauri/Cargo.toml --lib preview::tests +``` + 只有修改打包、payload、native bundling、安装/卸载流程、注册表、快捷方式或解压逻辑时,才运行完整安装器构建: ```bash diff --git a/OpenBitFun-Installer/AGENTS.md b/OpenBitFun-Installer/AGENTS.md index e74635f558..b3c58298fc 100644 --- a/OpenBitFun-Installer/AGENTS.md +++ b/OpenBitFun-Installer/AGENTS.md @@ -34,6 +34,7 @@ below for PR scope. ```bash pnpm --dir OpenBitFun-Installer run installer:dev pnpm --dir OpenBitFun-Installer run tauri:dev +pnpm --dir OpenBitFun-Installer run tauri:preview # native UI only; no installation pnpm --dir OpenBitFun-Installer run type-check pnpm --dir OpenBitFun-Installer run build # React build / CI reproduction pnpm --dir OpenBitFun-Installer run installer:build # packaging only @@ -50,6 +51,12 @@ pnpm --dir OpenBitFun-Installer run type-check # fron cargo check --manifest-path OpenBitFun-Installer/src-tauri/Cargo.toml # Tauri/Rust changes ``` +For the native `--preview` command boundary, use the focused policy tests: + +```bash +cargo test --manifest-path OpenBitFun-Installer/src-tauri/Cargo.toml --lib preview::tests +``` + For installer payload validation and the independent Data Migrator boundary, run: ```bash diff --git a/OpenBitFun-Installer/README.md b/OpenBitFun-Installer/README.md index bad46718ac..6000d02c88 100644 --- a/OpenBitFun-Installer/README.md +++ b/OpenBitFun-Installer/README.md @@ -83,9 +83,10 @@ OpenBitFun-Installer/ │ │ ├── ModelSetup.tsx # Optional model provider setup │ │ └── ThemeSetup.tsx # Theme preview + finish │ ├── components/ +│ │ ├── BrandMark.tsx # Canonical fine-line brand artwork +│ │ ├── StepIndicator.tsx # Read-only setup stages │ │ ├── WindowControls.tsx # Custom titlebar -│ │ ├── Checkbox.tsx # Styled checkbox -│ │ └── ProgressBar.tsx # Animated progress bar +│ │ └── ProgressBar.tsx # Accessible installation progress │ ├── hooks/ │ │ └── useInstaller.ts # Core installer state machine │ ├── styles/ @@ -144,6 +145,20 @@ Run the installer in development mode with hot reload: pnpm run tauri:dev ``` +### Native UI preview + +Run `pnpm run tauri:preview` to open the actual installer window with `--preview`. +The titlebar page selector opens each existing UI page without running an +installation. Theme and form changes stay in memory; Finish only closes the +preview. No simulated progress or installation result is generated. + +The native invoke boundary permits only launch context, the default path, +existing-installation detection, disk-space reads, and closing the window. All +other installer commands are rejected, including path validation (which writes +a probe file), installation, uninstallation, app launches, configuration writes, +and model requests. Launch context also skips configuration-directory creation. +This restriction is enforced by the native process even if the UI is modified. + ### Uninstall Mode (Dev + Runtime) Key behavior: @@ -220,6 +235,25 @@ Shared light/dark values come from `@openbitfun/theme-openbitfun`. Installer-onl [installerThemesData.ts](src/theme/installerThemesData.ts), and components consume only canonical `--openbitfun-*` variables projected by [installerThemeRuntime.ts](src/theme/installerThemeRuntime.ts). +Buttons, inputs, fields, selectors, checkboxes, radio controls, page headers, and +native scrollbar styling come from the public `@openbitfun/ui` package. The +installer owns page layout and the install state binding. The `dev`, `build`, and +`type-check` entrypoints prepare the public design-system packages first, so a +clean checkout does not depend on pre-existing `dist` files. + +The titlebar uses the application icon. Welcome and progress surfaces use the +canonical fine-line SVG from `assets/brand/source`. CSS masks follow the active +theme's foreground, including explicit theme choices that differ from the OS +setting. Theme choices use public radio controls with names and small color +palettes, without logos or framed thumbnails. The full installer window updates +on selection and serves as the live theme preview. + +Page hierarchy uses spacing and typography. Existing-installation detection is +a short disclosure; version, location, and the uninstaller action stay inside +its details. Model endpoint and protocol fields are grouped in an advanced +disclosure, initially open for custom providers. Motion respects the system's +reduced-motion preference. + ### Adding Install Steps 1. Add a new step key to `InstallStep` in [installer.ts](src/types/installer.ts) diff --git a/OpenBitFun-Installer/package.json b/OpenBitFun-Installer/package.json index 0e7794d7fb..c11f11c7d8 100644 --- a/OpenBitFun-Installer/package.json +++ b/OpenBitFun-Installer/package.json @@ -5,6 +5,10 @@ "type": "module", "description": "OpenBitFun Custom Installer - Modern branded installation experience", "scripts": { + "prepare:design-system": "pnpm --dir ../design-system run build:packages", + "predev": "pnpm run prepare:design-system", + "prebuild": "pnpm run prepare:design-system", + "pretype-check": "pnpm run prepare:design-system", "sync:model-i18n": "node scripts/sync-model-i18n.cjs", "sync:theme-i18n": "node scripts/sync-theme-i18n.cjs", "sync:i18n": "pnpm run sync:model-i18n && pnpm run sync:theme-i18n", @@ -12,6 +16,7 @@ "build": "pnpm run sync:i18n && tsc && vite build", "preview": "vite preview", "tauri:dev": "pnpm run sync:i18n && tauri dev", + "tauri:preview": "tauri dev -- -- --preview", "tauri:build": "pnpm run sync:i18n && tauri build", "tauri:build:fast": "pnpm run sync:i18n && tauri build -- --profile release-fast", "tauri:build:exe": "pnpm run sync:i18n && tauri build --no-bundle", @@ -25,6 +30,7 @@ }, "dependencies": { "@openbitfun/theme-openbitfun": "workspace:^", + "@openbitfun/ui": "workspace:^", "@tauri-apps/api": "2.11.1", "@tauri-apps/plugin-dialog": "2.7.3", "i18next": "^25.8.0", diff --git a/OpenBitFun-Installer/src-tauri/src/installer/commands.rs b/OpenBitFun-Installer/src-tauri/src/installer/commands.rs index 445876b4e3..4241ef0fea 100644 --- a/OpenBitFun-Installer/src-tauri/src/installer/commands.rs +++ b/OpenBitFun-Installer/src-tauri/src/installer/commands.rs @@ -90,6 +90,7 @@ struct PayloadManifestFile { #[serde(rename_all = "camelCase")] pub(crate) struct LaunchContext { pub mode: String, + pub preview_only: bool, pub uninstall_path: Option, pub app_language: Option, } @@ -427,6 +428,15 @@ unsafe fn windows_sys_get_disk_free_space( #[tauri::command] pub(crate) fn get_launch_context() -> LaunchContext { + // Resolve preview before helpers that create the application's config directory. + if crate::preview::is_enabled() { + return LaunchContext { + mode: "install".to_string(), + preview_only: true, + uninstall_path: None, + app_language: None, + }; + } let args: Vec = std::env::args().collect(); let app_language = read_saved_app_language(); if let Some(idx) = args.iter().position(|arg| arg == "--uninstall") { @@ -436,6 +446,7 @@ pub(crate) fn get_launch_context() -> LaunchContext { .or_else(guess_uninstall_path_from_exe); return LaunchContext { mode: "uninstall".to_string(), + preview_only: false, uninstall_path, app_language, }; @@ -444,6 +455,7 @@ pub(crate) fn get_launch_context() -> LaunchContext { if is_running_as_uninstall_binary() { return LaunchContext { mode: "uninstall".to_string(), + preview_only: false, uninstall_path: guess_uninstall_path_from_exe(), app_language, }; @@ -451,6 +463,7 @@ pub(crate) fn get_launch_context() -> LaunchContext { LaunchContext { mode: "install".to_string(), + preview_only: false, uninstall_path: None, app_language, } diff --git a/OpenBitFun-Installer/src-tauri/src/lib.rs b/OpenBitFun-Installer/src-tauri/src/lib.rs index bf14be84ed..c85627d10d 100644 --- a/OpenBitFun-Installer/src-tauri/src/lib.rs +++ b/OpenBitFun-Installer/src-tauri/src/lib.rs @@ -1,29 +1,40 @@ mod installer; +mod preview; use installer::commands; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + let preview_only = preview::is_enabled(); + let handler: fn(tauri::ipc::Invoke) -> bool = tauri::generate_handler![ + commands::get_launch_context, + commands::get_default_install_path, + commands::get_initial_install_path, + commands::get_existing_installation, + commands::launch_registered_uninstaller, + commands::get_disk_space, + commands::validate_install_path, + commands::start_installation, + commands::set_model_config, + commands::test_model_config_connection, + commands::list_model_config_models, + commands::set_theme_preference, + commands::uninstall, + commands::launch_application, + commands::launch_legacy_data_migrator, + commands::close_installer, + ]; tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) - .invoke_handler(tauri::generate_handler![ - commands::get_launch_context, - commands::get_default_install_path, - commands::get_initial_install_path, - commands::get_existing_installation, - commands::launch_registered_uninstaller, - commands::get_disk_space, - commands::validate_install_path, - commands::start_installation, - commands::set_model_config, - commands::test_model_config_connection, - commands::list_model_config_models, - commands::set_theme_preference, - commands::uninstall, - commands::launch_application, - commands::launch_legacy_data_migrator, - commands::close_installer, - ]) + .invoke_handler(move |invoke| { + if preview_only && !preview::allows_command(invoke.message.command()) { + invoke + .resolver + .reject("Installer UI preview does not execute this operation."); + return true; + } + handler(invoke) + }) .run(tauri::generate_context!()) .expect("error while running OpenBitFun Installer"); } diff --git a/OpenBitFun-Installer/src-tauri/src/preview.rs b/OpenBitFun-Installer/src-tauri/src/preview.rs new file mode 100644 index 0000000000..e8706bd0ca --- /dev/null +++ b/OpenBitFun-Installer/src-tauri/src/preview.rs @@ -0,0 +1,54 @@ +//! Native UI preview: only audited, read-only installer commands can run. + +pub(crate) fn is_enabled() -> bool { + std::env::args_os().any(|argument| argument == "--preview") +} + +pub(crate) fn allows_command(command: &str) -> bool { + matches!( + command, + "get_launch_context" + | "get_default_install_path" + | "get_existing_installation" + | "get_disk_space" + | "close_installer" + ) +} + +#[cfg(test)] +mod tests { + use super::allows_command; + + #[test] + fn preview_rejects_writes_process_launches_network_and_unknown_commands() { + for command in [ + "get_initial_install_path", + "validate_install_path", + "start_installation", + "uninstall", + "launch_registered_uninstaller", + "launch_application", + "launch_legacy_data_migrator", + "set_model_config", + "set_theme_preference", + "test_model_config_connection", + "list_model_config_models", + "future_command", + ] { + assert!(!allows_command(command), "preview allowed {command}"); + } + } + + #[test] + fn preview_allows_read_only_data_and_window_close() { + for command in [ + "get_launch_context", + "get_default_install_path", + "get_existing_installation", + "get_disk_space", + "close_installer", + ] { + assert!(allows_command(command), "preview rejected {command}"); + } + } +} diff --git a/OpenBitFun-Installer/src/App.tsx b/OpenBitFun-Installer/src/App.tsx index 3b71bb5462..0323cc6c72 100644 --- a/OpenBitFun-Installer/src/App.tsx +++ b/OpenBitFun-Installer/src/App.tsx @@ -1,5 +1,9 @@ +import { useLayoutEffect, useRef } from 'react'; +import { DesignSystemProvider, Select } from '@openbitfun/ui'; import { useTranslation } from 'react-i18next'; import { WindowControls } from './components/WindowControls'; +import { StepIndicator } from './components/StepIndicator'; +import appIcon from './assets/openbitfun-app-icon-16.png'; import { LanguageSelect } from './pages/LanguageSelect'; import { Options } from './pages/Options'; import { ModelSetup } from './pages/ModelSetup'; @@ -7,21 +11,31 @@ import { ProgressPage } from './pages/Progress'; import { ThemeSetup } from './pages/ThemeSetup'; import { UninstallPage } from './pages/Uninstall'; import { useInstaller } from './hooks/useInstaller'; -import { mapUiLanguageToAppLanguage, type InstallerUiLanguage } from './i18n/languages'; +import type { InstallStep } from './types/installer'; +import { + DEFAULT_INSTALLER_UI_LANGUAGE, + mapUiLanguageToAppLanguage, + resolveInstallerUiLanguage, + type InstallerUiLanguage, +} from './i18n/languages'; import { useSyncInstallerRootTheme } from './theme/installerThemeRuntime'; import './styles/global.css'; -const STEP_NUMBERS: Record = { - options: 2, - progress: 2, - model: 3, - theme: 4, -}; - function App() { const installer = useInstaller(); useSyncInstallerRootTheme(installer.options.themePreference); const { t, i18n } = useTranslation(); + const pageRef = useRef(null); + + useLayoutEffect(() => { + const language = resolveInstallerUiLanguage(i18n.resolvedLanguage ?? i18n.language) + ?? DEFAULT_INSTALLER_UI_LANGUAGE; + document.documentElement.lang = mapUiLanguageToAppLanguage(language); + }, [i18n.language]); + + useLayoutEffect(() => { + pageRef.current?.focus({ preventScroll: true }); + }, [installer.step]); const handleLanguageSelect = (lang: InstallerUiLanguage) => { i18n.changeLanguage(lang); @@ -32,14 +46,6 @@ function App() { installer.next(); }; - const STEP_TITLES: Record = { - options: t('options.title'), - model: t('model.title'), - progress: t('progress.title'), - theme: t('themeSetup.title'), - uninstall: t('uninstall.title'), - }; - const renderPage = () => { switch (installer.step) { case 'lang': @@ -58,6 +64,7 @@ function App() { onInstall={installer.install} isInstalling={installer.isInstalling} clearInstallError={installer.clearInstallError} + previewOnly={installer.previewOnly} /> ); case 'model': @@ -67,8 +74,9 @@ function App() { setOptions={installer.setOptions} onSkip={installer.next} onTestConnection={installer.testModelConnection} + previewOnly={installer.previewOnly} onNext={async () => { - await installer.saveModelConfig(); + if (!installer.previewOnly) await installer.saveModelConfig(); installer.next(); }} /> @@ -91,6 +99,7 @@ function App() { setOptions={installer.setOptions} onLaunch={installer.launchApp} onClose={installer.closeInstaller} + previewOnly={installer.previewOnly} /> ); case 'uninstall': @@ -103,6 +112,7 @@ function App() { uninstallProgress={installer.uninstallProgress} onUninstall={installer.startUninstall} onClose={installer.closeInstaller} + previewOnly={installer.previewOnly} /> ); default: @@ -110,52 +120,48 @@ function App() { } }; - const isFullscreen = installer.step === 'lang' || installer.step === 'uninstall'; - const stepNum = STEP_NUMBERS[installer.step]; - const defaultTitle = t('shared.product.name'); - const title = STEP_TITLES[installer.step] || defaultTitle; - const useSuccessStepColor = installer.installationCompleted; - return ( -
-
-
- - {isFullscreen ? defaultTitle : ( - <> - {stepNum} / 4 - · - {title} - - )} - + +
+
+
+ + {t('shared.product.name')} +
+ {installer.previewOnly && ( +
+ {t('preview.badge')} + +
+
+ +
+ + { - setApiKey(e.target.value); + autoComplete="off" + spellCheck={false} + onChange={(event) => { + setApiKey(event.target.value); resetTestState(); resetRemoteDiscovery(); }} /> - -
- - - -
- {baseUrlOptions.length > 0 ? ( - o.url === effectiveBaseUrl) ? effectiveBaseUrl : ''} + + + {baseUrlOptions.length > 0 && ( + + { - setBaseUrl(e.target.value); - resetTestState(); - resetRemoteDiscovery(); - // Only a preset URL implies its format. A hand-typed proxy URL - // keeps whatever the user picked, instead of snapping back to - // the template default and mismatching the endpoint. - const preset = template?.baseUrlOptions?.find((o) => o.url === e.target.value.trim()); - if (preset && !isCustomProvider) { - setApiFormat(preset.format); - } - }} - /> -
-
- - {!!effectiveBaseUrl && ( - - - - )} - - - { - const v = next as RequestFormatValue; - if (isCustomProvider) setCustomFormat(v); - else setApiFormat(v); - resetTestState(); - resetRemoteDiscovery(); - }} - /> - - - - {template ? ( -
- + )} + + {template && ( + + { - setModelName(e.target.value); + + )} + + {(!template || forceCustomModelInput || (modelName.trim() && !mergedModelIds.includes(modelName.trim()))) && ( + + { + setModelName(event.target.value); + resetTestState(); + }} + /> + + )} + {modelFetchHint &&

{modelFetchHint}

} + +
+ + { + setBaseUrl(event.target.value); + resetTestState(); + resetRemoteDiscovery(); + // Only preset URLs imply a format; custom proxy URLs keep the user's selection. + const preset = template?.baseUrlOptions?.find((option) => option.url === event.target.value.trim()); + if (preset && !isCustomProvider) setApiFormat(preset.format); + }} + /> + + + + { - setModelName(e.target.value); - resetTestState(); - }} - /> - )} - - - {modelFetchHint &&
{modelFetchHint}
} -
- )} +
+ + )} +
{!!selectedProviderId && ( -
- - {testStatus === 'success' && {testMessage}} - {testStatus === 'error' && {testMessage}} + + {testStatus === 'success' && {testMessage}} + {testStatus === 'error' && {testMessage}}
)}
-
- - + +
); diff --git a/OpenBitFun-Installer/src/pages/Options.tsx b/OpenBitFun-Installer/src/pages/Options.tsx index 804769189e..d7e182ae34 100644 --- a/OpenBitFun-Installer/src/pages/Options.tsx +++ b/OpenBitFun-Installer/src/pages/Options.tsx @@ -1,13 +1,9 @@ -import { - ChevronLeft as LucideChevronLeft, - ChevronRight as LucideChevronRight, - Folder as LucideFolder, -} from 'lucide-react'; +import { Button, Checkbox, Disclosure, Field, Input, PageHeader } from '@openbitfun/ui'; +import { ArrowRight } from 'lucide-react'; import { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { invoke } from '@tauri-apps/api/core'; import { open } from '@tauri-apps/plugin-dialog'; -import { Checkbox } from '../components/Checkbox'; import { InstallErrorPanel } from '../components/InstallErrorPanel'; import type { InstallOptions, @@ -17,6 +13,7 @@ import type { } from '../types/installer'; interface OptionsProps { + previewOnly?: boolean; options: InstallOptions; setOptions: React.Dispatch>; diskSpace: DiskSpaceInfo | null; @@ -42,6 +39,7 @@ export function Options({ onInstall, isInstalling, clearInstallError, + previewOnly = false, }: OptionsProps) { const { t } = useTranslation(); @@ -76,158 +74,99 @@ export function Options({ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; }; - const update = (key: keyof InstallOptions, value: boolean) => { - setOptions((prev) => ({ ...prev, [key]: value })); - }; - return (
-
-
- {t('options.subtitle')} -
- {existingInstall?.detected ? ( -
+ + + {existingInstall?.detected && ( + -
{t('options.existingInstallTitle')}
- {existingInstall.displayVersion ? ( -
- {t('options.existingInstallVersion', { version: existingInstall.displayVersion })} -
- ) : null} - {existingInstall.installLocation ? ( -
- {t('options.existingInstallLocation', { path: existingInstall.installLocation })} -
- ) : null} - {!existingInstall.mainBinaryPresent ? ( -
- {t('options.existingInstallBinaryMissing')} -
- ) : null} -

{t('options.existingInstallHint')}

-
- {existingInstall.uninstallString ? ( - - ) : null} + + )}
-
- ) : null} -
-
-
-
- + )} + +
+ {t('options.browse')} + } + > + { - setOptions((prev) => ({ ...prev, installPath: e.target.value })); + invalid={!!error} + aria-describedby={diskSpace ? 'install-disk-space' : undefined} + onChange={(event) => { + setOptions((prev) => ({ ...prev, installPath: event.target.value })); clearInstallError(); }} placeholder={t('options.pathPlaceholder')} + spellCheck={false} /> - -
+ {diskSpace && ( -
+
{t('options.required')}: {formatBytes(diskSpace.required)} - {t('options.available')}:{' '} - {diskSpace.available < Number.MAX_SAFE_INTEGER ? formatBytes(diskSpace.available) : '-'} + {t('options.available')}: {diskSpace.available < Number.MAX_SAFE_INTEGER ? formatBytes(diskSpace.available) : '—'} - {!diskSpace.sufficient && ( - {t('options.insufficientSpace')} - )} + {!diskSpace.sufficient && {t('options.insufficientSpace')}}
)} {error && } -
+ -
-
{t('options.optionsLabel')}
-
- update('desktopShortcut', value)} - label={t('options.desktopShortcut')} - /> - update('startMenu', value)} - label={t('options.startMenu')} - /> -
-
+
+ {t('options.optionsLabel')} + setOptions((prev) => ({ ...prev, desktopShortcut: checked }))} + label={t('options.desktopShortcut')} + /> + setOptions((prev) => ({ ...prev, startMenu: checked }))} + label={t('options.startMenu')} + /> +
- - +
); diff --git a/OpenBitFun-Installer/src/pages/Progress.tsx b/OpenBitFun-Installer/src/pages/Progress.tsx index 1fbe5de4d2..4feafe3695 100644 --- a/OpenBitFun-Installer/src/pages/Progress.tsx +++ b/OpenBitFun-Installer/src/pages/Progress.tsx @@ -1,5 +1,7 @@ -import { CircleX as LucideCircleX } from 'lucide-react'; +import { Button, PageHeader } from '@openbitfun/ui'; +import { ArrowRight, CircleCheck, CircleX } from 'lucide-react'; import { useTranslation } from 'react-i18next'; +import { BrandMark } from '../components/BrandMark'; import { ProgressBar } from '../components/ProgressBar'; import { InstallErrorPanel } from '../components/InstallErrorPanel'; import type { InstallProgress } from '../types/installer'; @@ -22,9 +24,9 @@ export function ProgressPage({ onBackToOptions, }: ProgressProps) { const { t } = useTranslation(); - const isCompleted = canConfirmProgress || progress.percent >= 100; - - const STEP_LABELS: Record = { + const isCompleted = canConfirmProgress && !error; + const percent = Number.isFinite(progress.percent) ? Math.min(100, Math.max(0, progress.percent)) : 0; + const stepLabels: Record = { prepare: t('progress.prepare'), extract: t('progress.extract'), registry: t('progress.registry'), @@ -33,90 +35,53 @@ export function ProgressPage({ config: t('progress.config'), complete: t('progress.complete'), }; - - const stepLabel = STEP_LABELS[progress.step] || progress.step || t('progress.starting'); + const stepLabel = isCompleted + ? t('progress.completed') + : stepLabels[progress.step] || progress.step || t('progress.starting'); return (
-
- {!error ? ( - <> -

- {t('progress.title')} -

-

- {stepLabel} -

-
- -
- {stepLabel} - {progress.percent}% -
-
- +
+
+ + {isCompleted &&
+
+ +
+ {error ? ( + ) : ( - <> -
- {!error ? ( - canConfirmProgress && ( -
- -
- ) - ) : ( -
- - -
- )} +
+ {error ? ( + <> + + + + ) : canConfirmProgress ? ( + + ) : null} +
); } diff --git a/OpenBitFun-Installer/src/pages/ThemeSetup.tsx b/OpenBitFun-Installer/src/pages/ThemeSetup.tsx index 3750c816b6..5ffcac51d6 100644 --- a/OpenBitFun-Installer/src/pages/ThemeSetup.tsx +++ b/OpenBitFun-Installer/src/pages/ThemeSetup.tsx @@ -1,53 +1,36 @@ -import { Check as LucideCheck } from 'lucide-react'; +import { Button, Checkbox, PageHeader, Radio } from '@openbitfun/ui'; +import { ArrowUpRight, Check } from 'lucide-react'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { invoke } from '@tauri-apps/api/core'; -import { Checkbox } from '../components/Checkbox'; -import type { InstallOptions, ThemeId, ThemePreferenceId } from '../types/installer'; +import type { InstallOptions, ThemePreferenceId } from '../types/installer'; import { SYSTEM_THEME_ID } from '../types/installer'; -import { THEMES, THEME_DISPLAY_ORDER, findInstallerThemeById } from '../theme/installerThemesData'; +import { THEMES, THEME_DISPLAY_ORDER } from '../theme/installerThemesData'; interface ThemeSetupProps { + previewOnly?: boolean; options: InstallOptions; setOptions: React.Dispatch>; onLaunch: () => Promise; onClose: () => void; } -export function ThemeSetup({ options, setOptions, onLaunch, onClose }: ThemeSetupProps) { +export function ThemeSetup({ options, setOptions, onLaunch, onClose, previewOnly = false }: ThemeSetupProps) { const { t } = useTranslation(); const [isFinishing, setIsFinishing] = useState(false); const [finishError, setFinishError] = useState(null); const orderedThemes = [...THEMES].sort((a, b) => THEME_DISPLAY_ORDER.indexOf(a.id) - THEME_DISPLAY_ORDER.indexOf(b.id)); - const lightPreview = findInstallerThemeById('openbitfun-light'); - const darkPreview = findInstallerThemeById('openbitfun-dark'); const selectTheme = (theme: ThemePreferenceId) => { setOptions((prev) => ({ ...prev, themePreference: theme })); }; - const cardStyle = (active: boolean) => ({ - width: '100%', - borderRadius: 12, - padding: 8, - background: active - ? 'color-mix(in srgb, var(--openbitfun-color-accent-default) 8%, transparent)' - : 'var(--openbitfun-color-surface-subtle)', - border: 'none', - cursor: 'pointer', - transition: 'background 0.2s ease', - textAlign: 'left' as const, - }); - - const previewBaseStyle = { - height: 72, - borderRadius: 8, - overflow: 'hidden' as const, - marginBottom: 8, - }; - const handleFinish = async () => { if (isFinishing) return; + if (previewOnly) { + onClose(); + return; + } setIsFinishing(true); setFinishError(null); @@ -72,88 +55,63 @@ export function ThemeSetup({ options, setOptions, onLaunch, onClose }: ThemeSetu return (
-
-

- {t('themeSetup.subtitle')} -

- -
- +
+ - {orderedThemes.map((theme) => ( - - ))} -
- -
- setOptions((prev) => ({ ...prev, launchAfterInstall: checked }))} - label={t('options.launchAfterInstall')} +
+ { if (checked) selectTheme(SYSTEM_THEME_ID); }} /> -
- - {finishError && ( -
- {finishError} +
+ {orderedThemes.map((theme) => ( + { if (checked) selectTheme(theme.id); }} + label={ + + {t(`themeSetup.themeNames.${theme.id}`, { defaultValue: theme.name })} +
- )} + + + {finishError &&

{finishError}

}
-
- +
); diff --git a/OpenBitFun-Installer/src/pages/Uninstall.tsx b/OpenBitFun-Installer/src/pages/Uninstall.tsx index a3d42cc3dd..ee46347ba8 100644 --- a/OpenBitFun-Installer/src/pages/Uninstall.tsx +++ b/OpenBitFun-Installer/src/pages/Uninstall.tsx @@ -1,7 +1,10 @@ +import { Button, PageHeader } from '@openbitfun/ui'; import { useTranslation } from 'react-i18next'; +import { BrandMark } from '../components/BrandMark'; import { ProgressBar } from '../components/ProgressBar'; interface UninstallPageProps { + previewOnly?: boolean; installPath: string; isUninstalling: boolean; uninstallCompleted: boolean; @@ -19,59 +22,40 @@ export function UninstallPage({ uninstallProgress, onUninstall, onClose, + previewOnly = false, }: UninstallPageProps) { const { t } = useTranslation(); return ( -
+
-
-
-
- {t('uninstall.title')} -
-
- {t('uninstall.subtitle')} -
- -
- {t('uninstall.installPath')}: - {installPath || t('uninstall.pathUnknown')} -
- - {uninstallError && ( -
- {uninstallError} -
- )} - - {uninstallCompleted && ( -
- {t('uninstall.completed')} -
- )} - - {(isUninstalling || uninstallCompleted) && ( -
- - {uninstallProgress}% -
- )} +
+ + +
+ {t('uninstall.installPath')} + {installPath || t('uninstall.pathUnknown')}
+ {uninstallError &&
{uninstallError}
} + {uninstallCompleted &&

{t('uninstall.completed')}

} + {(isUninstalling || uninstallCompleted) && ( +
+ + {uninstallProgress}% +
+ )}
-
- - {!uninstallCompleted && ( - - )} -
+ + {!uninstallCompleted && ( + + )}
); diff --git a/OpenBitFun-Installer/src/styles/animations.css b/OpenBitFun-Installer/src/styles/animations.css index 088f690433..8f3ce02761 100644 --- a/OpenBitFun-Installer/src/styles/animations.css +++ b/OpenBitFun-Installer/src/styles/animations.css @@ -1,38 +1,17 @@ -@keyframes fadeIn { - from { opacity: 0; transform: translateY(12px); } - to { opacity: 1; transform: translateY(0); } +@keyframes installer-enter { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } } -@keyframes heroContentFadeIn { - from { opacity: 0; transform: translateY(30px); } - to { opacity: 1; transform: translateY(0); } +@keyframes installer-breathe { + 0%, 100% { opacity: 0.65; } + 50% { opacity: 1; } } -@keyframes scaleIn { - from { opacity: 0; transform: scale(0.92); } - to { opacity: 1; transform: scale(1); } +@media (prefers-reduced-motion: reduce) { + .installer-app *, .installer-app *::before, .installer-app *::after { + animation: none !important; + transition: none !important; + scroll-behavior: auto !important; + } } - -@keyframes spin { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } -} - -@keyframes shimmer { - 0% { background-position: -200% 0; } - 100% { background-position: 200% 0; } -} - -@keyframes successBounce { - 0% { transform: scale(0); opacity: 0; } - 50% { transform: scale(1.1); } - 70% { transform: scale(0.96); } - 100% { transform: scale(1); opacity: 1; } -} - -.stagger-children > * { opacity: 0; animation: fadeIn 0.4s ease forwards; } -.stagger-children > *:nth-child(1) { animation-delay: 0ms; } -.stagger-children > *:nth-child(2) { animation-delay: 60ms; } -.stagger-children > *:nth-child(3) { animation-delay: 120ms; } -.stagger-children > *:nth-child(4) { animation-delay: 180ms; } -.stagger-children > *:nth-child(5) { animation-delay: 240ms; } diff --git a/OpenBitFun-Installer/src/styles/global.css b/OpenBitFun-Installer/src/styles/global.css index 52257f6c21..986fac45d5 100644 --- a/OpenBitFun-Installer/src/styles/global.css +++ b/OpenBitFun-Installer/src/styles/global.css @@ -1,763 +1,398 @@ @import './animations.css'; -*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; } +@layer openbitfun.reset { + *, *::before, *::after { box-sizing: border-box; } + body, h1, h2, p, ol, fieldset { margin: 0; } + fieldset { min-inline-size: 0; padding: 0; border: 0; } +} html, body, #root { - width: 100%; height: 100%; overflow: hidden; - font-family: var(--openbitfun-type-body-md-font-family); - font-size: var(--openbitfun-type-body-md-font-size); line-height: var(--openbitfun-type-body-md-line-height); - color: var(--openbitfun-color-content-primary); + width: 100%; + height: 100%; + overflow: hidden; background: var(--openbitfun-color-surface-canvas); + color: var(--openbitfun-color-content-primary); + font-family: var(--openbitfun-type-body-md-font-family); + font-size: var(--openbitfun-type-body-md-font-size); + line-height: var(--openbitfun-type-body-md-line-height); -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - user-select: none; text-rendering: optimizeLegibility; } .installer-app { - width: 100vw; height: 100vh; - background: var(--openbitfun-color-surface-canvas); - display: flex; flex-direction: column; + height: 100%; + display: flex; + flex-direction: column; overflow: hidden; } .titlebar { - height: 36px; - display: flex; align-items: center; justify-content: space-between; - padding: 0 16px; - background: var(--openbitfun-color-surface-canvas); - border-bottom: 1px solid var(--openbitfun-color-border-subtle); + height: 40px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: space-between; + padding-inline: var(--openbitfun-space-4) var(--openbitfun-space-2); -webkit-app-region: drag; - flex-shrink: 0; position: relative; z-index: 10; + user-select: none; } -.titlebar::before { - content: ''; - position: absolute; top: 0; left: 0; right: 0; height: 1px; - background: linear-gradient(90deg, - transparent 0%, var(--openbitfun-color-border-default) 25%, - color-mix(in srgb, var(--openbitfun-color-border-default) 70%, var(--openbitfun-color-content-primary)) 50%, - var(--openbitfun-color-border-default) 75%, - transparent 100%); - pointer-events: none; z-index: 1; +.titlebar-brand, .window-controls { + display: flex; + align-items: center; + gap: var(--openbitfun-space-2); } +.titlebar-brand img { flex-shrink: 0; } .titlebar-title { - font-size: var(--openbitfun-type-body-xs-font-size); font-weight: var(--openbitfun-type-label-lg-font-weight); - color: var(--openbitfun-color-content-muted); opacity: 0.6; - letter-spacing: var(--openbitfun-type-modifier-tracking-wide-letter-spacing); + color: var(--openbitfun-color-content-secondary); + font-size: var(--openbitfun-type-body-xs-font-size); + font-weight: var(--openbitfun-type-label-md-font-weight); } -.window-controls { - display: flex; align-items: center; gap: 6px; +.window-controls { -webkit-app-region: no-drag; gap: var(--openbitfun-space-1); } +.installer-preview-controls { + display: flex; + align-items: center; + gap: var(--openbitfun-space-3); + color: var(--openbitfun-color-content-muted); + font-size: var(--openbitfun-type-support-font-size); -webkit-app-region: no-drag; } - +.installer-preview-controls > span { white-space: nowrap; } +.installer-preview-controls > [data-openbitfun-component="select"] { width: 160px; } .window-controls__btn { - display: flex; align-items: center; justify-content: center; - width: 32px; height: 32px; padding: 0; + width: 32px; + height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border: 0; + border-radius: var(--openbitfun-radius-base); background: transparent; - border: 1px solid transparent; - border-radius: var(--openbitfun-radius-sm); - color: var(--openbitfun-color-content-muted); - cursor: pointer; outline: none; - transition: background 0.15s ease, - color 0.15s ease; -} -.window-controls__btn:hover { - background: var(--openbitfun-color-surface-subtle); color: var(--openbitfun-color-content-secondary); + cursor: pointer; + transition: background-color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); } -.window-controls__btn:active { opacity: 0.8; } +.window-controls__btn:hover { background: var(--openbitfun-color-action-neutral-surface-hover); } .window-controls__btn--close:hover { - background: var(--openbitfun-color-surface-subtle); + background: var(--openbitfun-color-status-danger-surface); color: var(--openbitfun-color-status-danger-content); } - -.installer-content { - flex: 1; display: flex; flex-direction: column; - min-height: 0; - overflow: hidden; position: relative; +.window-controls__btn:focus-visible, .request-preview summary:focus-visible { + outline: var(--openbitfun-focus-width) solid var(--openbitfun-color-focus-ring); + outline-offset: var(--openbitfun-focus-offset); } -.page-shell { - flex: 1; - min-height: 0; +.installer-content, .page-shell { display: flex; + flex: 1; flex-direction: column; + min-width: 0; + min-height: 0; } - +.installer-content { overflow: hidden; } +.installer-content:focus { outline: none; } .page-scroll { flex: 1; min-height: 0; overflow-y: auto; - padding: 24px 32px 18px; + padding: var(--openbitfun-space-6) var(--openbitfun-space-8); + scrollbar-gutter: stable both-edges; } - .page-container { width: 100%; - max-width: 620px; - margin: 0 auto; - animation: fadeIn 0.4s ease-out; + max-width: 560px; + margin-inline: auto; + animation: installer-enter var(--openbitfun-motion-duration-normal) var(--openbitfun-motion-easing-enter); } - .page-container--center { min-height: 100%; display: flex; flex-direction: column; justify-content: center; } - +.page-heading { margin-bottom: var(--openbitfun-space-6); } +.page-heading [data-openbitfun-part="description"] { + color: var(--openbitfun-color-content-secondary); + font-size: var(--openbitfun-type-body-md-font-size); + line-height: var(--openbitfun-type-body-md-line-height); +} .page-footer { + flex-shrink: 0; display: flex; - justify-content: flex-end; align-items: center; - gap: 10px; + justify-content: flex-end; flex-wrap: wrap; - padding: 10px 22px 12px; - border-top: 1px solid var(--openbitfun-color-border-subtle); - background: var(--openbitfun-color-surface-canvas); - flex-shrink: 0; -} - -.page-footer--split { - justify-content: space-between; -} - -.page-footer--center { - justify-content: center; -} - -.btn { - display: inline-flex; align-items: center; justify-content: center; - gap: 8px; - padding: 6px 10px; - min-height: 28px; - background: transparent; - border: 1px dashed var(--openbitfun-color-border-default); - border-radius: var(--openbitfun-radius-sm); - font-family: var(--openbitfun-type-body-md-font-family); - font-size: var(--openbitfun-type-body-sm-font-size); font-weight: var(--openbitfun-type-label-lg-font-weight); - color: var(--openbitfun-color-content-muted); - cursor: pointer; - box-shadow: none; - transition: background 0.3s cubic-bezier(0.4, 0, 0.2, 1), - color 0.3s cubic-bezier(0.4, 0, 0.2, 1), - border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1), - border-style 0.3s cubic-bezier(0.4, 0, 0.2, 1); -} -.btn svg { - color: var(--openbitfun-color-content-muted); - transition: color 0.2s ease; -} -.btn:hover:not(:disabled) { - background: var(--openbitfun-color-surface-subtle); - color: var(--openbitfun-color-content-primary); - border-style: solid; - border-color: color-mix(in srgb, var(--openbitfun-color-border-default) 70%, var(--openbitfun-color-content-primary)); - box-shadow: none; -} -.btn:hover:not(:disabled) svg { color: var(--openbitfun-color-accent-default); } -.btn:focus-visible { - outline: none; - background: var(--openbitfun-color-surface-subtle); - border-style: solid; - border-color: color-mix(in srgb, var(--openbitfun-color-border-default) 55%, var(--openbitfun-color-accent-default)); - box-shadow: 0 0 0 2px color-mix(in srgb, var(--openbitfun-color-accent-default) 32%, transparent); -} -.btn:disabled { opacity: 0.46; cursor: default; box-shadow: none; } - -.btn-primary { - color: var(--openbitfun-color-accent-default); -} -.btn-primary svg { color: var(--openbitfun-color-accent-default); } -.btn-primary:hover:not(:disabled) { - background: var(--openbitfun-color-surface-subtle); - color: var(--openbitfun-color-accent-default); -} - -.btn-success { - color: var(--openbitfun-color-status-success-content); -} -.btn-success svg { color: var(--openbitfun-color-status-success-content); } -.btn-success:hover:not(:disabled) { - background: var(--openbitfun-color-surface-subtle); - color: var(--openbitfun-color-status-success-content); -} -.btn-success:hover:not(:disabled) svg { - color: var(--openbitfun-color-status-success-content); -} - -.btn-ghost { - min-height: 28px; - padding: 6px 10px; - box-shadow: none; - color: var(--openbitfun-color-content-muted); -} -.btn-ghost:hover:not(:disabled) { - background: var(--openbitfun-color-surface-subtle); - color: var(--openbitfun-color-content-secondary); -} - -.card-btn { - display: flex; align-items: center; gap: 16px; - padding: 14px 16px; - min-height: 68px; - width: 100%; - background: transparent; - border: 1px dashed var(--openbitfun-color-border-default); - border-radius: var(--openbitfun-radius-sm); - cursor: pointer; text-align: left; - box-shadow: none; - transition: background 0.25s ease, transform 0.2s ease, border-color 0.25s ease, border-style 0.25s ease; - position: relative; overflow: hidden; -} -.card-btn:hover { - background: var(--openbitfun-color-surface-subtle); - border-style: solid; - border-color: color-mix(in srgb, var(--openbitfun-color-border-default) 70%, var(--openbitfun-color-content-primary)); - transform: translateY(-1px); - box-shadow: none; -} -.card-btn:active { transform: translateY(0); } -.card-btn:focus-visible { - outline: none; - background: var(--openbitfun-color-action-neutral-surface); - border-style: solid; - border-color: color-mix(in srgb, var(--openbitfun-color-border-default) 55%, var(--openbitfun-color-accent-default)); - box-shadow: 0 0 0 2px color-mix(in srgb, var(--openbitfun-color-accent-default) 32%, transparent); -} -.card-btn:hover .card-btn__icon { background: var(--openbitfun-color-action-neutral-surface); } -.card-btn:hover .card-btn__icon svg { color: var(--openbitfun-color-accent-default); } -.card-btn:hover .card-btn__arrow { opacity: 0.8; transform: translateX(0); } - -.card-btn__icon { - flex-shrink: 0; width: 40px; height: 40px; - display: flex; align-items: center; justify-content: center; - background: var(--openbitfun-color-surface-subtle); - border-radius: 10px; - transition: all 0.25s ease; -} -.card-btn__icon svg { color: var(--openbitfun-color-accent-default); transition: color 0.25s ease; } - -.card-btn__content { flex: 1; display: flex; flex-direction: column; gap: 3px; } -.card-btn__title { font-size: var(--openbitfun-type-body-md-font-size); font-weight: var(--openbitfun-type-label-lg-font-weight); color: var(--openbitfun-color-content-primary); } -.card-btn__subtitle { font-size: var(--openbitfun-type-body-xs-font-size); color: var(--openbitfun-color-content-muted); opacity: 0.7; } - -.card-btn__arrow { - flex-shrink: 0; color: var(--openbitfun-color-content-muted); - opacity: 0.3; transform: translateX(-6px); - transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1); -} - -.input-group { display: flex; gap: 8px; align-items: stretch; } - -.input { - flex: 1; padding: 10px 14px; - background: var(--openbitfun-color-surface-subtle); - border: 1px solid var(--openbitfun-color-border-subtle); - border-radius: var(--openbitfun-radius-sm); - color: var(--openbitfun-color-content-primary); - font-size: var(--openbitfun-type-body-xs-font-size); font-family: var(--openbitfun-type-code-md-font-family); - outline: none; transition: all 0.25s ease; -} -.input:focus { - border-color: color-mix(in srgb, var(--openbitfun-color-accent-default) 40%, transparent); - background: var(--openbitfun-color-action-neutral-surface); -} -.input::placeholder { color: var(--openbitfun-color-content-muted); } - -.openbitfun-select { - position: relative; + gap: var(--openbitfun-space-3); + min-height: 72px; width: 100%; + max-width: calc(560px + 2 * var(--openbitfun-space-8)); + margin-inline: auto; + padding: var(--openbitfun-space-4) var(--openbitfun-space-8); + background: var(--openbitfun-color-surface-canvas); } +.page-footer--split { justify-content: space-between; } -.openbitfun-select-trigger { - width: 100%; - min-height: 34px; - padding: 8px 10px; +.step-indicator { display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - background: var(--openbitfun-color-surface-subtle); - border: 1px solid var(--openbitfun-color-border-subtle); - border-radius: var(--openbitfun-radius-sm); - color: var(--openbitfun-color-content-primary); - cursor: pointer; - text-align: left; - transition: border-color 0.15s ease, background 0.15s ease; -} - -.openbitfun-select-trigger:hover { - border-color: color-mix(in srgb, var(--openbitfun-color-border-default) 70%, var(--openbitfun-color-content-primary)); - background: var(--openbitfun-color-action-neutral-surface); -} - -.openbitfun-select-trigger--open { - border-color: color-mix(in srgb, var(--openbitfun-color-accent-default) 40%, transparent); -} - -.openbitfun-select-value { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: var(--openbitfun-type-body-xs-font-size); - font-family: var(--openbitfun-type-code-md-font-family); - color: var(--openbitfun-color-content-primary); -} - -.openbitfun-select-value--placeholder { - color: var(--openbitfun-color-content-muted); -} - -.openbitfun-select-caret { flex-shrink: 0; - color: var(--openbitfun-color-content-secondary); - transition: transform 0.15s ease; -} - -.openbitfun-select-caret--open { - transform: rotate(180deg); -} - -.openbitfun-select-menu { - position: absolute; - top: calc(100% + 4px); - left: 0; - right: 0; - z-index: 20; - max-height: 176px; - overflow-y: auto; - padding: 4px; - background: var(--openbitfun-color-surface-panel); - border: 1px solid color-mix(in srgb, var(--openbitfun-color-border-default) 70%, var(--openbitfun-color-content-primary)); - border-radius: var(--openbitfun-radius-sm); - box-shadow: none; -} - -.openbitfun-select-search { - position: sticky; - top: 0; - z-index: 1; - padding: 2px 2px 6px; - background: var(--openbitfun-color-surface-panel); -} - -.openbitfun-select-search-input { + flex-wrap: wrap; + gap: var(--openbitfun-space-6); width: 100%; - min-height: 30px; - padding: 6px 8px; - border: 1px solid var(--openbitfun-color-border-subtle); - border-radius: var(--openbitfun-radius-sm); - background: var(--openbitfun-color-surface-subtle); - color: var(--openbitfun-color-content-primary); - font-size: var(--openbitfun-type-body-xs-font-size); - font-family: var(--openbitfun-type-code-md-font-family); - outline: none; -} - -.openbitfun-select-search-input:focus { - border-color: color-mix(in srgb, var(--openbitfun-color-accent-default) 40%, transparent); - background: var(--openbitfun-color-action-neutral-surface); -} - -.openbitfun-select-search-input::placeholder { + max-width: calc(560px + 2 * var(--openbitfun-space-8)); + margin-inline: auto; + list-style: none; + padding: var(--openbitfun-space-4) var(--openbitfun-space-8) var(--openbitfun-space-2); color: var(--openbitfun-color-content-muted); + font-size: var(--openbitfun-type-body-xs-font-size); } - -.openbitfun-select-option { - width: 100%; - border: 0; - background: transparent; - color: var(--openbitfun-color-content-primary); - padding: 8px; - border-radius: var(--openbitfun-radius-sm); - text-align: left; - display: flex; - flex-direction: column; - gap: 2px; - cursor: pointer; -} - -.openbitfun-select-option:hover { - background: var(--openbitfun-color-surface-subtle); -} - -.openbitfun-select-option--active { - background: var(--openbitfun-color-action-neutral-surface); +.step-indicator__item { + white-space: nowrap; } - -.openbitfun-select-option-label { - font-size: var(--openbitfun-type-body-xs-font-size); - line-height: var(--openbitfun-type-heading-page-line-height); +.step-indicator__item[aria-current="step"] { color: var(--openbitfun-color-content-primary); + font-weight: var(--openbitfun-type-label-selected-font-weight); } -.openbitfun-select-option-desc { - font-size: var(--openbitfun-type-support-font-size); - line-height: var(--openbitfun-type-heading-page-line-height); - color: var(--openbitfun-color-content-muted); -} - -.openbitfun-select-empty { - padding: 8px; - color: var(--openbitfun-color-content-muted); - font-size: var(--openbitfun-type-body-xs-font-size); +.brand-mark { + display: inline-block; + flex-shrink: 0; + width: 112px; + height: 112px; + background-color: currentColor; + -webkit-mask: url('../../../assets/brand/source/openbitfun-mark.svg') center / contain no-repeat; + mask: url('../../../assets/brand/source/openbitfun-mark.svg') center / contain no-repeat; +} +.brand-mark[data-size="hero"] { width: 144px; height: 144px; } +.brand-mark[data-working="true"] { animation: installer-breathe 2400ms ease-in-out infinite; } + +.welcome-page { display: flex; flex: 1; min-height: 0; } +.welcome-brand { + flex: 0 0 42%; + display: flex; + align-items: center; + justify-content: center; + padding: var(--openbitfun-space-8); } - -.model-setup-page { - flex: 1; - min-height: 0; +.welcome-brand__identity { display: flex; flex-direction: column; -} - -.model-setup-scroll { - flex: 1; - min-height: 0; - overflow-y: auto; - padding: 10px 22px 8px; -} - -.model-setup-container { - width: 100%; - max-width: 620px; - margin: 0 auto; -} - -.model-setup-intro { - margin-bottom: 2px; - font-size: var(--openbitfun-type-body-xs-font-size); + align-items: center; + gap: var(--openbitfun-space-4); + padding-bottom: var(--openbitfun-space-8); + animation: installer-enter var(--openbitfun-motion-duration-normal) var(--openbitfun-motion-easing-enter); +} +.welcome-brand__name { + font-family: var(--openbitfun-type-display-md-font-family); + font-size: var(--openbitfun-type-display-md-font-size); + font-weight: var(--openbitfun-type-heading-page-font-weight); + line-height: var(--openbitfun-type-display-md-line-height); + letter-spacing: var(--openbitfun-type-display-md-letter-spacing); +} +.welcome-brand__version { color: var(--openbitfun-color-content-muted); -} - -.model-setup-desc { - margin-bottom: 8px; - font-size: var(--openbitfun-type-body-xs-font-size); - color: var(--openbitfun-color-content-secondary); -} - -.model-setup-provider-desc { - margin-top: 4px; - margin-bottom: 2px; font-size: var(--openbitfun-type-support-font-size); - line-height: var(--openbitfun-type-modifier-leading-balanced-line-height); - color: var(--openbitfun-color-content-muted); -} - -.model-setup-row { - display: grid; - grid-template-columns: minmax(100px, 34%) 1fr; - gap: 6px 10px; - align-items: center; - margin-top: 5px; -} - -.model-setup-row__label { - font-size: var(--openbitfun-type-body-xs-font-size); - line-height: var(--openbitfun-type-modifier-leading-dense-line-height); - color: var(--openbitfun-color-content-secondary); - text-align: end; - padding-right: 2px; - word-break: break-word; -} - -.model-setup-row__control { - min-width: 0; + font-variant-numeric: tabular-nums; } - -.model-setup-row__control .input, -.model-setup-row__control .openbitfun-select { +.welcome-content { max-width: 360px; } +.language-options { display: flex; flex-direction: column; gap: var(--openbitfun-space-2); } +.language-option { width: 100%; -} - -.model-setup-stack { - display: flex; - flex-direction: column; - gap: 5px; -} - -.model-setup-inline { - display: flex; - gap: 8px; + min-height: 44px; align-items: center; + padding: var(--openbitfun-space-3); + border-radius: var(--openbitfun-radius-base); + transition: background-color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); } - -.model-setup-inline .input { - flex: 1; - min-width: 0; -} - -.model-setup-secret-btn { - flex-shrink: 0; - padding: 6px 8px; -} - -.input--readonly { - opacity: 0.92; - cursor: default; +.language-option:hover { background: var(--openbitfun-color-surface-subtle); } +.language-option:has(input:checked) { + background: var(--openbitfun-color-surface-subtle); } -.model-setup-fetch-hint { - font-size: var(--openbitfun-type-support-font-size); - line-height: var(--openbitfun-type-modifier-leading-balanced-line-height); +.section-label { + display: block; + margin-bottom: var(--openbitfun-space-3); + color: var(--openbitfun-color-content-secondary); + font-size: var(--openbitfun-type-meta-font-size); + font-weight: var(--openbitfun-type-meta-font-weight); + line-height: var(--openbitfun-type-meta-line-height); +} +.install-location { margin-bottom: var(--openbitfun-space-6); } +.path-input { flex: 1; } +.path-input input { font-family: var(--openbitfun-type-code-md-font-family); } +.disk-space { + display: flex; + flex-wrap: wrap; + gap: var(--openbitfun-space-2) var(--openbitfun-space-4); + margin-top: var(--openbitfun-space-2); color: var(--openbitfun-color-content-muted); - margin-top: 4px; - padding-left: calc(34% + 10px); + font-size: var(--openbitfun-type-support-font-size); } - -@media (max-width: 520px) { - .model-setup-fetch-hint { - padding-left: 0; - } +.install-options { display: flex; flex-direction: column; gap: var(--openbitfun-space-4); } +.existing-install { + margin-bottom: var(--openbitfun-space-5); + color: var(--openbitfun-color-content-secondary); + font-size: var(--openbitfun-type-body-sm-font-size); } - -.model-setup-fields { +.existing-install__details { display: flex; flex-direction: column; - margin-top: 4px; + align-items: flex-start; + gap: var(--openbitfun-space-2); } +.selectable-path { overflow-wrap: anywhere; user-select: text; } -.model-setup-test-msg--ok { - font-size: var(--openbitfun-type-body-xs-font-size); - color: var(--openbitfun-color-status-success-content); -} - -.model-setup-test-msg--err { - font-size: var(--openbitfun-type-body-xs-font-size); - color: var(--openbitfun-color-status-danger-content); -} - -.model-setup-test-row { - display: flex; - align-items: center; - gap: 10px; - margin-top: 8px; - flex-wrap: wrap; -} - -.model-setup-footer { - display: flex; - justify-content: flex-end; - align-items: center; - gap: 10px; - flex-wrap: wrap; - padding: 10px 22px 12px; - border-top: 1px solid var(--openbitfun-color-border-subtle); +.progress-content { max-width: 440px; align-items: center; text-align: center; } +.progress-brand { position: relative; margin-bottom: var(--openbitfun-space-5); } +.progress-brand__status { + position: absolute; + bottom: 0; + right: 0; + border-radius: var(--openbitfun-radius-pill); background: var(--openbitfun-color-surface-canvas); - flex-shrink: 0; -} - -@media (max-width: 760px) { - .model-setup-scroll { - padding: 8px 14px 6px; - } - - .model-setup-footer { - padding: 8px 14px 10px; - } } - -.checkbox-group { display: flex; flex-direction: column; gap: 2px; } - -.checkbox-item { - display: flex; align-items: center; gap: 10px; - cursor: pointer; padding: 8px 10px; - border-radius: var(--openbitfun-radius-sm); - transition: background 0.15s ease; -} -.checkbox-item:hover { background: var(--openbitfun-color-surface-subtle); } - -.checkbox-box { - width: 16px; height: 16px; border-radius: 4px; - border: 1.5px solid color-mix(in srgb, var(--openbitfun-color-border-default) 70%, var(--openbitfun-color-content-primary)); - background: transparent; - display: flex; align-items: center; justify-content: center; - transition: all 0.15s ease; flex-shrink: 0; +.progress-details { width: 100%; max-width: 360px; } +.progress-meta { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: var(--openbitfun-space-3); + margin-bottom: var(--openbitfun-space-3); + text-align: start; + color: var(--openbitfun-color-content-secondary); + font-size: var(--openbitfun-type-body-sm-font-size); } -.checkbox-box.checked { background: var(--openbitfun-color-accent-default); border-color: var(--openbitfun-color-accent-default); } -.checkbox-box svg { width: 10px; height: 10px; color: var(--openbitfun-color-surface-panel); opacity: 0; transition: opacity 0.15s ease; } -.checkbox-box.checked svg { opacity: 1; } - -.checkbox-label { font-size: var(--openbitfun-type-body-sm-font-size); color: var(--openbitfun-color-content-secondary); transition: color 0.15s ease; } -.checkbox-item:hover .checkbox-label { color: var(--openbitfun-color-content-primary); } - -.progress-bar-container { - width: 100%; height: 4px; +.progress-percent { flex-shrink: 0; font-variant-numeric: tabular-nums; } +.progress-bar { + width: 100%; + height: 4px; + overflow: hidden; + border-radius: var(--openbitfun-radius-pill); background: var(--openbitfun-color-action-neutral-surface); - border-radius: 2px; overflow: hidden; -} -.progress-bar-fill { - height: 100%; border-radius: 2px; - background: var(--openbitfun-color-accent-default); - transition: width 400ms cubic-bezier(0.4, 0, 0.2, 1); - position: relative; } -.progress-bar-fill::after { - content: ''; position: absolute; inset: 0; - background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--openbitfun-color-accent-default) 15%, transparent), transparent); - background-size: 200% 100%; - animation: shimmer 2s linear infinite; -} - -.step-indicator { display: flex; align-items: center; padding: 12px 0 20px; } -.step-dot-wrapper { display: flex; align-items: center; } -.step-dot { - width: 6px; height: 6px; border-radius: 50%; - background: color-mix(in srgb, var(--openbitfun-color-action-neutral-surface-pressed) 75%, var(--openbitfun-color-content-primary)); - transition: all 0.25s ease; -} -.step-dot.active { - width: 8px; height: 8px; +.progress-bar__fill { + height: 100%; + border-radius: inherit; background: var(--openbitfun-color-accent-default); - box-shadow: 0 0 8px color-mix(in srgb, var(--openbitfun-color-accent-default) 25%, transparent); + transition: width var(--openbitfun-motion-duration-normal) var(--openbitfun-motion-easing-standard); } -.step-dot.completed { background: var(--openbitfun-color-status-success-content); } -.step-line { width: 40px; height: 1px; background: color-mix(in srgb, var(--openbitfun-color-action-neutral-surface-pressed) 75%, var(--openbitfun-color-content-primary)); transition: background 0.25s ease; } -.step-line.completed { background: var(--openbitfun-color-status-success-content); } +.progress-bar[data-completed="true"] .progress-bar__fill { background: var(--openbitfun-color-status-success-content); } -.section-label { - display: flex; align-items: center; gap: 8px; - font-size: var(--openbitfun-type-body-xs-font-size); font-weight: var(--openbitfun-type-label-lg-font-weight); +.model-fields { display: flex; flex-direction: column; gap: var(--openbitfun-space-3); } +.model-fetch-hint, .request-preview { color: var(--openbitfun-color-content-muted); - text-transform: uppercase; - letter-spacing: var(--openbitfun-type-modifier-tracking-widest-letter-spacing); - margin-bottom: 12px; -} -.section-label svg { opacity: 0.6; } - -.installer-footer { - display: flex; justify-content: space-between; align-items: center; - padding-top: 16px; margin-top: auto; + font-size: var(--openbitfun-type-support-font-size); + line-height: var(--openbitfun-type-support-line-height); +} +.request-preview summary { cursor: pointer; width: fit-content; } +.request-preview code { + display: block; + margin-top: var(--openbitfun-space-2); + padding: var(--openbitfun-space-3); + border-radius: var(--openbitfun-radius-base); + background: var(--openbitfun-color-surface-subtle); + font-family: var(--openbitfun-type-code-md-font-family); + overflow-wrap: anywhere; + user-select: text; } - -::-webkit-scrollbar { width: 4px; } -::-webkit-scrollbar-track { background: transparent; } -::-webkit-scrollbar-thumb { background: color-mix(in srgb, var(--openbitfun-color-action-neutral-surface-pressed) 75%, var(--openbitfun-color-content-primary)); border-radius: 2px; } -::-webkit-scrollbar-thumb:hover { background: var(--openbitfun-color-content-muted); } - -.uninstall-page { - flex: 1; +.model-test-row { display: flex; - min-height: 0; - flex-direction: column; + flex-wrap: wrap; + align-items: center; + gap: var(--openbitfun-space-3); + margin-top: var(--openbitfun-space-5); + font-size: var(--openbitfun-type-body-sm-font-size); } +.model-test-row > span { overflow-wrap: anywhere; } -.uninstall-card { - width: 100%; - max-width: 600px; - background: transparent; - border: none; - border-radius: 0; - padding: 0; - margin: 0 auto; +.theme-options { display: flex; flex-direction: column; gap: var(--openbitfun-space-3); } +.theme-system { + min-height: 40px; + align-items: center; + width: fit-content; + padding-inline: var(--openbitfun-space-3); } - -.uninstall-title { - margin-bottom: 22px; - font-size: var(--openbitfun-type-heading-page-font-size); - font-weight: var(--openbitfun-type-label-selected-font-weight); - color: var(--openbitfun-color-content-primary); - text-align: center; - letter-spacing: var(--openbitfun-type-modifier-tracking-subtle-letter-spacing); +.theme-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--openbitfun-space-1) var(--openbitfun-space-5); } - -.uninstall-subtitle { - margin-bottom: 8px; - font-size: var(--openbitfun-type-body-xs-font-size); - color: var(--openbitfun-color-content-primary); - text-align: left; - opacity: 0.86; +.theme-option { + min-height: 48px; + align-items: center; + padding: var(--openbitfun-space-3); + border-radius: var(--openbitfun-radius-base); + transition: background-color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); } - -.uninstall-inline-meta { +.theme-option:hover, .theme-option:has(input:checked) { + background: var(--openbitfun-color-surface-subtle); +} +.theme-option > [data-openbitfun-part="content"] { flex: 1; } +.theme-option__label { display: flex; align-items: center; - gap: 6px; - margin-bottom: 14px; - padding: 4px 0 6px; - font-size: var(--openbitfun-type-body-xs-font-size); - color: var(--openbitfun-color-content-muted); - border: none; - min-width: 0; + justify-content: space-between; + gap: var(--openbitfun-space-3); } - -.uninstall-inline-label { - white-space: nowrap; +.theme-palette { + display: flex; flex-shrink: 0; - color: var(--openbitfun-color-content-secondary); - font-family: var(--openbitfun-type-code-md-font-family); - opacity: 1; -} - -.uninstall-inline-path { - color: var(--openbitfun-color-content-muted); - font-family: var(--openbitfun-type-code-md-font-family); - white-space: nowrap; + width: 56px; + height: 24px; overflow: hidden; - text-overflow: ellipsis; - min-width: 0; - max-width: 82%; - flex: 0 1 auto; - opacity: 0.9; + border-radius: var(--openbitfun-radius-base); } +.theme-palette > span { flex: 2; } +.theme-palette > span:last-child { flex: 1; } +.finish-error { margin-top: var(--openbitfun-space-3); overflow-wrap: anywhere; } -.uninstall-error { - margin-bottom: 10px; +.install-error { + margin-top: var(--openbitfun-space-3); color: var(--openbitfun-color-status-danger-content); - font-size: var(--openbitfun-type-body-xs-font-size); -} - -.uninstall-success { - margin-bottom: 10px; - color: var(--openbitfun-color-status-success-content); - font-size: var(--openbitfun-type-body-xs-font-size); -} - -.uninstall-progress-wrap { - display: flex; - align-items: center; - gap: 10px; - margin-bottom: 14px; -} - -.uninstall-progress-wrap .progress-bar-container { - flex: 1; -} - -.uninstall-progress-text { - font-size: var(--openbitfun-type-support-font-size); - color: var(--openbitfun-color-content-muted); - min-width: 34px; - text-align: right; + font-size: var(--openbitfun-type-body-sm-font-size); + line-height: var(--openbitfun-type-body-sm-line-height); + overflow-wrap: anywhere; + user-select: text; +} +.install-error[data-variant="bare"] { width: 100%; text-align: start; } +.install-error__hint { margin-top: var(--openbitfun-space-3); color: var(--openbitfun-color-content-secondary); } +.status-success { color: var(--openbitfun-color-status-success-content); } +.status-warning { color: var(--openbitfun-color-status-warning-content); } +.status-danger { color: var(--openbitfun-color-status-danger-content); } + +.uninstall-content { max-width: 520px; } +.uninstall-content > .brand-mark { width: 80px; height: 80px; margin-bottom: var(--openbitfun-space-5); } +.uninstall-location { margin-bottom: var(--openbitfun-space-5); } +.uninstall-location .section-label { margin-bottom: var(--openbitfun-space-2); } +.uninstall-location .selectable-path { + font-family: var(--openbitfun-type-code-md-font-family); + font-size: var(--openbitfun-type-body-sm-font-size); } +.uninstall-progress { display: flex; align-items: center; gap: var(--openbitfun-space-3); margin-top: var(--openbitfun-space-4); } +.uninstall-progress .progress-bar { flex: 1; } -.uninstall-actions { - display: flex; - gap: 10px; - justify-content: flex-end; - flex-wrap: wrap; +@media (max-width: 640px) { + .page-scroll, .page-footer, .step-indicator { padding-inline: var(--openbitfun-space-5); } + .welcome-brand { flex-basis: 36%; padding-inline: var(--openbitfun-space-4); } + .brand-mark[data-size="hero"] { width: 128px; height: 128px; } + .welcome-brand__name { font-size: var(--openbitfun-type-heading-page-font-size); } } -@media (max-width: 760px) { - .page-scroll { - padding: 18px 18px 12px; - } - - .page-footer { - padding: 8px 14px 10px; - } +@media (max-width: 480px) { + .welcome-page { flex-direction: column; overflow-y: auto; } + .welcome-brand { flex: 0 0 auto; padding: var(--openbitfun-space-4); } + .welcome-brand__identity { flex-direction: row; padding: 0; gap: var(--openbitfun-space-3); } + .welcome-brand .brand-mark { width: 56px; height: 56px; } + .welcome-brand__version { display: none; } + .welcome-page .page-shell { flex: 1 0 auto; } + .theme-grid { grid-template-columns: minmax(0, 1fr); } } -@media (max-width: 520px) { - .page-container--center { - justify-content: flex-start; - } - - .page-footer, - .page-footer--split { - justify-content: center; - } +@media (forced-colors: active) { + .brand-mark { background: CanvasText; } + .theme-option:has(input:checked) { outline: 1px solid Highlight; } + .progress-bar { border: 1px solid CanvasText; } + .progress-bar__fill { background: Highlight; forced-color-adjust: none; } } diff --git a/OpenBitFun-Installer/src/theme/installerThemeRuntime.ts b/OpenBitFun-Installer/src/theme/installerThemeRuntime.ts index d7ad943c69..bd8421f848 100644 --- a/OpenBitFun-Installer/src/theme/installerThemeRuntime.ts +++ b/OpenBitFun-Installer/src/theme/installerThemeRuntime.ts @@ -17,6 +17,8 @@ export function applyInstallerThemeToDocument(theme: InstallerTheme): void { root.style.setProperty('--openbitfun-color-surface-canvas', colors.background.primary); root.style.setProperty('--openbitfun-color-surface-panel', colors.background.secondary); + root.style.setProperty('--openbitfun-color-field-background', colors.background.secondary); + root.style.setProperty('--openbitfun-color-field-background-hover', colors.background.secondary); root.style.setProperty('--openbitfun-color-content-primary', colors.text.primary); root.style.setProperty('--openbitfun-color-content-secondary', colors.text.secondary); root.style.setProperty('--openbitfun-color-content-muted', colors.text.muted); diff --git a/OpenBitFun-Installer/src/types/installer.ts b/OpenBitFun-Installer/src/types/installer.ts index 8913048820..5cd6d9c550 100644 --- a/OpenBitFun-Installer/src/types/installer.ts +++ b/OpenBitFun-Installer/src/types/installer.ts @@ -5,6 +5,7 @@ export type InstallStep = 'lang' | 'options' | 'model' | 'progress' | 'theme' | export interface LaunchContext { mode: 'install' | 'uninstall'; + previewOnly?: boolean; uninstallPath: string | null; appLanguage?: AppLanguage | null; } diff --git a/design-system/packages/ui/src/components/Checkbox/Checkbox.module.css b/design-system/packages/ui/src/components/Checkbox/Checkbox.module.css index a683eff22a..20e7f23067 100644 --- a/design-system/packages/ui/src/components/Checkbox/Checkbox.module.css +++ b/design-system/packages/ui/src/components/Checkbox/Checkbox.module.css @@ -74,7 +74,10 @@ } .root[data-invalid="true"] .box { border-color: var(--openbitfun-color-status-danger-border); } - .root[data-disabled="true"] .box { background: var(--openbitfun-color-field-background); } + .root[data-disabled="true"] .box { + color: var(--openbitfun-color-content-disabled); + background: var(--openbitfun-color-field-background); + } .icon { inline-size: 100%; diff --git a/design-system/packages/ui/src/components/Input/Input.meta.ts b/design-system/packages/ui/src/components/Input/Input.meta.ts index 8462fa2743..ffe75d96a3 100644 --- a/design-system/packages/ui/src/components/Input/Input.meta.ts +++ b/design-system/packages/ui/src/components/Input/Input.meta.ts @@ -11,6 +11,7 @@ export const inputMeta = { { name: "placeholder", type: "string" }, { name: "leading", type: "ReactNode" }, { name: "trailing", type: "ReactNode" }, + { defaultValue: "false", name: "hideNativePasswordReveal", type: "boolean" }, { defaultValue: "sm", name: "size", type: "sm | md | lg" }, { defaultValue: "false", name: "readOnly", type: "boolean" }, { defaultValue: "false", name: "invalid", type: "boolean" }, diff --git a/design-system/packages/ui/src/components/Input/Input.module.css b/design-system/packages/ui/src/components/Input/Input.module.css index bd6b1c02b1..8a956e5fb4 100644 --- a/design-system/packages/ui/src/components/Input/Input.module.css +++ b/design-system/packages/ui/src/components/Input/Input.module.css @@ -87,6 +87,10 @@ display: none; } + .input[type="password"][data-hide-native-password-reveal="true"]::-ms-reveal { + display: none; + } + .leading, .trailing { --openbitfun-opacity-icon-artwork: 1; diff --git a/design-system/packages/ui/src/components/Input/Input.tsx b/design-system/packages/ui/src/components/Input/Input.tsx index f8100d52fc..1d8212aadf 100644 --- a/design-system/packages/ui/src/components/Input/Input.tsx +++ b/design-system/packages/ui/src/components/Input/Input.tsx @@ -13,6 +13,8 @@ import styles from "./Input.module.css"; export interface InputProps extends Omit, "className" | "size"> { className?: string; + /** Hide the browser reveal button when the caller provides a visibility control. */ + hideNativePasswordReveal?: boolean; invalid?: boolean; leading?: ReactNode; onValueChange?: (value: string) => void; @@ -24,6 +26,7 @@ export const Input = forwardRef(function Input({ "aria-invalid": ariaInvalid, className, disabled, + hideNativePasswordReveal = false, invalid = false, leading, onChange, @@ -63,6 +66,7 @@ export const Input = forwardRef(function Input({ {...props} aria-invalid={resolvedAriaInvalid} className={styles.input} + data-hide-native-password-reveal={hideNativePasswordReveal ? "true" : undefined} disabled={disabled} onChange={handleChange} onCompositionEnd={(event) => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 197f0ca7c0..8c4ca0fff2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,9 @@ importers: '@openbitfun/theme-openbitfun': specifier: workspace:^ version: link:../design-system/packages/theme-openbitfun + '@openbitfun/ui': + specifier: workspace:^ + version: link:../design-system/packages/ui '@tauri-apps/api': specifier: 2.11.1 version: 2.11.1 diff --git a/src/mobile-web/src/components/WorkspaceFiles.scss b/src/mobile-web/src/components/WorkspaceFiles.scss index c162902de5..202200226c 100644 --- a/src/mobile-web/src/components/WorkspaceFiles.scss +++ b/src/mobile-web/src/components/WorkspaceFiles.scss @@ -7,7 +7,7 @@ display: flex; align-items: center; gap: 8px; min-width: 0; } &__toolbar { justify-content: space-between; flex-wrap: wrap; } - &__title { flex: 1; .runtime-files__sort { margin-left: auto; } h2 { font-size: 16px; font-weight: 600; margin: 0; } } + &__title { flex: 1; .runtime-files__sort { margin-left: auto; } h2 { font-size: var(--openbitfun-type-flow-title-font-size); font-weight: var(--openbitfun-type-label-selected-font-weight); margin: 0; } } &__sort { min-width: 0; [data-openbitfun-part="label"] { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } } &__tools { flex-wrap: wrap; margin-left: auto; } &__icon { flex: 0 0 34px; width: 34px; padding: 0; [data-openbitfun-part="label"] { display: flex; justify-content: center; } } @@ -25,15 +25,15 @@ [data-openbitfun-part="label"] { display: block; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } } &__name { display: block; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - &__modified { display: block; margin-top: 3px; color: var(--openbitfun-color-content-secondary); font-size: 11px; line-height: 1.3; } + &__modified { display: block; margin-top: 3px; color: var(--openbitfun-color-content-secondary); font-size: var(--openbitfun-type-meta-font-size); line-height: var(--openbitfun-type-modifier-leading-snug-line-height); } &__row-actions { gap: 0; flex-shrink: 0; color: var(--openbitfun-color-content-secondary); } - &__empty { min-height: 200px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; color: var(--openbitfun-color-content-secondary); font-size: 13px; } + &__empty { min-height: 200px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; color: var(--openbitfun-color-content-secondary); font-size: var(--openbitfun-type-body-sm-font-size); } &__action { display: grid; grid-template-columns: minmax(0, 1fr) minmax(120px, 1fr) auto; align-items: center; gap: 12px; padding: 14px; border: 1px solid var(--files-border); background: var(--openbitfun-color-surface-raised); border-radius: 12px; - strong { font-size: 13px; } p { margin: 4px 0 0; font-size: 12px; color: var(--openbitfun-color-content-secondary); overflow-wrap: anywhere; } + strong { font-size: var(--openbitfun-type-body-sm-font-size); } p { margin: 4px 0 0; font-size: var(--openbitfun-type-body-xs-font-size); color: var(--openbitfun-color-content-secondary); overflow-wrap: anywhere; } } &__action-buttons { justify-content: flex-end; } &__upload { padding: 12px; border: 1px solid var(--files-border); border-radius: 12px; - > div { flex: 1; min-width: 0; } strong { display: block; font-size: 13px; overflow-wrap: anywhere; } progress { display: block; width: 100%; height: 5px; margin-top: 8px; accent-color: var(--openbitfun-color-content-primary); } + > div { flex: 1; min-width: 0; } strong { display: block; font-size: var(--openbitfun-type-body-sm-font-size); overflow-wrap: anywhere; } progress { display: block; width: 100%; height: 5px; margin-top: 8px; accent-color: var(--openbitfun-color-content-primary); } } &__spinner { animation: runtime-files-spin 1s linear infinite; } } @@ -55,26 +55,26 @@ [data-openbitfun-part="heading"] { text-align: left; } [data-openbitfun-part="body"] { display: flex; flex: 1; min-height: 0; flex-direction: column; padding: 0; overflow: hidden; } [data-openbitfun-component="mobile-textarea"] { flex: 1; min-height: 0; border: 0; border-radius: 0; } - .runtime-file-editor__input { width: 100%; height: 100%; min-height: 0; resize: none; padding: 20px; font-family: var(--openbitfun-type-code-md-font-family); font-size: 14px; line-height: 1.7; tab-size: 2; border-radius: 0; } + .runtime-file-editor__input { width: 100%; height: 100%; min-height: 0; resize: none; padding: 20px; font-family: var(--openbitfun-type-code-md-font-family); font-size: var(--openbitfun-type-body-md-font-size); line-height: var(--openbitfun-type-modifier-leading-spacious-line-height); tab-size: 2; border-radius: 0; } } .runtime-file-editor__actions { display: flex; align-items: center; gap: 8px; } @media (max-width: 600px) { .runtime-file-editor[data-openbitfun-part="surface"] [data-openbitfun-part="header"] { flex-direction: column-reverse; align-items: stretch; gap: 12px; } .runtime-file-editor__actions { justify-content: space-between; } - .runtime-file-editor[data-openbitfun-part="surface"] .runtime-file-editor__input { padding: 14px; font-size: 13px; } + .runtime-file-editor[data-openbitfun-part="surface"] .runtime-file-editor__input { padding: 14px; font-size: var(--openbitfun-type-code-md-font-size); } } .runtime-file-editor__document { display: flex; flex: 1; min-height: 0; overflow: hidden; } .runtime-file-editor__gutter { flex-shrink: 0; overflow: hidden; user-select: none; text-align: right; - font-family: var(--openbitfun-type-code-md-font-family); font-size: 14px; line-height: 1.7; + font-family: var(--openbitfun-type-code-md-font-family); font-size: var(--openbitfun-type-body-md-font-size); line-height: var(--openbitfun-type-modifier-leading-spacious-line-height); color: var(--openbitfun-color-content-secondary); background: var(--openbitfun-color-surface-panel); border-right: 1px solid var(--openbitfun-color-border-subtle); pre { margin: 0; padding: 20px 10px; font: inherit; white-space: pre; } } .runtime-file-editor__document [data-openbitfun-component="mobile-textarea"] { min-width: 0; padding: 0; } @media (max-width: 600px) { - .runtime-file-editor__gutter { font-size: 13px; pre { padding-block: 14px; } } + .runtime-file-editor__gutter { font-size: var(--openbitfun-type-code-md-font-size); pre { padding-block: 14px; } } } /* Flat actions and list rows throughout the runtime tools flow. */ diff --git a/src/mobile-web/src/components/WorkspaceTerminal.tsx b/src/mobile-web/src/components/WorkspaceTerminal.tsx index e4a3437356..9f5d981452 100644 --- a/src/mobile-web/src/components/WorkspaceTerminal.tsx +++ b/src/mobile-web/src/components/WorkspaceTerminal.tsx @@ -27,7 +27,13 @@ export function WorkspaceTerminal({ manager, workspace }: { manager: RemoteSessi let caughtUp = false; let stop: import('../../../shared/relay-transport/SessionStream').SessionStreamHandle | undefined; const fail = (cause: unknown) => { if (!disposed) setError(cause instanceof Error ? cause.message : String(cause)); }; - const terminal = new Terminal({cursorBlink:true,scrollback:5000,fontSize:14,convertEol:false}); + const typography = getComputedStyle(container.current); + const terminal = new Terminal({ + cursorBlink: true, + scrollback: 5000, + fontSize: Number.parseFloat(typography.getPropertyValue('--openbitfun-type-body-md-font-size')), + convertEol: false, + }); const fit = new FitAddon(); terminal.loadAddon(fit); terminal.open(container.current); const applyTheme = () => { if (!container.current || disposed) return; diff --git a/src/mobile-web/src/pages/DeviceToolsPage.tsx b/src/mobile-web/src/pages/DeviceToolsPage.tsx index 8dc788a4b2..6395c8c68d 100644 --- a/src/mobile-web/src/pages/DeviceToolsPage.tsx +++ b/src/mobile-web/src/pages/DeviceToolsPage.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from 'react'; import { ChevronDown, Files, Monitor, Server, TerminalSquare, X } from 'lucide-react'; -import { MobileBanner, MobileChoiceSheet, MobileIconButton, MobileListRow, MobilePageHeader, MobileStatus } from '@openbitfun/ui/mobile'; +import { MobileBanner, MobileButton, MobileChoiceSheet, MobileIconButton, MobileListRow, MobilePageHeader, MobileStatus } from '@openbitfun/ui/mobile'; import { WorkspaceFiles } from '../components/WorkspaceFiles'; import { WorkspaceTerminal } from '../components/WorkspaceTerminal'; import type { RemoteSessionManager } from '../services/RemoteSessionManager'; @@ -42,8 +42,8 @@ export default function DeviceToolsPage({ manager, onBack }: { manager: RemoteSe leading={connection ? : } trailing={} onClick={() => setChoosing(true)}/>
- - + } onClick={() => setPanel('files')}>{t('workspace.files')} + } onClick={() => setPanel('terminal')}>{t('workspace.terminal')}
setChoosing(false)} title={t('workspace.location')} diff --git a/src/mobile-web/src/pages/WorkspacePage.scss b/src/mobile-web/src/pages/WorkspacePage.scss index 6c41c66cdd..afd49ae9b5 100644 --- a/src/mobile-web/src/pages/WorkspacePage.scss +++ b/src/mobile-web/src/pages/WorkspacePage.scss @@ -11,13 +11,13 @@ display: flex; flex-direction: column; gap: 16px; padding: 24px; border: 1px solid var(--openbitfun-color-border-subtle); border-radius: 20px; background: var(--openbitfun-color-surface-panel); - p { color: var(--openbitfun-color-content-secondary); font-size: 13px; line-height: 1.6; margin: 0; } + p { color: var(--openbitfun-color-content-secondary); font-size: var(--openbitfun-type-body-sm-font-size); line-height: var(--openbitfun-type-body-lg-line-height); margin: 0; } } -.workspace-picker__label { display: flex; flex-direction: column; gap: 8px; font-size: 13px; font-weight: 500; } -.workspace-picker__section-title { margin: 28px 4px 12px; font-size: 13px; font-weight: 500; color: var(--openbitfun-color-content-secondary); } +.workspace-picker__label { display: flex; flex-direction: column; gap: 8px; font-size: var(--openbitfun-type-label-sm-font-size); font-weight: var(--openbitfun-type-label-md-font-weight); } +.workspace-picker__section-title { margin: 28px 4px 12px; font-size: var(--openbitfun-type-label-sm-font-size); font-weight: var(--openbitfun-type-label-md-font-weight); color: var(--openbitfun-color-content-secondary); } .workspace-terminal { display: flex; flex-direction: column; gap: 16px; } .workspace-terminal__toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; } -.workspace-terminal__path { min-width: 0; flex: 1; overflow-wrap: anywhere; color: var(--openbitfun-color-content-secondary); font-size: 13px; } +.workspace-terminal__path { min-width: 0; flex: 1; overflow-wrap: anywhere; color: var(--openbitfun-color-content-secondary); font-size: var(--openbitfun-type-code-md-font-size); } .workspace-terminal__actions { display: flex; gap: 8px; } .workspace-terminal__screen { flex: 1; min-height: 0; padding: 14px; overflow: hidden; box-sizing: border-box; @@ -42,7 +42,7 @@ } .workspace-folder-picker { .workspace-folder-picker__path { display: flex; align-items: center; gap: 10px; padding: 8px; border-bottom: 1px solid var(--openbitfun-color-border-subtle); } - .workspace-folder-picker__path > span { min-width: 0; overflow-wrap: anywhere; font-size: 13px; color: var(--openbitfun-color-content-secondary); } + .workspace-folder-picker__path > span { min-width: 0; overflow-wrap: anywhere; font-size: var(--openbitfun-type-code-md-font-size); color: var(--openbitfun-color-content-secondary); } .workspace-folder-picker__list { min-height: 180px; max-height: 45dvh; overflow-y: auto; } .workspace-folder-picker__footer { display: flex; justify-content: flex-end; gap: 8px; } } diff --git a/src/mobile-web/src/styles/components/harmony-native.scss b/src/mobile-web/src/styles/components/harmony-native.scss index a539972353..efb04dedb2 100644 --- a/src/mobile-web/src/styles/components/harmony-native.scss +++ b/src/mobile-web/src/styles/components/harmony-native.scss @@ -345,7 +345,7 @@ } .harmony-sidebar__device-row.is-current .harmony-sidebar__row-label { - font-weight: var(--openbitfun-font-weight-bold); + font-weight: var(--openbitfun-type-label-selected-font-weight); } .harmony-sidebar__status { @@ -1499,7 +1499,7 @@ } .harmony-sidebar__workspace-session.is-current .harmony-sidebar__row-label { - font-weight: var(--openbitfun-font-weight-bold); + font-weight: var(--openbitfun-type-label-selected-font-weight); } .harmony-sidebar__device-icon svg, diff --git a/src/mobile-web/tests/helpers/browser-account-harness.mjs b/src/mobile-web/tests/helpers/browser-account-harness.mjs index e73c826a85..95077413d6 100644 --- a/src/mobile-web/tests/helpers/browser-account-harness.mjs +++ b/src/mobile-web/tests/helpers/browser-account-harness.mjs @@ -229,6 +229,10 @@ export class RelayFixture { if (!request.isInterceptResolutionHandled()) await request.abort().catch(() => {}); }); }); + // Account scenarios use English action labels independently of the host OS. + await page.evaluateOnNewDocument(() => { + localStorage.setItem('openbitfun-mobile-language', 'en-US'); + }); if (init) await page.evaluateOnNewDocument(init); await page.goto(url, { waitUntil: 'networkidle0' }); return page; @@ -274,6 +278,10 @@ export async function signOut(page) { if (!await page.$('.devices-page') && await page.$('.harmony-sidebar__settings')) { await page.click('button[aria-label="Settings"]'); await page.waitForSelector('button[aria-label="Devices"]', { visible: true }); + await page.$eval('button[aria-label="Devices"]', async button => { + const sheet = button.closest('[role="dialog"]'); + if (sheet) await Promise.all(sheet.getAnimations().map(animation => animation.finished)); + }); } const button = await page.$('button[aria-label="Devices"]'); if (button) { await button.click(); await page.waitForSelector('.devices-page'); } diff --git a/src/web-ui/src/app/scenes/agents/AgentsScene.tsx b/src/web-ui/src/app/scenes/agents/AgentsScene.tsx index ebd49645df..61fcdf5207 100644 --- a/src/web-ui/src/app/scenes/agents/AgentsScene.tsx +++ b/src/web-ui/src/app/scenes/agents/AgentsScene.tsx @@ -27,6 +27,7 @@ import { type AgentCapabilityTooltipField, } from './components/AgentCapabilityTooltip'; import { capabilityTooltipAriaLabel } from './components/agentCapabilityTooltipUtils'; +import { AgentCapabilityOption } from './components/AgentCapabilityOption'; import { SkillGroupPicker, SkillGroupSummary } from './components/SkillGroupPicker'; import { ToolGroupPicker, ToolGroupSummary } from './components/ToolGroupPicker'; import { useUserSkillGroups } from '@/features/skill-groups/useUserSkillGroups'; @@ -370,8 +371,8 @@ const AgentsHomeView: React.FC = () => { })), [selectedAgentCoverageSourceBySkillKey, selectedAgentSkillConfigs, t], ); - const selectedAgentRuntimeSkillCount = useMemo( - () => selectedAgentSkillConfigs.filter((skill) => skill.selectedForRuntime).length, + const selectedAgentRuntimeSkillKeys = useMemo( + () => new Set(selectedAgentSkillConfigs.filter((skill) => skill.selectedForRuntime).map((skill) => skill.key)), [selectedAgentSkillConfigs], ); const selectedAgentProfileMemberNames = useMemo(() => { @@ -496,9 +497,7 @@ const AgentsHomeView: React.FC = () => { if (selectedAgentHasSkillTool && selectedAgentSkillConfigs.length > 0) { const currentSkillCount = skillsEditing ? (pendingSkills ?? selectedAgentSkills).length - : isPrimaryAgent(selectedAgent) - ? selectedAgentRuntimeSkillCount - : selectedAgentSkills.length; + : selectedAgentSkills.length; tabs.push({ key: 'skills', label: t('agentsOverview.skills'), @@ -531,7 +530,6 @@ const AgentsHomeView: React.FC = () => { selectedAgentManageableSubagents.length, selectedAgentSkillConfigs.length, selectedAgentSkills, - selectedAgentRuntimeSkillCount, selectedAgentTools, skillsEditing, subagentsEditing, @@ -1136,11 +1134,19 @@ const AgentsHomeView: React.FC = () => { testId="agent-detail-tool-groups" /> ) : ( - + <> + + {t('agentsOverview.toolGroups.enabledCount', { count: selectedAgentTools.length })} + } + /> + + ) ) : null} @@ -1175,6 +1181,7 @@ const AgentsHomeView: React.FC = () => { ) @@ -1188,72 +1195,93 @@ const AgentsHomeView: React.FC = () => { {t('agentsOverview.noSubagents')} ) : subagentsEditing ? ( -
- {selectedAgentManageableSubagents.map((subagent: SubagentInfo) => { - const isOn = (pendingSubagentIds ?? selectedAgentEnabledSubagentIds).includes(subagent.id); - const isExternal = !isLocallyManageableSubagent(subagent); - const tooltipFields = subagentTooltipFields(subagent, t, isExternal); - return ( - - - - - - ); - })} -
- ) : ( -
- {selectedAgentEnabledSubagents.length === 0 ? ( - - {t('agentsOverview.noSubagents')} - - ) : ( - selectedAgentEnabledSubagents.map((subagent: SubagentInfo) => { - const tooltipFields = subagentTooltipFields( - subagent, - t, - !isLocallyManageableSubagent(subagent), - ); - return ( - - {subagent.name} + /> ); - }) - )} -
+ })} +
+ + ) : ( + <> + + {t('agentsOverview.subagentsEnabledCount', { count: selectedAgentEnabledSubagents.length })} + } + /> +
+ {selectedAgentEnabledSubagents.length === 0 ? ( + + {t('agentsOverview.noEnabledSubagents')} + + ) : ( + selectedAgentEnabledSubagents.map((subagent: SubagentInfo) => { + const tooltipFields = subagentTooltipFields( + subagent, + t, + !isLocallyManageableSubagent(subagent), + ); + return ( + + } + aria-label={`${subagent.name}: ${t('agentsOverview.capabilityEnabled')}`} + > + {subagent.name} + + + ); + }) + )} +
+ ) ) : null} diff --git a/src/web-ui/src/app/scenes/agents/components/AgentCapabilityOption.appearance.ts b/src/web-ui/src/app/scenes/agents/components/AgentCapabilityOption.appearance.ts new file mode 100644 index 0000000000..43fd848cd6 --- /dev/null +++ b/src/web-ui/src/app/scenes/agents/components/AgentCapabilityOption.appearance.ts @@ -0,0 +1,9 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance/types'; + +export const agentCapabilityOptionAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'agent-capability-option', + parts: [{ id: 'root' }], + states: [ + { id: 'selected', selector: { kind: 'self', suffix: '[data-openbitfun-state~="selected"]' } }, + ], +}; diff --git a/src/web-ui/src/app/scenes/agents/components/AgentCapabilityOption.scss b/src/web-ui/src/app/scenes/agents/components/AgentCapabilityOption.scss new file mode 100644 index 0000000000..3d0722ab2d --- /dev/null +++ b/src/web-ui/src/app/scenes/agents/components/AgentCapabilityOption.scss @@ -0,0 +1,13 @@ +// Checkbox owns selection, focus and disabled paint; the product owns spacing. +.agent-capability-option { + display: inline-flex; + min-inline-size: 0; + max-inline-size: 100%; + + &__control { + align-items: center; + inline-size: 100%; + min-block-size: var(--openbitfun-control-height-sm); + padding: var(--openbitfun-space-2); + } +} diff --git a/src/web-ui/src/app/scenes/agents/components/AgentCapabilityOption.tsx b/src/web-ui/src/app/scenes/agents/components/AgentCapabilityOption.tsx new file mode 100644 index 0000000000..c0962c9aae --- /dev/null +++ b/src/web-ui/src/app/scenes/agents/components/AgentCapabilityOption.tsx @@ -0,0 +1,47 @@ +import { Checkbox, OverflowText } from '@openbitfun/ui'; +import { forwardRef, type HTMLAttributes } from 'react'; +import './AgentCapabilityOption.scss'; + +interface AgentCapabilityOptionProps extends Omit, 'children' | 'onChange'> { + checked: boolean; + disabled?: boolean; + label: string; + inputAriaLabel?: string; + onCheckedChange: (checked: boolean) => void; +} + +/** Capability selection shares one native checkbox interaction across Agent editors. */ +export const AgentCapabilityOption = forwardRef( + function AgentCapabilityOption({ + checked, + disabled, + label, + inputAriaLabel, + 'aria-describedby': describedBy, + onCheckedChange, + className, + ...props + }, ref) { + return ( + + {label}} + /> + + ); + }, +); diff --git a/src/web-ui/src/app/scenes/agents/components/AgentCapabilityTooltip.tsx b/src/web-ui/src/app/scenes/agents/components/AgentCapabilityTooltip.tsx index 901981e078..9f23959390 100644 --- a/src/web-ui/src/app/scenes/agents/components/AgentCapabilityTooltip.tsx +++ b/src/web-ui/src/app/scenes/agents/components/AgentCapabilityTooltip.tsx @@ -52,6 +52,7 @@ export const AgentCapabilityTooltip: React.FC = ({
)} placement={placement} + trigger="hover-focus" className="agent-capability-tooltip" interactive > diff --git a/src/web-ui/src/app/scenes/agents/components/AgentDetailDialog.scss b/src/web-ui/src/app/scenes/agents/components/AgentDetailDialog.scss index 5f13db0a13..4358ba2072 100644 --- a/src/web-ui/src/app/scenes/agents/components/AgentDetailDialog.scss +++ b/src/web-ui/src/app/scenes/agents/components/AgentDetailDialog.scss @@ -132,20 +132,20 @@ color: var(--openbitfun-color-content-muted); } - &__chip-grid, - &__token-grid { + &__chip-grid { display: flex; flex-wrap: wrap; gap: var(--openbitfun-space-2); } - &__tooltip-trigger { - display: inline-flex; - max-width: 100%; + &__token-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 200px), 1fr)); + gap: var(--openbitfun-space-2); } &__token { - max-width: min(240px, 100%); + max-width: 100%; } &__model-select { diff --git a/src/web-ui/src/app/scenes/agents/components/SkillGroupPicker.scss b/src/web-ui/src/app/scenes/agents/components/SkillGroupPicker.scss index 9490209413..2c26bb0d5a 100644 --- a/src/web-ui/src/app/scenes/agents/components/SkillGroupPicker.scss +++ b/src/web-ui/src/app/scenes/agents/components/SkillGroupPicker.scss @@ -2,6 +2,31 @@ @include capability.capability-group-layout('skill'); +.skill-group-summary { + &__count { + color: var(--openbitfun-color-content-muted); + font-size: var(--openbitfun-type-meta-font-size); + line-height: var(--openbitfun-type-meta-line-height); + font-variant-numeric: tabular-nums; + } + + &__skills { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 200px), 1fr)); + gap: var(--openbitfun-space-3); + } + + &__item { + display: flex; + min-inline-size: 0; + align-items: flex-start; + flex-direction: column; + gap: var(--openbitfun-space-1); + font-size: var(--openbitfun-type-label-md-font-size); + line-height: var(--openbitfun-type-label-md-line-height); + } +} + .skill-group-picker__unavailable { color: var(--openbitfun-color-content-muted); font-size: var(--openbitfun-type-meta-font-size); diff --git a/src/web-ui/src/app/scenes/agents/components/SkillGroupPicker.tsx b/src/web-ui/src/app/scenes/agents/components/SkillGroupPicker.tsx index ffc9b0bb5d..17a2f2c741 100644 --- a/src/web-ui/src/app/scenes/agents/components/SkillGroupPicker.tsx +++ b/src/web-ui/src/app/scenes/agents/components/SkillGroupPicker.tsx @@ -1,13 +1,14 @@ -import { Button, Checkbox, FieldGroup, FieldRow, FormSection, OverflowText, StatusPill, Toolbar } from '@openbitfun/ui'; +import { Button, Checkbox, FieldGroup, FieldRow, FormSection, Icon, OverflowText, StatusPill, Toolbar } from '@openbitfun/ui'; import React, { useMemo } from 'react'; import { useI18n, type UseI18nReturn } from '@/infrastructure/i18n/hooks/useI18n'; import type { UserSkillGroup } from '@/infrastructure/config/types'; import { type GroupableSkill, type ResolvedSkillGroup, builtinSkillGroupLabelKey, resolveSkillGroupSummary, resolveSkillSelectionGroups, setSkillGroupSelection, - skillGroupKeys, toggleSkillSelection, + skillGroupKeys, } from '@/features/skill-groups/skillGroups'; import { AgentCapabilityTooltip, type AgentCapabilityTooltipField } from './AgentCapabilityTooltip'; +import { AgentCapabilityOption } from './AgentCapabilityOption'; import { capabilityTooltipAriaLabel } from './agentCapabilityTooltipUtils'; import './SkillGroupPicker.scss'; @@ -23,6 +24,7 @@ interface SkillGroupPickerProps { interface SkillGroupSummaryProps { skills: GroupableSkill[]; selectedSkillKeys: readonly string[]; + runtimeSkillKeys?: ReadonlySet; userGroups: UserSkillGroup[]; } @@ -71,6 +73,7 @@ function skillDisplayName(skill: GroupableSkill, duplicateNames: Set): s function skillTooltipFields( skill: GroupableSkill, t: UseI18nReturn['t'], + editing = false, ): AgentCapabilityTooltipField[] { const source = [skill.sourceLabel ?? skill.sourceSlot, skill.level].filter(Boolean).join('/'); return [ @@ -85,7 +88,7 @@ function skillTooltipFields( monospace: true, }] : []), ...(skill.runtimeStatus ? [{ - label: t('agentsOverview.capabilityTooltip.status'), + label: editing ? t('agentsOverview.capabilityTooltip.savedStatus') : t('agentsOverview.capabilityTooltip.status'), value: skill.runtimeStatus, }] : skill.isShadowed ? [{ label: t('agentsOverview.capabilityTooltip.status'), @@ -128,6 +131,7 @@ export const SkillGroupPicker: React.FC = ({ data-openbitfun-product-part="head" leading={ {t('agentsOverview.skillGroupPicker.selectedCount', { count: selectedCount })} + {' · '}{t('agentsOverview.selectionSaveHint')} } />
@@ -167,6 +171,7 @@ export const SkillGroupPicker: React.FC = ({ ) : null} 0 && !allSelected} checked={allSelected} onCheckedChange={(checked) => onSelectionChange( @@ -188,7 +193,7 @@ export const SkillGroupPicker: React.FC = ({
{group.skills.map((skill) => { const selected = selectedSkillKeys.includes(skill.key); - const tooltipFields = skillTooltipFields(skill, t); + const tooltipFields = skillTooltipFields(skill, t, true); return ( = ({ fields={tooltipFields} placement="top" > - + /> ); })} @@ -236,6 +237,7 @@ export const SkillGroupPicker: React.FC = ({ export const SkillGroupSummary: React.FC = ({ skills, selectedSkillKeys, + runtimeSkillKeys, userGroups, }) => { const { t } = useI18n('scenes/agents'); @@ -251,6 +253,15 @@ export const SkillGroupSummary: React.FC = ({ return (
+ + {t('agentsOverview.skillGroupPicker.enabledCount', { count: new Set(selectedSkillKeys).size })} + } + trailing={runtimeSkillKeys ? + {t('agentsOverview.skillGroupPicker.runtimeCount', { count: runtimeSkillKeys.size })} + : undefined} + /> {groups.map((group) => ( @@ -264,9 +275,15 @@ export const SkillGroupSummary: React.FC = ({ description={skill.description} fields={tooltipFields} > - - {skillDisplayName(skill, duplicateNames)} - + + {skillDisplayName(skill, duplicateNames)} + : undefined} + > + {skill.runtimeStatus ?? t('agentsOverview.capabilityEnabled')} + + ); })} diff --git a/src/web-ui/src/app/scenes/agents/components/ToolGroupPicker.scss b/src/web-ui/src/app/scenes/agents/components/ToolGroupPicker.scss index 810c74b889..6815dd66d5 100644 --- a/src/web-ui/src/app/scenes/agents/components/ToolGroupPicker.scss +++ b/src/web-ui/src/app/scenes/agents/components/ToolGroupPicker.scss @@ -54,8 +54,8 @@ } &__token-grid { - display: flex; - flex-wrap: wrap; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 200px), 1fr)); gap: var(--openbitfun-space-2); } diff --git a/src/web-ui/src/app/scenes/agents/components/ToolGroupPicker.tsx b/src/web-ui/src/app/scenes/agents/components/ToolGroupPicker.tsx index 90d63f6e63..b6afb62ffb 100644 --- a/src/web-ui/src/app/scenes/agents/components/ToolGroupPicker.tsx +++ b/src/web-ui/src/app/scenes/agents/components/ToolGroupPicker.tsx @@ -34,7 +34,6 @@ import { resolveToolGroupSummary, resolveToolGroups, setToolGroupSelection, - toggleToolSelection, unavailableUserToolNames, } from './toolGroups'; import { @@ -42,6 +41,7 @@ import { type AgentCapabilityTooltipField, } from './AgentCapabilityTooltip'; import { capabilityTooltipAriaLabel } from './agentCapabilityTooltipUtils'; +import { AgentCapabilityOption } from './AgentCapabilityOption'; import './ToolGroupPicker.scss'; interface ToolGroupPickerProps { @@ -178,13 +178,13 @@ const GroupManagerModal: React.FC = ({ setToolNames(new Set(group.toolNames)); }; - const toggleTool = (toolName: string) => { + const setToolSelected = (toolName: string, checked: boolean) => { setToolNames((current) => { const next = new Set(current); - if (next.has(toolName)) { - next.delete(toolName); - } else { + if (checked) { next.add(toolName); + } else { + next.delete(toolName); } return next; }); @@ -318,19 +318,16 @@ const GroupManagerModal: React.FC = ({ titleMonospace placement="top" > - + inputAriaLabel={capabilityTooltipAriaLabel(tool.name, tool.description, tooltipFields)} + /> ); })} @@ -466,6 +463,7 @@ export const ToolGroupPicker: React.FC = ({ leading={( {t('agentsOverview.toolGroups.selectedCount', { count: selectedCount })} + {' · '}{t('agentsOverview.selectionSaveHint')} )} trailing={( @@ -516,6 +514,7 @@ export const ToolGroupPicker: React.FC = ({ ) : null} 0 && !allSelected} onCheckedChange={(checked) => onSelectionChange( @@ -543,19 +542,18 @@ export const ToolGroupPicker: React.FC = ({ titleMonospace placement="top" > - + inputAriaLabel={capabilityTooltipAriaLabel(tool.name, tool.description, tooltipFields)} + /> ); })} @@ -608,7 +606,11 @@ export const ToolGroupSummary: React.FC = ({ fields={tooltipFields} titleMonospace > - + } + > {tool.name.replace(/_/g, ' ')} diff --git a/src/web-ui/src/app/scenes/agents/components/_AgentCapabilityGroups.scss b/src/web-ui/src/app/scenes/agents/components/_AgentCapabilityGroups.scss index 92b361f6dc..8821eadaf3 100644 --- a/src/web-ui/src/app/scenes/agents/components/_AgentCapabilityGroups.scss +++ b/src/web-ui/src/app/scenes/agents/components/_AgentCapabilityGroups.scss @@ -47,6 +47,9 @@ font-size: var(--openbitfun-type-meta-font-size); line-height: var(--openbitfun-type-meta-line-height); font-variant-numeric: tabular-nums; + } + + &__group-count { white-space: nowrap; } @@ -55,8 +58,9 @@ } &__token-grid { - display: flex; - flex-wrap: wrap; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 200px), 1fr)); + inline-size: 100%; gap: var(--openbitfun-space-2); } diff --git a/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts b/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts index 6b3fc5ba31..57cc71b9f0 100644 --- a/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts +++ b/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts @@ -197,6 +197,7 @@ import { miniAppDetailModalAppearanceDescriptor } from '@/app/scenes/miniapps/co import { agentCardAppearanceDescriptor } from '@/app/scenes/agents/components/AgentCard.appearance'; import { coreAgentCardAppearanceDescriptor } from '@/app/scenes/agents/components/CoreAgentCard.appearance'; import { agentCapabilityTooltipAppearanceDescriptor } from '@/app/scenes/agents/components/AgentCapabilityTooltip.appearance'; +import { agentCapabilityOptionAppearanceDescriptor } from '@/app/scenes/agents/components/AgentCapabilityOption.appearance'; import { gitNavAppearanceDescriptor } from '@/app/scenes/git/GitNav.appearance'; import { fileViewerNavAppearanceDescriptor } from '@/app/scenes/file-viewer/FileViewerNav.appearance'; import { assistantQuickInputAppearanceDescriptor } from '@/app/scenes/profile/views/AssistantQuickInput.appearance'; @@ -333,6 +334,7 @@ export function createDefaultAppearanceRegistry(): AppearanceRegistry { .registerComponent(agentCardAppearanceDescriptor) .registerComponent(coreAgentCardAppearanceDescriptor) .registerComponent(agentCapabilityTooltipAppearanceDescriptor) + .registerComponent(agentCapabilityOptionAppearanceDescriptor) .registerComponent(gitNavAppearanceDescriptor) .registerComponent(fileViewerNavAppearanceDescriptor) .registerComponent(assistantQuickInputAppearanceDescriptor) diff --git a/src/web-ui/src/infrastructure/config/components/ModelSettingsPage.tsx b/src/web-ui/src/infrastructure/config/components/ModelSettingsPage.tsx index 075707699f..c2ece50efc 100644 --- a/src/web-ui/src/infrastructure/config/components/ModelSettingsPage.tsx +++ b/src/web-ui/src/infrastructure/config/components/ModelSettingsPage.tsx @@ -2787,6 +2787,7 @@ const ModelSettingsPage: React.FC = () => {