diff --git a/src/web-ui/src/app/components/SceneBar/SceneBar.scss b/src/web-ui/src/app/components/SceneBar/SceneBar.scss index f815e9ef6..871fff0ad 100644 --- a/src/web-ui/src/app/components/SceneBar/SceneBar.scss +++ b/src/web-ui/src/app/components/SceneBar/SceneBar.scss @@ -48,6 +48,8 @@ } .bitfun-scene-tab { + // Measured by SceneTab and overridden per-tab; 0 until first measurement. + --bitfun-scene-tab-close-shift: 0px; position: relative; display: flex; align-items: center; @@ -136,6 +138,15 @@ &:active { transform: scale(0.985); background: var(--bf-appearance-token-element-bg-medium); + + // Counter the whole-tab scale drift for the absolutely-positioned close + // button so press/release stays under the cursor. The drift (distance + // from the tab center × 0.015) is measured by SceneTab and exposed as + // --bitfun-scene-tab-close-shift. The transform transition below keeps + // this in sync with the tab's 120ms scale. + .bitfun-scene-tab__close { + transform: translateY(-50%) translateX(var(--bitfun-scene-tab-close-shift)); + } } &:focus-visible { @@ -152,6 +163,12 @@ &:active { transform: none; background: transparent; + + // No tab-scale drift here (transform is none), so keep the close + // button at its base position instead of applying the compensation. + .bitfun-scene-tab__close { + transform: translateY(-50%); + } } } @@ -237,9 +254,12 @@ transform: translateY(-50%); cursor: pointer; flex-shrink: 0; - transition: opacity $motion-fast $easing-standard, - color $motion-fast $easing-standard, - background $motion-fast $easing-standard; + transition: + // Sync the scale-drift compensation with the tab's 120ms press scale. + transform 120ms cubic-bezier(0.23, 1, 0.32, 1), + opacity $motion-fast $easing-standard, + color $motion-fast $easing-standard, + background $motion-fast $easing-standard; &:hover { color: var(--bf-appearance-token-color-accent-500); @@ -278,6 +298,13 @@ background: var(--bf-appearance-token-element-bg-medium); opacity: 1; } + + // Keep the close button at its hovered position while pressed: the global + // motion baseline drops buttons 1px on :active. + .bitfun-scene-tab__close:active { + translate: 0 -1px; + scale: 0.985; + } } [data-bf-appearance='bitfun-slate'] .bitfun-scene-tab__icon { @@ -349,4 +376,10 @@ transition: none; transform: none; } + + // No tab-scale drift under reduced motion either (transform is none), so + // keep the close button at its base position instead of the compensation. + .bitfun-scene-tab:active .bitfun-scene-tab__close { + transform: translateY(-50%); + } } diff --git a/src/web-ui/src/app/components/SceneBar/SceneTab.tsx b/src/web-ui/src/app/components/SceneBar/SceneTab.tsx index 72d825841..29a02bf28 100644 --- a/src/web-ui/src/app/components/SceneBar/SceneTab.tsx +++ b/src/web-ui/src/app/components/SceneBar/SceneTab.tsx @@ -7,7 +7,7 @@ * Optional action (e.g. new session) shown inside __content when onActionClick is provided. */ -import React, { useCallback } from 'react'; +import React, { useCallback, useEffect, useRef } from 'react'; import { Plus, X } from 'lucide-react'; import { Tooltip } from '@/component-library'; import type { SceneTab as SceneTabType, SceneTabDef } from './types'; @@ -36,6 +36,26 @@ const SceneTab: React.FC = ({ onClose, }) => { const { Icon, label, pinned } = def; + const tabRef = useRef(null); + + // Expose the close button's scale drift as a CSS var so SceneBar.scss can + // counter it while the tab's 120ms press scale is playing. The close button + // is absolutely positioned (right: 6px, 18px wide → center 15px from the + // right edge); scaling the whole tab 0.985 around its center drifts that + // point left by 0.015 × (width/2 − 15) px, which would make the mouseup + // miss the button. Without this the click bubbles to the tab and activates + // it instead of closing. + useEffect(() => { + const el = tabRef.current; + if (!el) return; + const updateShift = () => { + el.style.setProperty('--bitfun-scene-tab-close-shift', `${0.015 * (el.offsetWidth / 2 - 15)}px`); + }; + updateShift(); + const observer = new ResizeObserver(updateShift); + observer.observe(el); + return () => observer.disconnect(); + }, []); const handleClick = useCallback((e: React.MouseEvent) => { e.stopPropagation(); @@ -80,6 +100,7 @@ const SceneTab: React.FC = ({ return (