From 6593494e208e8a4edc138b8f2dd82ba8eeeeaa56 Mon Sep 17 00:00:00 2001 From: linzhary Date: Tue, 11 Aug 2026 14:47:54 +0800 Subject: [PATCH 1/3] fix(scene-bar): keep scene tab close button under cursor on press The whole scene tab scales 0.985 around its center on :active (120ms), which drags the absolutely-positioned close button left by 0.015 x (width/2 - 15) px (~4-8px on typical scene tab widths). The mouseup hit-test then lands outside the button, so the click bubbles to the tab and activates it instead of closing it. SceneTab now measures the drift and exposes it as --scene-tab-close-shift; SceneBar.scss counter-translates the close button with the same 120ms easing as the tab scale, so the press animation is preserved and the button stays under the cursor. The close button is also pinned against the global motion baseline 1px drop on :active. only-child and prefers-reduced-motion paths keep the button at its base position. Verification: pnpm run type-check:web, sass compile; manual close-button verification in desktop dev. Fixes #2210 --- .../src/app/components/SceneBar/SceneBar.scss | 37 +++++++++++++++++-- .../src/app/components/SceneBar/SceneTab.tsx | 23 +++++++++++- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/src/web-ui/src/app/components/SceneBar/SceneBar.scss b/src/web-ui/src/app/components/SceneBar/SceneBar.scss index f815e9ef6..d02099bdb 100644 --- a/src/web-ui/src/app/components/SceneBar/SceneBar.scss +++ b/src/web-ui/src/app/components/SceneBar/SceneBar.scss @@ -136,6 +136,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 + // --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(--scene-tab-close-shift, 0px)); + } } &:focus-visible { @@ -152,6 +161,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 +252,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 +296,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 +374,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..e8260198b 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('--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 (
Date: Tue, 11 Aug 2026 14:47:54 +0800 Subject: [PATCH 2/3] fix(scene-bar): define close-shift default instead of a var() fallback The theme color audit contract keeps FALLBACK_VAR_CONTRACTS empty, so var(--scene-tab-close-shift, 0px) was flagged as an uncontracted fallback usage. Declare --scene-tab-close-shift: 0px on the tab as the default and consume it without a fallback. --- src/web-ui/src/app/components/SceneBar/SceneBar.scss | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/web-ui/src/app/components/SceneBar/SceneBar.scss b/src/web-ui/src/app/components/SceneBar/SceneBar.scss index d02099bdb..bb3345100 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. + --scene-tab-close-shift: 0px; position: relative; display: flex; align-items: center; @@ -143,7 +145,7 @@ // --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(--scene-tab-close-shift, 0px)); + transform: translateY(-50%) translateX(var(--scene-tab-close-shift)); } } From 566d461bd243065789b401c39bb888ed0fee8b02 Mon Sep 17 00:00:00 2001 From: linzhary Date: Tue, 11 Aug 2026 14:47:54 +0800 Subject: [PATCH 3/3] fix(scene-bar): rename close-shift var to avoid legacy --scene- prefix The appearance contract audit forbids the --scene-* CSS token family, so --scene-tab-close-shift was rejected in both files. Use the component-scoped name --bitfun-scene-tab-close-shift instead. --- src/web-ui/src/app/components/SceneBar/SceneBar.scss | 8 ++++---- src/web-ui/src/app/components/SceneBar/SceneTab.tsx | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/web-ui/src/app/components/SceneBar/SceneBar.scss b/src/web-ui/src/app/components/SceneBar/SceneBar.scss index bb3345100..871fff0ad 100644 --- a/src/web-ui/src/app/components/SceneBar/SceneBar.scss +++ b/src/web-ui/src/app/components/SceneBar/SceneBar.scss @@ -49,7 +49,7 @@ .bitfun-scene-tab { // Measured by SceneTab and overridden per-tab; 0 until first measurement. - --scene-tab-close-shift: 0px; + --bitfun-scene-tab-close-shift: 0px; position: relative; display: flex; align-items: center; @@ -142,10 +142,10 @@ // 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 - // --scene-tab-close-shift. The transform transition below keeps this in - // sync with the tab's 120ms scale. + // --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(--scene-tab-close-shift)); + transform: translateY(-50%) translateX(var(--bitfun-scene-tab-close-shift)); } } diff --git a/src/web-ui/src/app/components/SceneBar/SceneTab.tsx b/src/web-ui/src/app/components/SceneBar/SceneTab.tsx index e8260198b..29a02bf28 100644 --- a/src/web-ui/src/app/components/SceneBar/SceneTab.tsx +++ b/src/web-ui/src/app/components/SceneBar/SceneTab.tsx @@ -49,7 +49,7 @@ const SceneTab: React.FC = ({ const el = tabRef.current; if (!el) return; const updateShift = () => { - el.style.setProperty('--scene-tab-close-shift', `${0.015 * (el.offsetWidth / 2 - 15)}px`); + el.style.setProperty('--bitfun-scene-tab-close-shift', `${0.015 * (el.offsetWidth / 2 - 15)}px`); }; updateShift(); const observer = new ResizeObserver(updateShift);