From cf22e3a9a9f402dbe83f9c3fbdc70072b8debb3c Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Tue, 15 Sep 2026 17:11:11 +0800 Subject: [PATCH] fix(desktop): unify sidebar frosted glass and sticky header surfaces --- src/apps/desktop/AGENTS.md | 3 ++ src/apps/desktop/src/appearance.rs | 25 ++++++++- src/web-ui/src/app/appearance.ts | 1 + .../src/app/components/NavPanel/MainNav.tsx | 5 +- .../src/app/components/NavPanel/NavPanel.scss | 4 +- .../components/StickySectionHeader.test.tsx | 45 ++++++++++++++++ .../components/StickySectionHeader.tsx | 29 +++++++++++ .../NavPanel/unifiedSessionCreation.test.ts | 2 +- .../app/global-search/GlobalSearchRoot.scss | 5 +- src/web-ui/src/app/layout/AppLayout.scss | 11 +++- src/web-ui/src/app/layout/WorkspaceBody.scss | 51 ++++++++++++++++++- src/web-ui/src/app/layout/WorkspaceBody.tsx | 8 +++ .../app/styles/_workspace-shell-surfaces.scss | 49 ++++++++++++++++++ .../ThemeTokenAppearanceAdapter.test.ts | 33 ++++++++++++ .../adapters/ThemeTokenAppearanceAdapter.ts | 6 ++- 15 files changed, 264 insertions(+), 13 deletions(-) create mode 100644 src/web-ui/src/infrastructure/appearance/adapters/ThemeTokenAppearanceAdapter.test.ts diff --git a/src/apps/desktop/AGENTS.md b/src/apps/desktop/AGENTS.md index c4c2884a20..0de3bb0a70 100644 --- a/src/apps/desktop/AGENTS.md +++ b/src/apps/desktop/AGENTS.md @@ -136,6 +136,9 @@ For window geometry recovery, legacy state compatibility, and snapshot persisten run `cargo test -p openbitfun-desktop --lib window_state_support::tests`. For the matching startup wiring contract, run `pnpm --dir src/web-ui run test:run src/app/startup/startupPerformanceContract.test.ts`. +For native sidebar material and appearance bootstrap, run +`cargo test -p openbitfun-desktop --no-default-features --lib appearance::startup_appearance_tests` +and `pnpm --dir src/web-ui run test:run src/infrastructure/appearance/adapters/ThemeTokenAppearanceAdapter.test.ts`. For embedded browser preview encoding and target correlation, run `cargo test -p openbitfun-desktop --lib api::browser_api::tests`. After browser command registration changes, also run diff --git a/src/apps/desktop/src/appearance.rs b/src/apps/desktop/src/appearance.rs index dae4b04cd5..c501d03a79 100644 --- a/src/apps/desktop/src/appearance.rs +++ b/src/apps/desktop/src/appearance.rs @@ -318,6 +318,7 @@ impl AppearanceConfig { let startup_locale_json = serde_json::to_string(&startup_locale).unwrap_or_else(|_| "\"zh-CN\"".to_string()); let show_startup_window_controls = !cfg!(target_os = "macos"); + let native_sidebar_material = cfg!(any(target_os = "windows", target_os = "macos")); let startup_trace_id_json = serde_json::to_string(startup_trace_id) .unwrap_or_else(|_| "\"desktop-unknown\"".to_string()); let bootstrap_log_level_json = serde_json::to_string(crate::logging::level_to_str( @@ -370,6 +371,9 @@ impl AppearanceConfig { root.setAttribute('data-color-scheme', '{appearance_mode}'); root.setAttribute('data-contrast', 'standard'); root.setAttribute('data-density', 'compact'); + if ({native_sidebar_material}) {{ + root.setAttribute('data-openbitfun-native-material', 'sidebar'); + }} root.style.setProperty('--openbitfun-color-surface-canvas', '{bg_primary}'); root.style.setProperty('--openbitfun-color-surface-panel', '{bg_secondary}'); @@ -380,10 +384,10 @@ impl AppearanceConfig { root.style.setProperty('--openbitfun-color-content-primary', '{text_primary}'); root.style.setProperty('--openbitfun-color-content-muted', '{text_muted}'); root.style.setProperty('--openbitfun-color-accent-default', '{accent_color}'); - root.style.backgroundColor = '{bg_primary}'; + root.style.backgroundColor = {native_sidebar_material} ? 'transparent' : '{bg_primary}'; if (document.body) {{ - document.body.style.backgroundColor = '{bg_primary}'; + document.body.style.backgroundColor = {native_sidebar_material} ? 'transparent' : '{bg_primary}'; }} return true; @@ -609,6 +613,23 @@ pub fn create_main_window( } }); + // The webview must be transparent for the OS material to reach the sidebar. + // Opaque scene and startup surfaces remain owned by the frontend. + #[cfg(any(target_os = "windows", target_os = "macos"))] + { + builder = builder + .transparent(true) + .background_color(tauri::window::Color(0, 0, 0, 0)) + .effects( + tauri::window::EffectsBuilder::new() + .effects([ + tauri::window::Effect::Acrylic, + tauri::window::Effect::Sidebar, + ]) + .build(), + ); + } + #[cfg(debug_assertions)] if !use_development_frontend() { // Product-path isolation alone does not isolate WKWebView storage. diff --git a/src/web-ui/src/app/appearance.ts b/src/web-ui/src/app/appearance.ts index cae2bdde55..a5658a9d45 100644 --- a/src/web-ui/src/app/appearance.ts +++ b/src/web-ui/src/app/appearance.ts @@ -6,6 +6,7 @@ export const workbenchAppearanceDescriptor: AppearanceSurfaceDescriptor = { { id: 'workspace' }, { id: 'collapsedNav' }, { id: 'navArea' }, + { id: 'workspaceMaterial', propertyProfile: 'paint', visualRole: 'decoration' }, { id: 'navDivider' }, { id: 'sceneArea' }, { id: 'sceneSurface' }, diff --git a/src/web-ui/src/app/components/NavPanel/MainNav.tsx b/src/web-ui/src/app/components/NavPanel/MainNav.tsx index be20ecd006..0c2f1cd18f 100644 --- a/src/web-ui/src/app/components/NavPanel/MainNav.tsx +++ b/src/web-ui/src/app/components/NavPanel/MainNav.tsx @@ -110,6 +110,7 @@ const MainNav: React.FC = ({ const workspaceMenuButtonRef = useRef(null); const workspaceMenuRef = useRef(null); const sectionsScrollRef = useRef(null); + const sessionContentRef = useRef(null); const [workspaceMenuOpen, setWorkspaceMenuOpen] = useState(false); const [workspaceMenuClosing, setWorkspaceMenuClosing] = useState(false); const [workspaceMenuPos, setWorkspaceMenuPos] = useState({ top: 0, left: 0 }); @@ -580,7 +581,7 @@ const MainNav: React.FC = ({ {/* Unified sessions */}
- + = ({ } /> -
+
diff --git a/src/web-ui/src/app/components/NavPanel/NavPanel.scss b/src/web-ui/src/app/components/NavPanel/NavPanel.scss index 64ec1f6f46..11fdb9b257 100644 --- a/src/web-ui/src/app/components/NavPanel/NavPanel.scss +++ b/src/web-ui/src/app/components/NavPanel/NavPanel.scss @@ -2236,9 +2236,9 @@ $_section-header-height: 22px; &::after { content: ''; position: absolute; - left: 0; + left: var(--openbitfun-space-2); + right: var(--openbitfun-space-2); bottom: 0; - width: 100vw; border-bottom: 1px solid; @include shell-surfaces.sidebar-hairline-color; pointer-events: none; diff --git a/src/web-ui/src/app/components/NavPanel/components/StickySectionHeader.test.tsx b/src/web-ui/src/app/components/NavPanel/components/StickySectionHeader.test.tsx index 11e7ac7fb1..e79a82d6f6 100644 --- a/src/web-ui/src/app/components/NavPanel/components/StickySectionHeader.test.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/StickySectionHeader.test.tsx @@ -105,4 +105,49 @@ describe('StickySectionHeader', () => { expect(disconnectSpy).toHaveBeenCalledOnce(); root = createRoot(container); }); + + it('clips covered rows on scroll and resize, then restores them on return and unmount', () => { + let onResize: ResizeObserverCallback; + const disconnectResize = vi.fn(); + vi.stubGlobal('ResizeObserver', class { + constructor(callback: ResizeObserverCallback) { onResize = callback; } + observe = vi.fn(); + disconnect = disconnectResize; + }); + const scrollRootRef = createRef(); + scrollRootRef.current = document.createElement('div'); + const contentRef = createRef(); + const content = document.createElement('div'); + contentRef.current = content; + let contentTop = 100; + vi.spyOn(content, 'getBoundingClientRect').mockImplementation(() => new DOMRect(0, contentTop, 300, 1000)); + + act(() => root.render( + Sessions, + )); + const header = container.querySelector('[data-testid="nav-sessions-sticky-header"]')!; + vi.spyOn(header, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 300, 22)); + expect(content.style.clipPath).toBe(''); + + contentTop = -20; + act(() => scrollRootRef.current!.dispatchEvent(new Event('scroll'))); + expect(content.style.clipPath).toBe('inset(42px 0 0)'); + + contentTop = -40; + act(() => onResize!([], {} as ResizeObserver)); + expect(content.style.clipPath).toBe('inset(62px 0 0)'); + + contentTop = 100; + act(() => scrollRootRef.current!.dispatchEvent(new Event('scroll'))); + expect(content.style.clipPath).toBe(''); + + contentTop = -20; + act(() => scrollRootRef.current!.dispatchEvent(new Event('scroll'))); + act(() => root.unmount()); + expect(content.style.clipPath).toBe(''); + expect(disconnectResize).toHaveBeenCalledOnce(); + act(() => scrollRootRef.current!.dispatchEvent(new Event('scroll'))); + expect(content.style.clipPath).toBe(''); + root = createRoot(container); + }); }); diff --git a/src/web-ui/src/app/components/NavPanel/components/StickySectionHeader.tsx b/src/web-ui/src/app/components/NavPanel/components/StickySectionHeader.tsx index 1c4fb3915a..d432d4a6a0 100644 --- a/src/web-ui/src/app/components/NavPanel/components/StickySectionHeader.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/StickySectionHeader.tsx @@ -3,20 +3,48 @@ import React, { useEffect, useRef, useState } from 'react'; interface StickySectionHeaderProps { children: React.ReactNode; scrollRootRef: React.RefObject; + contentRef?: React.RefObject; } /** * Keeps one section header in the DOM while exposing whether it has crossed * the top edge of its scroll container. CSS owns the sticky positioning; the * observer state is only for the docked divider and appearance contract. + * Clip covered rows so the header can share the sidebar's transparent material. */ const StickySectionHeader: React.FC = ({ children, scrollRootRef, + contentRef, }) => { const sentinelRef = useRef(null); + const headerRef = useRef(null); const [isStuck, setIsStuck] = useState(false); + useEffect(() => { + const scrollRoot = scrollRootRef.current; + const header = headerRef.current; + const content = contentRef?.current; + if (!scrollRoot || !header || !content) return; + + const originalClipPath = content.style.clipPath; + const updateClip = () => { + const overlap = Math.max(0, header.getBoundingClientRect().bottom - content.getBoundingClientRect().top); + const clipPath = overlap > 0 ? `inset(${overlap}px 0 0)` : originalClipPath; + if (content.style.clipPath !== clipPath) content.style.clipPath = clipPath; + }; + scrollRoot.addEventListener('scroll', updateClip, { passive: true }); + const resizeObserver = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(updateClip) : null; + [scrollRoot, header, content].forEach(element => resizeObserver?.observe(element)); + updateClip(); + + return () => { + scrollRoot.removeEventListener('scroll', updateClip); + resizeObserver?.disconnect(); + content.style.clipPath = originalClipPath; + }; + }, [scrollRootRef, contentRef]); + useEffect(() => { const scrollRoot = scrollRootRef.current; const sentinel = sentinelRef.current; @@ -46,6 +74,7 @@ const StickySectionHeader: React.FC = ({ aria-hidden="true" />
{ expect(contentIndex).toBeGreaterThan(-1); expect(persistentFooterIndex).toBeGreaterThan(contentIndex); expect(mainNav).toContain(''); - expect(mainNav).toContain(''); + expect(mainNav).toContain(''); expect(mainNav).not.toContain('expandedSections'); expect(mainNav).not.toContain('toggleSection'); expect(mainNav).not.toContain('openbitfun-nav-panel__collapsible'); diff --git a/src/web-ui/src/app/global-search/GlobalSearchRoot.scss b/src/web-ui/src/app/global-search/GlobalSearchRoot.scss index 85f5718c65..f08c86bbdf 100644 --- a/src/web-ui/src/app/global-search/GlobalSearchRoot.scss +++ b/src/web-ui/src/app/global-search/GlobalSearchRoot.scss @@ -1,4 +1,5 @@ @use '../styles/nav-panel-font-scope.scss' as nav-font; +@use '../styles/workspace-shell-surfaces' as shell-surfaces; .global-search-dialog { // The results viewport absorbs short windows and longer result sets. @@ -361,7 +362,7 @@ padding: 0 var(--openbitfun-space-2); border: none; border-radius: var(--openbitfun-radius-pill); - background: var(--openbitfun-color-surface-scene); + @include shell-surfaces.sidebar-overlay(var(--openbitfun-color-surface-scene)); box-shadow: inset 0 0 0 1px var(--openbitfun-color-border-subtle); cursor: text; color: var(--openbitfun-color-content-primary); @@ -371,7 +372,7 @@ &:hover, &:active { - background: var(--openbitfun-color-action-quiet-hover); + @include shell-surfaces.sidebar-overlay(var(--openbitfun-color-surface-scene), 28%); box-shadow: inset 0 0 0 1px var(--openbitfun-color-field-border-hover); } diff --git a/src/web-ui/src/app/layout/AppLayout.scss b/src/web-ui/src/app/layout/AppLayout.scss index 5cb818b83f..2ca9b16faa 100644 --- a/src/web-ui/src/app/layout/AppLayout.scss +++ b/src/web-ui/src/app/layout/AppLayout.scss @@ -29,6 +29,11 @@ html, body { overflow: hidden; } +html[data-openbitfun-native-material='sidebar'], +html[data-openbitfun-native-material='sidebar'] body { + background: transparent; +} + // ==================== Main layout container ==================== .openbitfun-app-layout { position: relative; @@ -65,6 +70,10 @@ html, body { background: transparent; } + :root[data-openbitfun-native-material='sidebar'] &:has(.openbitfun-workspace-body) { + background: transparent; + } + * { box-sizing: border-box; } @@ -114,7 +123,7 @@ html, body { width: 100%; margin-top: 0; overflow: hidden; - background: var(--openbitfun-color-surface-scene); + background: transparent; } .openbitfun-app-layout--toolbar-mode > [data-openbitfun-product-component='toolbar-mode'][data-openbitfun-product-part='root'] { diff --git a/src/web-ui/src/app/layout/WorkspaceBody.scss b/src/web-ui/src/app/layout/WorkspaceBody.scss index ba2908f942..01ce6cbdaa 100644 --- a/src/web-ui/src/app/layout/WorkspaceBody.scss +++ b/src/web-ui/src/app/layout/WorkspaceBody.scss @@ -15,6 +15,11 @@ $_resize-target-width: 6px; @include shell-surfaces.sidebar-background; + :root[data-openbitfun-native-material='sidebar'] &, + .openbitfun-app-layout[data-openbitfun-background-media='video'] & { + background: transparent; + } + position: relative; isolation: isolate; display: flex; @@ -29,9 +34,18 @@ $_resize-target-width: 6px; gap: 0; } -.openbitfun-workspace-body__nav-area { - @include shell-surfaces.sidebar-background; +.openbitfun-workspace-body__material { + @include shell-surfaces.sidebar-glass; + + // A single background continues through the scene's exposed rounded corners. + // Keeping its filter off the layout containers preserves fixed menu geometry. + position: absolute; + inset: 0; + z-index: -1; + pointer-events: none; +} +.openbitfun-workspace-body__nav-area { position: relative; z-index: 3; display: flex; @@ -42,6 +56,39 @@ $_resize-target-width: 6px; height: 100%; overflow: visible; + > .openbitfun-nav-bar, + > .openbitfun-nav-panel, + .openbitfun-nav-panel__brand-header, + .openbitfun-nav-panel__sticky-section-header, + .openbitfun-nav-panel__content:not(.is-split-open) .openbitfun-nav-panel__layer--scene, + .openbitfun-nav-panel [data-openbitfun-component='navigation-panel'] { + background: transparent; + } + + // Settings and other scene navigation share the same material as MainNav. + [data-openbitfun-component='search-field'] > [data-openbitfun-component='input'] { + @include shell-surfaces.sidebar-overlay(var(--openbitfun-color-field-background)); + + &:hover, + &:focus-within { + @include shell-surfaces.sidebar-overlay(var(--openbitfun-color-field-background-hover), 28%); + } + } + + // Keep the gear on the shared material, including IconButton's ::before + // surface. Icon color and the keyboard focus ring provide interaction feedback. + .openbitfun-nav-panel__footer-btn--icon { + &, + &:is(:hover, :active, .is-active, .is-hover-open) { + background: transparent; + } + + &::before, + &:is(:hover, :active, .is-active, .is-hover-open)::before { + background: transparent; + } + } + &.is-collapsed { flex-basis: 0; width: 0; diff --git a/src/web-ui/src/app/layout/WorkspaceBody.tsx b/src/web-ui/src/app/layout/WorkspaceBody.tsx index 01e1b44466..b355c216fe 100644 --- a/src/web-ui/src/app/layout/WorkspaceBody.tsx +++ b/src/web-ui/src/app/layout/WorkspaceBody.tsx @@ -140,6 +140,13 @@ const WorkspaceBody: React.FC = ({ data-openbitfun-part="workspace" data-openbitfun-state={isNavCollapsed ? 'collapsed' : undefined} > +