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 <help@stably.ai>
This commit is contained in:
brennanb2025
2026-05-26 20:41:04 -07:00
co-authored by Orca
parent 2958ad09db
commit e45fc7c2f7
4 changed files with 185 additions and 60 deletions
+42 -27
View File
@@ -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;
}
}
@@ -54,7 +54,8 @@ function renderSurface(
activeTourId: 'tasks',
renderState,
panelRef: { current: null } as RefObject<HTMLElement | null>,
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,
@@ -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 (
<ContextualTourOverlaySurface
activeTourId={activeTourId}
renderState={renderState}
panelRef={panelRef}
highlightStyle={highlightStyle}
spotlightRect={spotlightRect}
spotlightHostRect={spotlightHostRect}
panelPosition={panelPosition}
panelPlacement={clamped.placement}
panelHost={renderState.panelHost}
@@ -377,3 +393,22 @@ export function ContextualTourOverlay(): JSX.Element | null {
/>
)
}
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
}
@@ -30,7 +30,8 @@ type ContextualTourOverlaySurfaceProps = {
activeTourId: ContextualTourId
renderState: ActiveTourRenderState
panelRef: RefObject<HTMLElement | null>
highlightStyle: CSSProperties
spotlightRect: SpotlightRect
spotlightHostRect: SpotlightRect | null
panelPosition: PanelPositionStyle
panelPlacement: ContextualTourPanelPlacement | null
panelHost: HTMLElement | null
@@ -40,6 +41,14 @@ type ContextualTourOverlaySurfaceProps = {
onOverlayKeyDownCapture: (event: KeyboardEvent<HTMLDivElement>) => 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({
</section>
)
const spotlight = panelHost ? (
spotlightHostRect ? (
<ContextualTourSpotlight rect={spotlightHostRect} hosted />
) : null
) : (
<ContextualTourSpotlight rect={spotlightRect} hosted={false} />
)
return (
<div
className={cn(
@@ -146,21 +164,77 @@ export function ContextualTourOverlaySurface({
role="presentation"
onKeyDownCapture={onOverlayKeyDownCapture}
>
{panelHost ? null : (
<div
aria-hidden="true"
data-contextual-tour-highlight=""
className="contextual-tour-highlight fixed rounded-md animate-in fade-in-0 duration-200"
style={highlightStyle}
/>
)}
{panelHost ? null : spotlight}
<div className="pointer-events-auto">
{panelHost ? createPortal(panel, panelHost) : panel}
{panelHost
? createPortal(
<>
{spotlight}
{panel}
</>,
panelHost
)
: panel}
</div>
</div>
)
}
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 (
<div
aria-hidden="true"
data-contextual-tour-spotlight=""
data-contextual-tour-spotlight-hosted={hosted ? 'true' : undefined}
className={cn('contextual-tour-spotlight', positionClass)}
>
<svg className="contextual-tour-spotlight-svg" preserveAspectRatio="none" aria-hidden="true">
<defs>
<mask id={maskId}>
<rect width="100%" height="100%" fill="white" />
<rect
x={rect.left}
y={rect.top}
width={rect.width}
height={rect.height}
rx={rect.radius}
ry={rect.radius}
fill="black"
/>
</mask>
</defs>
<rect
width="100%"
height="100%"
className="contextual-tour-spotlight-fill"
mask={`url(#${maskId})`}
/>
</svg>
<div
className={cn('contextual-tour-spotlight-edge', positionClass)}
style={{
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
borderRadius: rect.radius
}}
/>
</div>
)
}
function ContextualTourProgressDots({
current,
total