Skip to content

Commit 58326ee

Browse files
committed
fix(ui): smooth transitions and align settings layout
1 parent 4714e0c commit 58326ee

24 files changed

Lines changed: 1031 additions & 159 deletions

src/web-ui/src/app/components/NavPanel/NavPanel.scss

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,6 @@
1111
@use '../../../component-library/styles/btn-primary-tokens.scss' as btn-primary;
1212

1313
$_nav-width: 240px;
14-
// Scene-inner fade: used when switching between different SceneNav components
15-
// while already in scene-nav mode (e.g. file-viewer → settings).
16-
@keyframes bitfun-nav-panel-scene-inner-in {
17-
from { opacity: 0; }
18-
to { opacity: 1; }
19-
}
2014

2115
$_item-height: 32px;
2216
$_section-header-height: 24px;
@@ -80,11 +74,8 @@ $_section-header-height: 24px;
8074
// ── SceneNav overlay ──
8175
&--scene {
8276
clip-path: inset(var(--clip-origin-top) 0 var(--clip-origin-bottom) 0);
83-
// Wrapper re-keyed on sceneId change → plays the fade-in when the
84-
// SceneNav content is swapped while the accordion row stays expanded.
8577
transition: clip-path $motion-base $easing-decelerate,
8678
opacity $motion-fast $easing-decelerate;
87-
animation: bitfun-nav-panel-scene-inner-in $motion-fast $easing-decelerate;
8879
position: absolute;
8980
inset: 0;
9081
z-index: 1;

src/web-ui/src/app/components/NavPanel/NavPanel.tsx

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,17 @@
1414
* MainNav is always mounted so its state is preserved across transitions.
1515
*/
1616

17-
import React, { Suspense, useState, useEffect, useRef, useCallback } from 'react';
17+
import React, {
18+
Suspense,
19+
startTransition,
20+
useState,
21+
useEffect,
22+
useRef,
23+
useCallback,
24+
} from 'react';
1825
import { useI18n } from '@/infrastructure/i18n';
1926
import { useNavSceneStore } from '../../stores/navSceneStore';
20-
import { getSceneNav } from '../../scenes/nav-registry';
27+
import { getSceneNav, preloadSceneNav } from '../../scenes/nav-registry';
2128
import type { SceneTabId } from '../SceneBar/types';
2229
import MainNav from './MainNav';
2330
import PersistentFooterActions from './components/PersistentFooterActions';
@@ -40,13 +47,28 @@ const NavPanel: React.FC<NavPanelProps> = ({ className = '' }) => {
4047
const navSceneId = useNavSceneStore(s => s.navSceneId);
4148

4249
const [mountedSceneId, setMountedSceneId] = useState<SceneTabId | null>(navSceneId);
50+
const sceneRequestRef = useRef(0);
4351
useEffect(() => {
44-
if (navSceneId) setMountedSceneId(navSceneId);
52+
const requestId = ++sceneRequestRef.current;
53+
if (!navSceneId) return;
54+
55+
const commit = () => {
56+
if (sceneRequestRef.current !== requestId) return;
57+
// React keeps the currently painted navigation visible if the cached
58+
// lazy component still suspends for a final promise microtask.
59+
startTransition(() => setMountedSceneId(navSceneId));
60+
};
61+
void preloadSceneNav(navSceneId).then(commit, commit);
4562
}, [navSceneId]);
4663

4764
const SceneNavComponent = mountedSceneId ? getSceneNav(mountedSceneId) : null;
4865

49-
const useSplitOpen = !!(showSceneNav && mountedSceneId && SPLIT_OPEN_SCENES.has(mountedSceneId));
66+
const hasMountedSceneNav = showSceneNav && mountedSceneId !== null;
67+
const useSplitOpen = !!(
68+
hasMountedSceneNav
69+
&& mountedSceneId
70+
&& SPLIT_OPEN_SCENES.has(mountedSceneId)
71+
);
5072

5173
const contentRef = useRef<HTMLDivElement>(null);
5274

@@ -72,13 +94,13 @@ const NavPanel: React.FC<NavPanelProps> = ({ className = '' }) => {
7294

7395
const contentCls = [
7496
'bitfun-nav-panel__content',
75-
showSceneNav && 'is-scene',
97+
hasMountedSceneNav && 'is-scene',
7698
useSplitOpen && 'is-split-open',
7799
].filter(Boolean).join(' ');
78100

79101
const sceneCls = [
80102
'bitfun-nav-panel__layer bitfun-nav-panel__layer--scene',
81-
showSceneNav && 'is-active',
103+
hasMountedSceneNav && 'is-active',
82104
].filter(Boolean).join(' ');
83105

84106
return (
@@ -95,7 +117,7 @@ const NavPanel: React.FC<NavPanelProps> = ({ className = '' }) => {
95117
{SceneNavComponent && (
96118
<div className={sceneCls}>
97119
<Suspense fallback={null}>
98-
<div key={mountedSceneId} className="bitfun-nav-panel__scene-inner">
120+
<div className="bitfun-nav-panel__scene-inner">
99121
<SceneNavComponent />
100122
</div>
101123
</Suspense>

src/web-ui/src/app/scenes/SceneViewport.scss

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,8 @@
106106
display: none;
107107
overflow: hidden;
108108

109-
&--active {
109+
&--active,
110+
&--outgoing {
110111
display: flex;
111112
flex-direction: row;
112113
min-width: 0;
@@ -120,11 +121,26 @@
120121
height: 100%;
121122
}
122123
}
124+
125+
&--active {
126+
z-index: 1;
127+
}
128+
129+
&--outgoing {
130+
z-index: 2;
131+
pointer-events: none;
132+
}
123133
}
124134
}
125135

126136
@media (prefers-reduced-motion: reduce) {
127137
.bitfun-scene-viewport__clip::after {
128138
transition: none;
129139
}
140+
141+
// Do not keep the previous scene painted during the exit grace period when
142+
// the user has asked for motion to be reduced.
143+
.bitfun-scene-viewport__scene--exiting {
144+
display: none;
145+
}
130146
}

src/web-ui/src/app/scenes/SceneViewport.tsx

Lines changed: 116 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,15 @@
88
* scene is explicitly opened.
99
*/
1010

11-
import React, { Suspense, lazy } from 'react';
11+
import React, {
12+
Suspense,
13+
lazy,
14+
useCallback,
15+
useEffect,
16+
useLayoutEffect,
17+
useRef,
18+
useState,
19+
} from 'react';
1220
import type { SceneTabId } from '../components/SceneBar/types';
1321
import { useSceneManager } from '../hooks/useSceneManager';
1422
import { useI18n } from '@/infrastructure/i18n/hooks/useI18n';
@@ -36,6 +44,37 @@ const WelcomeScene = lazy(() => import('./welcome/WelcomeScene'));
3644
const MiniAppScene = lazy(() => import('./miniapps/MiniAppScene'));
3745
const PanelViewScene = lazy(() => import('./panel-view/PanelViewScene'));
3846

47+
// Keep in sync with bitfun-motion-view-exit in app/styles/motion.scss.
48+
const SCENE_EXIT_DURATION_MS = 140;
49+
50+
interface SceneTransition {
51+
outgoingTabId: SceneTabId;
52+
incomingTabId: SceneTabId;
53+
phase: 'holding' | 'exiting';
54+
}
55+
56+
interface SceneReadyBoundaryProps {
57+
sceneId: SceneTabId;
58+
onReady: (sceneId: SceneTabId) => void;
59+
children: React.ReactNode;
60+
}
61+
62+
/**
63+
* This effect commits only after a lazy scene has resolved through Suspense.
64+
* It lets the viewport hold the outgoing pixels until the incoming tree is
65+
* actually paintable instead of exposing a fallback between the two scenes.
66+
*/
67+
const SceneReadyBoundary: React.FC<SceneReadyBoundaryProps> = ({
68+
sceneId,
69+
onReady,
70+
children,
71+
}) => {
72+
useLayoutEffect(() => {
73+
onReady(sceneId);
74+
}, [onReady, sceneId]);
75+
76+
return <>{children}</>;
77+
};
3978

4079
interface SceneViewportProps {
4180
workspacePath?: string;
@@ -45,8 +84,72 @@ interface SceneViewportProps {
4584
const SceneViewport: React.FC<SceneViewportProps> = ({ workspacePath, isEntering = false }) => {
4685
const { openTabs, activeTabId } = useSceneManager();
4786
const { t } = useI18n('common');
87+
const [transition, setTransition] = useState<SceneTransition | null>(null);
88+
const [readyVersion, setReadyVersion] = useState(0);
89+
const readySceneIdsRef = useRef<Set<SceneTabId>>(new Set());
90+
const previousActiveTabIdRef = useRef<SceneTabId>(activeTabId);
4891
useDialogCompletionNotify();
4992

93+
const markSceneReady = useCallback((sceneId: SceneTabId) => {
94+
if (readySceneIdsRef.current.has(sceneId)) return;
95+
readySceneIdsRef.current.add(sceneId);
96+
setReadyVersion(version => version + 1);
97+
}, []);
98+
99+
// Derive the outgoing id during render as well as from state. This keeps a
100+
// just-closed active tab (notably the welcome tab) in the keyed React tree
101+
// for its exit frame instead of unmounting and remounting it after layout.
102+
const outgoingTabId = previousActiveTabIdRef.current !== activeTabId
103+
? previousActiveTabIdRef.current
104+
: transition?.outgoingTabId ?? null;
105+
const renderedTabIds = openTabs.map(tab => tab.id);
106+
if (outgoingTabId && !renderedTabIds.includes(outgoingTabId)) {
107+
renderedTabIds.push(outgoingTabId);
108+
}
109+
110+
useLayoutEffect(() => {
111+
const previousActiveTabId = previousActiveTabIdRef.current;
112+
previousActiveTabIdRef.current = activeTabId;
113+
114+
if (!previousActiveTabId || previousActiveTabId === activeTabId) {
115+
return;
116+
}
117+
118+
setTransition({
119+
outgoingTabId: previousActiveTabId,
120+
incomingTabId: activeTabId,
121+
phase: readySceneIdsRef.current.has(activeTabId) ? 'exiting' : 'holding',
122+
});
123+
}, [activeTabId]);
124+
125+
useLayoutEffect(() => {
126+
if (
127+
transition?.phase !== 'holding'
128+
|| !readySceneIdsRef.current.has(transition.incomingTabId)
129+
) {
130+
return;
131+
}
132+
133+
setTransition(current => (
134+
current?.incomingTabId === transition.incomingTabId
135+
? { ...current, phase: 'exiting' }
136+
: current
137+
));
138+
}, [readyVersion, transition]);
139+
140+
useEffect(() => {
141+
if (transition?.phase !== 'exiting') return;
142+
143+
const completedTransition = transition;
144+
const exitTimer = window.setTimeout(() => {
145+
setTransition(current => (
146+
current === completedTransition ? null : current
147+
));
148+
}, SCENE_EXIT_DURATION_MS);
149+
150+
return () => window.clearTimeout(exitTimer);
151+
}, [transition]);
152+
50153
// All tabs closed — show empty state
51154
if (openTabs.length === 0) {
52155
return (
@@ -64,19 +167,24 @@ const SceneViewport: React.FC<SceneViewportProps> = ({ workspacePath, isEntering
64167
return (
65168
<div className="bitfun-scene-viewport" data-testid="scene-viewport">
66169
<div className="bitfun-scene-viewport__clip" data-testid="scene-viewport-clip">
67-
{openTabs.map(tab => {
68-
const isActive = tab.id === activeTabId;
170+
{renderedTabIds.map(tabId => {
171+
const isActive = tabId === activeTabId;
172+
const isOutgoing = !isActive && tabId === outgoingTabId;
173+
const isExiting = isOutgoing && transition?.phase === 'exiting';
69174
return (
70175
<div
71-
key={tab.id}
176+
key={tabId}
72177
className={[
73178
'bitfun-scene-viewport__scene',
74179
isActive && 'bitfun-scene-viewport__scene--active',
180+
isOutgoing && 'bitfun-scene-viewport__scene--outgoing',
181+
isExiting && 'bitfun-scene-viewport__scene--exiting',
75182
].filter(Boolean).join(' ')}
76183
aria-hidden={!isActive}
77184
data-testid="scene-viewport-scene"
78-
data-scene-id={tab.id}
185+
data-scene-id={tabId}
79186
data-scene-active={isActive ? 'true' : 'false'}
187+
data-scene-transition={isExiting ? 'exit' : undefined}
80188
>
81189
<Suspense
82190
fallback={
@@ -92,7 +200,9 @@ const SceneViewport: React.FC<SceneViewportProps> = ({ workspacePath, isEntering
92200
) : null
93201
}
94202
>
95-
{renderScene(tab.id, workspacePath, isEntering, isActive)}
203+
<SceneReadyBoundary sceneId={tabId} onReady={markSceneReady}>
204+
{renderScene(tabId, workspacePath, isEntering, isActive)}
205+
</SceneReadyBoundary>
96206
</Suspense>
97207
</div>
98208
);

src/web-ui/src/app/scenes/nav-registry.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,31 @@ import type { SceneTabId } from '../components/SceneBar/types';
1414

1515
type LazyNavComponent = ReturnType<typeof lazy<ComponentType>>;
1616

17+
const loadSettingsNav = () => import('./settings/SettingsNav');
18+
const loadFileViewerNav = () => import('./file-viewer/FileViewerNav');
19+
const loadShellNav = () => import('./shell/ShellNav');
20+
1721
const SCENE_NAV_REGISTRY: Partial<Record<SceneTabId, LazyNavComponent>> = {
18-
settings: lazy(() => import('./settings/SettingsNav')),
19-
'file-viewer': lazy(() => import('./file-viewer/FileViewerNav')),
20-
shell: lazy(() => import('./shell/ShellNav')),
22+
settings: lazy(loadSettingsNav),
23+
'file-viewer': lazy(loadFileViewerNav),
24+
shell: lazy(loadShellNav),
2125
// terminal: lazy(() => import('./terminal/TerminalNav')),
2226
};
2327

28+
const SCENE_NAV_LOADERS: Partial<Record<SceneTabId, () => Promise<unknown>>> = {
29+
settings: loadSettingsNav,
30+
'file-viewer': loadFileViewerNav,
31+
shell: loadShellNav,
32+
};
33+
2434
/**
2535
* Returns the lazy nav component registered for the given scene,
2636
* or `null` if the scene uses the default MainNav.
2737
*/
2838
export function getSceneNav(sceneId: SceneTabId): LazyNavComponent | null {
2939
return SCENE_NAV_REGISTRY[sceneId] ?? null;
3040
}
41+
42+
export async function preloadSceneNav(sceneId: SceneTabId): Promise<void> {
43+
await SCENE_NAV_LOADERS[sceneId]?.();
44+
}

0 commit comments

Comments
 (0)