fix: Tour-Tooltip auf feste Breite begrenzen

Entfernt left+right-Stretching in CSS, positioniert das Tooltip horizontal
am Spotlight und misst Ziele nach Navigation mit verzögerten Retries.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-31 13:08:31 +02:00
parent 520ba766a3
commit b7e2d470a9
3 changed files with 44 additions and 14 deletions
+6 -4
View File
@@ -4276,10 +4276,12 @@ body.app-tour-active .app-tour-target-active {
} }
.app-tour-tooltip:not(.centered) { .app-tour-tooltip:not(.centered) {
left: max(16px, env(safe-area-inset-left, 0px)); left: 50%;
right: max(16px, env(safe-area-inset-right, 0px)); transform: translateX(-50%);
width: auto; }
max-width: none;
.app-tour-tooltip:not(.centered).app-tour-tooltip--anchored {
transform: none;
} }
.app-tour-tooltip.centered { .app-tour-tooltip.centered {
+32 -10
View File
@@ -18,6 +18,14 @@ interface SpotlightRect {
const TOOLTIP_EDGE_MARGIN = 16 const TOOLTIP_EDGE_MARGIN = 16
const TOOLTIP_ESTIMATED_HEIGHT = 240 const TOOLTIP_ESTIMATED_HEIGHT = 240
const TOOLTIP_WIDTH = 420
function computeTooltipLeft(spotlight: SpotlightRect): number {
const tooltipWidth = Math.min(TOOLTIP_WIDTH, window.innerWidth - TOOLTIP_EDGE_MARGIN * 2)
const ideal = spotlight.left + spotlight.width / 2 - tooltipWidth / 2
const maxLeft = window.innerWidth - TOOLTIP_EDGE_MARGIN - tooltipWidth
return Math.max(TOOLTIP_EDGE_MARGIN, Math.min(ideal, maxLeft))
}
function buildCutoutClipPath(rect: SpotlightRect): string { function buildCutoutClipPath(rect: SpotlightRect): string {
const right = rect.left + rect.width const right = rect.left + rect.width
@@ -51,6 +59,7 @@ export default function AppTourOverlay() {
currentStepId, currentStepId,
currentStepIndex, currentStepIndex,
totalSteps, totalSteps,
layoutTick,
nextStep, nextStep,
prevStep, prevStep,
skipTour skipTour
@@ -66,7 +75,10 @@ export default function AppTourOverlay() {
return return
} }
let cancelled = false
const updateSpotlight = () => { const updateSpotlight = () => {
if (cancelled) return
const selector = getTourTargetSelector(currentStepId) const selector = getTourTargetSelector(currentStepId)
if (!selector) { if (!selector) {
setSpotlight(null) setSpotlight(null)
@@ -78,6 +90,10 @@ export default function AppTourOverlay() {
return return
} }
const rect = el.getBoundingClientRect() const rect = el.getBoundingClientRect()
if (rect.width <= 0 || rect.height <= 0) {
setSpotlight(null)
return
}
const padding = 8 const padding = 8
setSpotlight({ setSpotlight({
top: Math.max(8, rect.top - padding), top: Math.max(8, rect.top - padding),
@@ -90,19 +106,17 @@ export default function AppTourOverlay() {
updateSpotlight() updateSpotlight()
window.addEventListener('resize', updateSpotlight) window.addEventListener('resize', updateSpotlight)
window.addEventListener('scroll', updateSpotlight, true) window.addEventListener('scroll', updateSpotlight, true)
const retryDelay = getTourTargetRetryDelay(currentStepId)
const timer = window.setTimeout(updateSpotlight, retryDelay) const retryDelays = [getTourTargetRetryDelay(currentStepId), 120, 280, 480]
const retryTimer = retryDelay > 120 const timers = retryDelays.map((delay) => window.setTimeout(updateSpotlight, delay))
? window.setTimeout(updateSpotlight, retryDelay + 180)
: undefined
return () => { return () => {
window.clearTimeout(timer) cancelled = true
if (retryTimer !== undefined) window.clearTimeout(retryTimer) for (const timer of timers) window.clearTimeout(timer)
window.removeEventListener('resize', updateSpotlight) window.removeEventListener('resize', updateSpotlight)
window.removeEventListener('scroll', updateSpotlight, true) window.removeEventListener('scroll', updateSpotlight, true)
} }
}, [currentStepId, isActive]) }, [currentStepId, isActive, layoutTick])
useEffect(() => { useEffect(() => {
if (!isActive) return if (!isActive) return
@@ -138,9 +152,17 @@ export default function AppTourOverlay() {
const tooltipStyle = centered const tooltipStyle = centered
? undefined ? undefined
: spotlight : spotlight
? { top: computeTooltipTop(spotlight) } ? { top: computeTooltipTop(spotlight), left: computeTooltipLeft(spotlight) }
: { top: '20%' } : { top: '20%' }
const tooltipClassName = [
'app-tour-tooltip',
centered ? 'centered' : '',
!centered && spotlight ? 'app-tour-tooltip--anchored' : ''
]
.filter(Boolean)
.join(' ')
const backdropStyle = spotlight && !centered const backdropStyle = spotlight && !centered
? { clipPath: buildCutoutClipPath(spotlight) } ? { clipPath: buildCutoutClipPath(spotlight) }
: undefined : undefined
@@ -165,7 +187,7 @@ export default function AppTourOverlay() {
/> />
)} )}
<div className={`app-tour-tooltip${centered ? ' centered' : ''}`} style={tooltipStyle}> <div className={tooltipClassName} style={tooltipStyle}>
<button type="button" className="app-tour-close" onClick={skipTour} aria-label={t('tour.skip')}> <button type="button" className="app-tour-close" onClick={skipTour} aria-label={t('tour.skip')}>
<X size={18} /> <X size={18} />
</button> </button>
+6
View File
@@ -51,6 +51,7 @@ interface AppTourContextValue {
currentStepId: TourStepId | null currentStepId: TourStepId | null
currentStepIndex: number currentStepIndex: number
totalSteps: number totalSteps: number
layoutTick: number
startTour: (options?: { force?: boolean; demoMode?: boolean }) => void startTour: (options?: { force?: boolean; demoMode?: boolean }) => void
stopTour: () => void stopTour: () => void
restartTour: () => void restartTour: () => void
@@ -135,6 +136,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
const [stepIndex, setStepIndex] = useState(0) const [stepIndex, setStepIndex] = useState(0)
const [pendingAfterLogin, setPendingAfterLogin] = useState(false) const [pendingAfterLogin, setPendingAfterLogin] = useState(false)
const [isDemoTour, setIsDemoTour] = useState(false) const [isDemoTour, setIsDemoTour] = useState(false)
const [layoutTick, setLayoutTick] = useState(0)
const navigationRef = useRef<TourNavigation | null>(null) const navigationRef = useRef<TourNavigation | null>(null)
const demoContextRef = useRef<DemoTourContext | null>(null) const demoContextRef = useRef<DemoTourContext | null>(null)
const tourModeRef = useRef<{ demoMode: boolean }>({ demoMode: false }) const tourModeRef = useRef<{ demoMode: boolean }>({ demoMode: false })
@@ -276,6 +278,8 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
if (!stepId) return if (!stepId) return
applyStepSideEffects(stepId) applyStepSideEffects(stepId)
scrollToCurrentTarget(stepId) scrollToCurrentTarget(stepId)
const timer = window.setTimeout(() => setLayoutTick((tick) => tick + 1), 0)
return () => window.clearTimeout(timer)
}, [isActive, isDemoTour, stepIndex, applyStepSideEffects, scrollToCurrentTarget]) }, [isActive, isDemoTour, stepIndex, applyStepSideEffects, scrollToCurrentTarget])
const restartTour = useCallback(() => { const restartTour = useCallback(() => {
@@ -318,6 +322,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
currentStepId, currentStepId,
currentStepIndex: stepIndex, currentStepIndex: stepIndex,
totalSteps: stepOrder.length, totalSteps: stepOrder.length,
layoutTick,
startTour, startTour,
stopTour, stopTour,
restartTour, restartTour,
@@ -342,6 +347,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
startTour, startTour,
stepIndex, stepIndex,
stepOrder.length, stepOrder.length,
layoutTick,
stopTour stopTour
] ]
) )