mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 00:03:46 +00:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user