From e45fc7c2f7cec816c83830d14a83df0c4a578030 Mon Sep 17 00:00:00 2001 From: brennanb2025 Date: Tue, 26 May 2026 20:41:04 -0700 Subject: [PATCH] Replace tour highlight ring with spotlight cutout Switches the contextual tour highlight from a pulsing focus ring to a dim scrim with an SVG-mask cutout at the target. The cutout pulls its radius from the target element's border-radius, so curved buttons get a curved hole. Hosted (Sheet/Dialog) tours portal the scrim into the host so only the panel contents dim, not the rest of the app. Co-authored-by: Orca --- src/renderer/src/assets/main.css | 69 +++++++------ .../ContextualTourOverlay.test.tsx | 3 +- .../ContextualTourOverlay.tsx | 77 +++++++++++---- .../ContextualTourOverlaySurface.tsx | 96 ++++++++++++++++--- 4 files changed, 185 insertions(+), 60 deletions(-) diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index fd883d9cdb4..6fe0e990028 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -2394,41 +2394,56 @@ html.onboarding-tour-start-transition::view-transition-new(root) { gap: 6px; } -/* Why: contextual-tour highlight is sized to the target rect and reads - as a focus ring sitting on the surface. The inset shadow keeps the - crisp ring inside the element so it doesn't get clipped at viewport - edges; the outer shadow is a soft halo that blooms outward without - drawing a hard frame. */ -.contextual-tour-highlight { +/* Why: contextual-tour spotlight is a full-cover scrim with an SVG-mask + hole at the target rect plus a thin edge stroke, so attention reads + from the dim contrast rather than from chrome painted over the target. + The mask uses the target's own border-radius so curved buttons don't + leave un-dimmed corners between their curve and the cutout edge. */ +.contextual-tour-spotlight { pointer-events: none; - box-shadow: - inset 0 0 0 2px color-mix(in srgb, var(--ring) 80%, transparent), - 0 0 0 2px color-mix(in srgb, var(--ring) 40%, transparent), - 0 0 24px 6px color-mix(in srgb, var(--ring) 28%, transparent), - 0 0 56px 16px color-mix(in srgb, var(--ring) 14%, transparent); - animation: contextual-tour-highlight-pulse 2.6s ease-in-out infinite; + inset: 0; + animation: contextual-tour-spotlight-fade-in 200ms ease-out; } -@keyframes contextual-tour-highlight-pulse { - 0%, - 100% { - box-shadow: - inset 0 0 0 2px color-mix(in srgb, var(--ring) 80%, transparent), - 0 0 0 2px color-mix(in srgb, var(--ring) 40%, transparent), - 0 0 24px 6px color-mix(in srgb, var(--ring) 28%, transparent), - 0 0 56px 16px color-mix(in srgb, var(--ring) 14%, transparent); +.contextual-tour-spotlight-svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; +} + +/* Why: tint toward black so the scrim reads as a dim regardless of + theme — tinting toward --background would be invisible in light theme + (white over white). */ +.contextual-tour-spotlight-fill { + fill: rgb(0 0 0 / 0.5); +} + +.contextual-tour-spotlight-edge { + pointer-events: none; + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ring) 55%, transparent); + transition: + top 180ms ease, + left 180ms ease, + width 180ms ease, + height 180ms ease; +} + +@keyframes contextual-tour-spotlight-fade-in { + from { + opacity: 0; } - 50% { - box-shadow: - inset 0 0 0 2px color-mix(in srgb, var(--ring) 95%, transparent), - 0 0 0 2px color-mix(in srgb, var(--ring) 55%, transparent), - 0 0 32px 10px color-mix(in srgb, var(--ring) 38%, transparent), - 0 0 68px 22px color-mix(in srgb, var(--ring) 22%, transparent); + to { + opacity: 1; } } @media (prefers-reduced-motion: reduce) { - .contextual-tour-highlight { + .contextual-tour-spotlight { animation: none; } + .contextual-tour-spotlight-edge { + transition: none; + } } diff --git a/src/renderer/src/components/contextual-tours/ContextualTourOverlay.test.tsx b/src/renderer/src/components/contextual-tours/ContextualTourOverlay.test.tsx index 8ee563c17e6..960e94a4fe2 100644 --- a/src/renderer/src/components/contextual-tours/ContextualTourOverlay.test.tsx +++ b/src/renderer/src/components/contextual-tours/ContextualTourOverlay.test.tsx @@ -54,7 +54,8 @@ function renderSurface( activeTourId: 'tasks', renderState, panelRef: { current: null } as RefObject, - highlightStyle: { left: 6, top: 16, width: 108, height: 68 }, + spotlightRect: { left: 4, top: 14, width: 112, height: 72, radius: 8 }, + spotlightHostRect: null, panelPosition: { left: 130, top: 20, '--contextual-tour-arrow-offset': '40px' }, panelPlacement: 'right', panelHost: renderState.panelHost, diff --git a/src/renderer/src/components/contextual-tours/ContextualTourOverlay.tsx b/src/renderer/src/components/contextual-tours/ContextualTourOverlay.tsx index c79d8cf0557..9f36d479798 100644 --- a/src/renderer/src/components/contextual-tours/ContextualTourOverlay.tsx +++ b/src/renderer/src/components/contextual-tours/ContextualTourOverlay.tsx @@ -30,10 +30,14 @@ import { ContextualTourOverlaySurface, getContextualTourFocusableElements, handleContextualTourOverlayKeyDown, - type ActiveTourRenderState + type ActiveTourRenderState, + type SpotlightRect } from './ContextualTourOverlaySurface' const PANEL_FALLBACK_SIZE = { width: 304, height: 172 } +// Why: padding around the spotlight cutout so a tight ring of the +// underlying surface still reads as part of the highlighted target. +const SPOTLIGHT_PADDING = 6 export function ContextualTourOverlay(): JSX.Element | null { const activeTourId = useAppStore((s) => s.activeContextualTourId) @@ -290,24 +294,29 @@ export function ContextualTourOverlay(): JSX.Element | null { } // Why: Radix dialog/sheet content owns focus and z-index; hosting controls // there avoids outside-focus dismissal and keeps them above the backdrop. - // For hosted panels, the panel sits inside the dialog (no floating arrow - // / highlight rect on top), so we mark the target itself with a soft ring. + // The hosted spotlight portals into the host so it dims only the panel + // contents around the target, not the rest of the app. const previousZIndex = panelHost.style.zIndex const targetElement = renderState?.targetElement instanceof HTMLElement ? renderState.targetElement : null - const previousBoxShadow = targetElement?.style.boxShadow - const previousBorderRadius = targetElement?.style.borderRadius + const previousPosition = targetElement?.style.position + const previousZ = targetElement?.style.zIndex panelHost.style.zIndex = '80' if (targetElement) { - targetElement.style.boxShadow = - '0 0 8px 0 color-mix(in srgb, var(--ring) 55%, transparent), 0 0 24px 4px color-mix(in srgb, var(--ring) 32%, transparent), 0 0 56px 16px color-mix(in srgb, var(--ring) 16%, transparent)' - targetElement.style.borderRadius ||= 'var(--radius-md)' + // Why: lift the target above the host-portaled scrim quadrants so the + // cutout reads as un-dimmed even though the scrim shares its stacking + // context. Setting position only when unset preserves any existing + // layout behavior. + if (!targetElement.style.position) { + targetElement.style.position = 'relative' + } + targetElement.style.zIndex = '1' } return () => { panelHost.style.zIndex = previousZIndex if (targetElement) { - targetElement.style.boxShadow = previousBoxShadow ?? '' - targetElement.style.borderRadius = previousBorderRadius ?? '' + targetElement.style.position = previousPosition ?? '' + targetElement.style.zIndex = previousZ ?? '' } } }, [activeTourId, renderState]) @@ -339,24 +348,31 @@ export function ContextualTourOverlay(): JSX.Element | null { top: cssPosition.top, '--contextual-tour-arrow-offset': `${cssPosition.arrowOffset}px` } - // Why: align the highlight rect to the element so the ring sits ON the - // surface like a focus ring. Outsetting it floats the ring in empty - // space when the element touches a viewport edge (e.g. the right - // sidebar) and reads as misplaced chrome. The glow in box-shadow - // already extends outward from the element edge. - const highlightStyle: CSSProperties = { - left: renderState.rect.left, - top: renderState.rect.top, - width: renderState.rect.width, - height: renderState.rect.height + const targetRadius = getContextualTourTargetRadius(renderState.targetElement) + const spotlightRect: SpotlightRect = { + left: renderState.rect.left - SPOTLIGHT_PADDING, + top: renderState.rect.top - SPOTLIGHT_PADDING, + width: renderState.rect.width + SPOTLIGHT_PADDING * 2, + height: renderState.rect.height + SPOTLIGHT_PADDING * 2, + radius: targetRadius + SPOTLIGHT_PADDING } + const spotlightHostRect: SpotlightRect | null = panelHostRect + ? { + left: spotlightRect.left - panelHostRect.left, + top: spotlightRect.top - panelHostRect.top, + width: spotlightRect.width, + height: spotlightRect.height, + radius: spotlightRect.radius + } + : null return ( ) } + +function getContextualTourTargetRadius(element: Element): number { + if (typeof window === 'undefined' || !(element instanceof HTMLElement)) { + return 0 + } + // Why: read all four corners and take the max so the cutout never has a + // smaller radius than the target. Asymmetric radii are uncommon in our + // chrome but worth handling. + const style = window.getComputedStyle(element) + const radii = [ + style.borderTopLeftRadius, + style.borderTopRightRadius, + style.borderBottomRightRadius, + style.borderBottomLeftRadius + ] + .map((value) => parseFloat(value)) + .filter((value) => Number.isFinite(value)) + return radii.length > 0 ? Math.max(...radii) : 0 +} diff --git a/src/renderer/src/components/contextual-tours/ContextualTourOverlaySurface.tsx b/src/renderer/src/components/contextual-tours/ContextualTourOverlaySurface.tsx index 99b9de1c0d6..fe62cd38690 100644 --- a/src/renderer/src/components/contextual-tours/ContextualTourOverlaySurface.tsx +++ b/src/renderer/src/components/contextual-tours/ContextualTourOverlaySurface.tsx @@ -30,7 +30,8 @@ type ContextualTourOverlaySurfaceProps = { activeTourId: ContextualTourId renderState: ActiveTourRenderState panelRef: RefObject - highlightStyle: CSSProperties + spotlightRect: SpotlightRect + spotlightHostRect: SpotlightRect | null panelPosition: PanelPositionStyle panelPlacement: ContextualTourPanelPlacement | null panelHost: HTMLElement | null @@ -40,6 +41,14 @@ type ContextualTourOverlaySurfaceProps = { onOverlayKeyDownCapture: (event: KeyboardEvent) => void } +export type SpotlightRect = { + top: number + left: number + width: number + height: number + radius: number +} + if (typeof window !== 'undefined') { const guardedWindow = window as Window & { __orcaContextualTourGlobalKeyGuardInstalled?: boolean @@ -59,7 +68,8 @@ export function ContextualTourOverlaySurface({ activeTourId, renderState, panelRef, - highlightStyle, + spotlightRect, + spotlightHostRect, panelPosition, panelPlacement, panelHost, @@ -134,6 +144,14 @@ export function ContextualTourOverlaySurface({ ) + const spotlight = panelHost ? ( + spotlightHostRect ? ( + + ) : null + ) : ( + + ) + return (
- {panelHost ? null : ( - ) } +function ContextualTourSpotlight({ + rect, + hosted +}: { + rect: SpotlightRect + hosted: boolean +}): JSX.Element { + // Why: an SVG mask scrim cuts a rounded-rect hole that follows the + // target's border-radius, so curved buttons don't leave un-dimmed + // corners between the target's curve and the cutout edge. + const positionClass = hosted ? 'absolute' : 'fixed' + const maskId = `contextual-tour-spotlight-mask-${hosted ? 'hosted' : 'fixed'}` + return ( +