Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/apps/desktop/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 23 additions & 2 deletions src/apps/desktop/src/appearance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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}');
Expand All @@ -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;
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions src/web-ui/src/app/appearance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
5 changes: 3 additions & 2 deletions src/web-ui/src/app/components/NavPanel/MainNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ const MainNav: React.FC<MainNavProps> = ({
const workspaceMenuButtonRef = useRef<HTMLButtonElement | null>(null);
const workspaceMenuRef = useRef<HTMLDivElement | null>(null);
const sectionsScrollRef = useRef<HTMLDivElement | null>(null);
const sessionContentRef = useRef<HTMLDivElement | null>(null);
const [workspaceMenuOpen, setWorkspaceMenuOpen] = useState(false);
const [workspaceMenuClosing, setWorkspaceMenuClosing] = useState(false);
const [workspaceMenuPos, setWorkspaceMenuPos] = useState({ top: 0, left: 0 });
Expand Down Expand Up @@ -580,7 +581,7 @@ const MainNav: React.FC<MainNavProps> = ({

{/* Unified sessions */}
<div className="openbitfun-nav-panel__section" data-openbitfun-component="nav-panel" data-openbitfun-part="section" data-openbitfun-section="sessions">
<StickySectionHeader scrollRootRef={sectionsScrollRef}>
<StickySectionHeader scrollRootRef={sectionsScrollRef} contentRef={sessionContentRef}>
<SectionHeader
label={t('nav.items.sessions')}
actions={
Expand All @@ -607,7 +608,7 @@ const MainNav: React.FC<MainNavProps> = ({
}
/>
</StickySectionHeader>
<div className="openbitfun-nav-panel__items" data-openbitfun-component="nav-panel" data-openbitfun-part="sectionContent">
<div ref={sessionContentRef} className="openbitfun-nav-panel__items" data-openbitfun-component="nav-panel" data-openbitfun-part="sectionContent">
<WorkspaceListSection variant="all" />
</div>
</div>
Expand Down
4 changes: 2 additions & 2 deletions src/web-ui/src/app/components/NavPanel/NavPanel.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement>();
scrollRootRef.current = document.createElement('div');
const contentRef = createRef<HTMLDivElement>();
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(
<StickySectionHeader scrollRootRef={scrollRootRef} contentRef={contentRef}>Sessions</StickySectionHeader>,
));
const header = container.querySelector<HTMLElement>('[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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,48 @@ import React, { useEffect, useRef, useState } from 'react';
interface StickySectionHeaderProps {
children: React.ReactNode;
scrollRootRef: React.RefObject<HTMLDivElement | null>;
contentRef?: React.RefObject<HTMLDivElement | null>;
}

/**
* 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<StickySectionHeaderProps> = ({
children,
scrollRootRef,
contentRef,
}) => {
const sentinelRef = useRef<HTMLSpanElement | null>(null);
const headerRef = useRef<HTMLDivElement | null>(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;
Expand Down Expand Up @@ -46,6 +74,7 @@ const StickySectionHeader: React.FC<StickySectionHeaderProps> = ({
aria-hidden="true"
/>
<div
ref={headerRef}
className={`openbitfun-nav-panel__sticky-section-header${isStuck ? ' is-stuck' : ''}`}
data-openbitfun-component="nav-panel"
data-openbitfun-part="stickySectionHeader"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ describe('unified project session creation', () => {
expect(contentIndex).toBeGreaterThan(-1);
expect(persistentFooterIndex).toBeGreaterThan(contentIndex);
expect(mainNav).toContain('<NavigationPanelBody className="openbitfun-nav-panel__sections" ref={sectionsScrollRef}>');
expect(mainNav).toContain('<StickySectionHeader scrollRootRef={sectionsScrollRef}>');
expect(mainNav).toContain('<StickySectionHeader scrollRootRef={sectionsScrollRef} contentRef={sessionContentRef}>');
expect(mainNav).not.toContain('expandedSections');
expect(mainNav).not.toContain('toggleSection');
expect(mainNav).not.toContain('openbitfun-nav-panel__collapsible');
Expand Down
5 changes: 3 additions & 2 deletions src/web-ui/src/app/global-search/GlobalSearchRoot.scss
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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);
Expand All @@ -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);
}

Expand Down
11 changes: 10 additions & 1 deletion src/web-ui/src/app/layout/AppLayout.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -65,6 +70,10 @@ html, body {
background: transparent;
}

:root[data-openbitfun-native-material='sidebar'] &:has(.openbitfun-workspace-body) {
background: transparent;
}

* {
box-sizing: border-box;
}
Expand Down Expand Up @@ -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'] {
Expand Down
51 changes: 49 additions & 2 deletions src/web-ui/src/app/layout/WorkspaceBody.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down
8 changes: 8 additions & 0 deletions src/web-ui/src/app/layout/WorkspaceBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,13 @@ const WorkspaceBody: React.FC<WorkspaceBodyProps> = ({
data-openbitfun-part="workspace"
data-openbitfun-state={isNavCollapsed ? 'collapsed' : undefined}
>
<div
className="openbitfun-workspace-body__material"
data-openbitfun-scene="workbench"
data-openbitfun-part="workspaceMaterial"
data-openbitfun-theme-scope="chrome"
aria-hidden="true"
/>
{isNavCollapsed && (
<div className="openbitfun-workspace-body__collapsed-nav" data-openbitfun-scene="workbench" data-openbitfun-part="collapsedNav">
<NavBar isCollapsed onExpandNav={toggleLeftPanel} onMaximize={onMaximize} />
Expand All @@ -153,6 +160,7 @@ const WorkspaceBody: React.FC<WorkspaceBodyProps> = ({
style={isNavCollapsed ? undefined : { '--nav-width': `${navWidth}px` } as React.CSSProperties}
data-openbitfun-scene="workbench"
data-openbitfun-part="navArea"
data-openbitfun-theme-scope="chrome"
data-openbitfun-state={isNavCollapsed ? 'collapsed' : undefined}
>
<NavBar onExpandNav={toggleLeftPanel} onMaximize={onMaximize} />
Expand Down
Loading
Loading