Remove contextual tour spotlight

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-05-28 15:06:05 -07:00
co-authored by Orca
parent ccf36eda7b
commit c8fda55d8e
5 changed files with 4 additions and 240 deletions
-61
View File
@@ -2562,64 +2562,3 @@ html.onboarding-tour-start-transition::view-transition-new(root) {
justify-content: flex-end;
gap: 6px;
}
/* Why: contextual-tour spotlight is a full-cover scrim with one SVG-mask
hole at the target rect, so attention reads from the single undimmed area
rather than from extra 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;
inset: 0;
animation: contextual-tour-spotlight-fade-in 200ms ease-out;
}
.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.22);
}
/* Why: inside Dialog/Sheet content the target element is lifted above this
scrim. A plain scrim avoids drawing a fake rectangular cutout around grouped
controls such as the Add Project source choices. */
.contextual-tour-hosted-scrim {
pointer-events: none;
inset: 0;
background: rgb(0 0 0 / 0.14);
animation: contextual-tour-spotlight-fade-in 200ms ease-out;
}
/* Why: tours can appear inside existing modal surfaces. In that case the
modal backdrop is secondary context, not the primary attention device. */
:root[data-contextual-tour-active='true'] [data-slot='dialog-overlay'],
:root[data-contextual-tour-active='true'] [data-slot='sheet-overlay'] {
background-color: rgb(0 0 0 / 0.28);
}
@keyframes contextual-tour-spotlight-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.contextual-tour-spotlight {
animation: none;
}
.contextual-tour-hosted-scrim {
animation: none;
}
}
@@ -54,8 +54,6 @@ function renderSurface(
activeTourId: 'tasks',
renderState,
panelRef: { current: null } as RefObject<HTMLElement | null>,
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,14 +30,10 @@ import {
ContextualTourOverlaySurface,
getContextualTourFocusableElements,
handleContextualTourOverlayKeyDown,
type ActiveTourRenderState,
type SpotlightRect
type ActiveTourRenderState
} 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)
@@ -287,52 +283,6 @@ export function ContextualTourOverlay(): JSX.Element | null {
}
}, [activeTourId])
useEffect(() => {
if (!activeTourId || typeof document === 'undefined') {
return
}
// Why: a tour over a Dialog/Sheet already has a modal backdrop; keep that
// backdrop lighter so the tour has one clear focus instead of a blackout.
document.documentElement.dataset.contextualTourActive = 'true'
return () => {
delete document.documentElement.dataset.contextualTourActive
}
}, [activeTourId])
useEffect(() => {
const panelHost = renderState?.panelHost
if (!activeTourId || !panelHost) {
return
}
// Why: Radix dialog/sheet content owns focus and z-index; hosting controls
// there avoids outside-focus dismissal and keeps them above the backdrop.
// 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 previousPosition = targetElement?.style.position
const previousZ = targetElement?.style.zIndex
panelHost.style.zIndex = '80'
if (targetElement) {
// 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.position = previousPosition ?? ''
targetElement.style.zIndex = previousZ ?? ''
}
}
}, [activeTourId, renderState])
if (!activeTourId || !renderState) {
return null
}
@@ -360,31 +310,12 @@ export function ContextualTourOverlay(): JSX.Element | null {
top: cssPosition.top,
'--contextual-tour-arrow-offset': `${cssPosition.arrowOffset}px`
}
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}
spotlightRect={spotlightRect}
spotlightHostRect={spotlightHostRect}
panelPosition={panelPosition}
panelPlacement={clamped.placement}
panelHost={renderState.panelHost}
@@ -405,22 +336,3 @@ 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,8 +30,6 @@ type ContextualTourOverlaySurfaceProps = {
activeTourId: ContextualTourId
renderState: ActiveTourRenderState
panelRef: RefObject<HTMLElement | null>
spotlightRect: SpotlightRect
spotlightHostRect: SpotlightRect | null
panelPosition: PanelPositionStyle
panelPlacement: ContextualTourPanelPlacement | null
panelHost: HTMLElement | null
@@ -41,14 +39,6 @@ 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
@@ -68,8 +58,6 @@ export function ContextualTourOverlaySurface({
activeTourId,
renderState,
panelRef,
spotlightRect,
spotlightHostRect,
panelPosition,
panelPlacement,
panelHost,
@@ -144,96 +132,24 @@ export function ContextualTourOverlaySurface({
</section>
)
const spotlight = panelHost ? (
spotlightHostRect ? (
<ContextualTourSpotlight rect={spotlightHostRect} hosted />
) : null
) : (
<ContextualTourSpotlight rect={spotlightRect} hosted={false} />
)
return (
<div
className={cn(
// Why: the overlay must let pointer events reach the highlighted
// surface so the user can still notice the target while reading.
// Only the panel itself captures interaction.
// Why: tours are callouts, not modality. Let pointer events reach the
// underlying surface; only the panel itself captures interaction.
'fixed inset-0 z-[70] pointer-events-none'
)}
data-contextual-tour-overlay=""
role="presentation"
onKeyDownCapture={onOverlayKeyDownCapture}
>
{panelHost ? null : spotlight}
<div className="pointer-events-auto">
{panelHost
? createPortal(
<>
{spotlight}
{panel}
</>,
panelHost
)
: panel}
{panelHost ? createPortal(panel, panelHost) : panel}
</div>
</div>
)
}
function ContextualTourSpotlight({
rect,
hosted
}: {
rect: SpotlightRect
hosted: boolean
}): JSX.Element {
if (hosted) {
return (
<div
aria-hidden="true"
data-contextual-tour-spotlight=""
data-contextual-tour-spotlight-hosted="true"
className="contextual-tour-hosted-scrim absolute"
/>
)
}
// 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 maskId = 'contextual-tour-spotlight-mask-fixed'
return (
<div
aria-hidden="true"
data-contextual-tour-spotlight=""
className="contextual-tour-spotlight fixed"
>
<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>
)
}
function ContextualTourProgressDots({
current,
total
@@ -7,7 +7,6 @@ const WORKSPACE_BOARD_KEEP_OPEN_SELECTOR = [
'[data-workspace-status-appearance-popover]',
'[data-contextual-tour-overlay]',
'[data-contextual-tour-panel]',
'[data-contextual-tour-spotlight]',
'[data-radix-popper-content-wrapper]',
'[data-slot="dropdown-menu-content"]',
'[data-slot="context-menu-content"]',