mirror of
https://github.com/stablyai/orca.git
synced 2026-10-15 16:02:49 +00:00
@@ -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"]',
|
||||
|
||||
Reference in New Issue
Block a user