diff --git a/apps/studio/components/layouts/Navigation/LayoutHeader/LayoutHeader.tsx b/apps/studio/components/layouts/Navigation/LayoutHeader/LayoutHeader.tsx index 0efbfa33ac8..b79a51dc1f2 100644 --- a/apps/studio/components/layouts/Navigation/LayoutHeader/LayoutHeader.tsx +++ b/apps/studio/components/layouts/Navigation/LayoutHeader/LayoutHeader.tsx @@ -1,6 +1,5 @@ import { LOCAL_STORAGE_KEYS, useParams } from 'common' import dayjs from 'dayjs' -import { DevToolbarTrigger } from 'dev-tools' import { AnimatePresence, motion } from 'framer-motion' import { ChevronLeft } from 'lucide-react' import Link from 'next/link' @@ -224,7 +223,6 @@ export const LayoutHeader = ({ {customHeaderComponents && customHeaderComponents} {IS_PLATFORM ? ( <> -
diff --git a/apps/studio/pages/_app.tsx b/apps/studio/pages/_app.tsx index f5524da0dd5..bbbf975b556 100644 --- a/apps/studio/pages/_app.tsx +++ b/apps/studio/pages/_app.tsx @@ -36,7 +36,7 @@ import duration from 'dayjs/plugin/duration' import relativeTime from 'dayjs/plugin/relativeTime' import timezone from 'dayjs/plugin/timezone' import utc from 'dayjs/plugin/utc' -import { DevToolbar, DevToolbarProvider } from 'dev-tools' +import { DevToolbar, DevToolbarProvider, DevToolbarTrigger } from 'dev-tools' import Head from 'next/head' import { NuqsAdapter } from 'nuqs/adapters/next/pages' import { ErrorInfo, useCallback, type ComponentProps } from 'react' @@ -191,6 +191,7 @@ function CustomApp({ Component, pageProps }: AppPropsWithLayout) { + diff --git a/apps/studio/public/img/logo-pixel-small-light.png b/apps/studio/public/img/logo-pixel-small-light.png new file mode 100644 index 00000000000..a99090b97ed Binary files /dev/null and b/apps/studio/public/img/logo-pixel-small-light.png differ diff --git a/packages/common/configcat.ts b/packages/common/configcat.ts index 0316badb37f..a23d9defbf4 100644 --- a/packages/common/configcat.ts +++ b/packages/common/configcat.ts @@ -73,7 +73,11 @@ export async function getFlags(userEmail: string = '', customAttributes?: Record if (!client) { return [] - } else if (userEmail) { + } + + await client.waitForReady() + + if (userEmail) { return client.getAllValuesAsync( new configcat.User(userEmail, undefined, undefined, _customAttributes) ) diff --git a/packages/common/feature-flags.tsx b/packages/common/feature-flags.tsx index fd8bfe9d7b9..8cac94f0386 100644 --- a/packages/common/feature-flags.tsx +++ b/packages/common/feature-flags.tsx @@ -145,8 +145,8 @@ export const FeatureFlagProvider = ({ let flagStore: FeatureFlagContextType = { configcat: {}, posthog: {} } - // Run both async operations in parallel - const [flags, flagValues] = await Promise.all([ + // Run both async operations in parallel — allSettled so a failure in one doesn't block the other + const [phResult, ccResult] = await Promise.allSettled([ loadPHFlags ? (async () => { await ensureGroupContext() @@ -163,7 +163,17 @@ export const FeatureFlagProvider = ({ : Promise.resolve([]), ]) - const isLocalDev = process.env.NODE_ENV === 'development' + const flags = phResult.status === 'fulfilled' ? phResult.value : {} + if (phResult.status === 'rejected') { + console.warn('[FeatureFlags] PostHog flags failed', phResult.reason) + } + const flagValues = ccResult.status === 'fulfilled' ? ccResult.value : [] + + // Dev toolbar flag overrides are available in local dev and staging. + // Duplicated for tree-shaking — bundler must see literal process.env reference. + // Keep in sync: dev-tools/index.ts, DevToolbarContext.tsx, DevToolbar.tsx, DevToolbarTrigger.tsx + const env = process.env.NEXT_PUBLIC_ENVIRONMENT + const isDevToolsEnabled = env === 'local' || env === 'staging' const safeParse = (value: string | undefined): Record => { if (!value) return {} @@ -176,8 +186,8 @@ export const FeatureFlagProvider = ({ // Process PostHog flags if loaded if (Object.keys(flags).length > 0) { - // Apply local dev overrides for PostHog flags - if (isLocalDev) { + // Apply dev toolbar overrides for PostHog flags + if (isDevToolsEnabled) { try { const cookies = getCookies() const phOverrides = safeParse(cookies['x-ph-flag-overrides']) @@ -196,10 +206,10 @@ export const FeatureFlagProvider = ({ try { const cookies = getCookies() - // Merge overrides: vercel-flag-overrides first, then x-cc-flag-overrides (local only) - // x-cc-flag-overrides takes precedence in local dev + // Merge overrides: vercel-flag-overrides first, then x-cc-flag-overrides (dev toolbar only) + // x-cc-flag-overrides takes precedence when dev tools are enabled const vercelOverrides = safeParse(cookies['vercel-flag-overrides']) - const ccOverrides = isLocalDev ? safeParse(cookies['x-cc-flag-overrides']) : {} + const ccOverrides = isDevToolsEnabled ? safeParse(cookies['x-cc-flag-overrides']) : {} overridesCookieValue = { ...vercelOverrides, diff --git a/packages/dev-tools/DevToolbar.test.tsx b/packages/dev-tools/DevToolbar.test.tsx index 0029db6dfd3..7e17c155569 100644 --- a/packages/dev-tools/DevToolbar.test.tsx +++ b/packages/dev-tools/DevToolbar.test.tsx @@ -23,7 +23,7 @@ vi.mock('common', async () => { } }) -const originalEnv = process.env.NODE_ENV +const originalEnv = process.env.NEXT_PUBLIC_ENVIRONMENT /** * Helper to render the full component tree as used in production. @@ -35,12 +35,15 @@ async function renderFullToolbar() { const { DevToolbarProvider } = await import('./DevToolbarContext') const { DevToolbarTrigger } = await import('./DevToolbarTrigger') const { DevToolbar } = await import('./DevToolbar') + const { TooltipProvider } = await import('ui') return render( - - - - + + + + + + ) } @@ -76,14 +79,18 @@ describe('DevToolbar', () => { }) afterEach(() => { - process.env.NODE_ENV = originalEnv + if (originalEnv === undefined) { + delete process.env.NEXT_PUBLIC_ENVIRONMENT + } else { + process.env.NEXT_PUBLIC_ENVIRONMENT = originalEnv + } vi.resetModules() vi.restoreAllMocks() }) describe('when not in local development', () => { beforeEach(() => { - process.env.NODE_ENV = 'production' + process.env.NEXT_PUBLIC_ENVIRONMENT = 'prod' }) it('returns null and does not render anything', async () => { @@ -104,7 +111,7 @@ describe('DevToolbar', () => { describe('when in local development but not enabled', () => { beforeEach(() => { - process.env.NODE_ENV = 'development' + process.env.NEXT_PUBLIC_ENVIRONMENT = 'local' }) it('does not render trigger when toolbar is not enabled', async () => { @@ -126,7 +133,7 @@ describe('DevToolbar', () => { describe('when in local development and enabled', () => { beforeEach(() => { - process.env.NODE_ENV = 'development' + process.env.NEXT_PUBLIC_ENVIRONMENT = 'local' localStorage.setItem('dev-telemetry-toolbar-enabled', 'true') }) @@ -149,7 +156,7 @@ describe('DevToolbar', () => { // Sheet should open with the title await waitFor(() => { - expect(screen.getByText('Dev Telemetry')).toBeInTheDocument() + expect(screen.getByText('Dev Toolbar')).toBeInTheDocument() }) }) @@ -166,8 +173,22 @@ describe('DevToolbar', () => { expect(screen.getByRole('tab', { name: /Flags/i })).toBeInTheDocument() }) }) + }) - it('shows "Local Only" badge in toolbar header', async () => { + describe('when in staging environment and enabled', () => { + beforeEach(() => { + process.env.NEXT_PUBLIC_ENVIRONMENT = 'staging' + localStorage.setItem('dev-telemetry-toolbar-enabled', 'true') + }) + + it('renders the toolbar trigger', async () => { + vi.resetModules() + await renderFullToolbar() + const triggerButton = screen.getByRole('button') + expect(triggerButton).toBeInTheDocument() + }) + + it('shows server events notice in events tab', async () => { vi.resetModules() const user = userEvent.setup() await renderFullToolbar() @@ -176,14 +197,30 @@ describe('DevToolbar', () => { await user.click(triggerButton) await waitFor(() => { - expect(screen.getByText('Local Only')).toBeInTheDocument() + expect( + screen.getByText( + 'Server-side events are only visible when using the toolbar in local development' + ) + ).toBeInTheDocument() }) }) + + it('does not connect to SSE in staging', async () => { + const EventSourceSpy = vi.fn() + vi.stubGlobal('EventSource', EventSourceSpy) + + vi.resetModules() + await renderFullToolbar() + + expect(EventSourceSpy).not.toHaveBeenCalled() + + vi.unstubAllGlobals() + }) }) describe('window.devTelemetry function', () => { beforeEach(() => { - process.env.NODE_ENV = 'development' + process.env.NEXT_PUBLIC_ENVIRONMENT = 'local' }) it('enables toolbar when called', async () => { @@ -203,12 +240,15 @@ describe('DevToolbar', () => { const { DevToolbarProvider } = await import('./DevToolbarContext') const { DevToolbarTrigger } = await import('./DevToolbarTrigger') const { DevToolbar } = await import('./DevToolbar') + const { TooltipProvider } = await import('ui') rerender( - - - - + + + + + + ) expect(localStorage.getItem('dev-telemetry-toolbar-enabled')).toBe('true') @@ -217,7 +257,7 @@ describe('DevToolbar', () => { describe('cleanup', () => { it('removes window.devTelemetry on unmount', async () => { - process.env.NODE_ENV = 'development' + process.env.NEXT_PUBLIC_ENVIRONMENT = 'local' vi.resetModules() const result = await renderFullToolbar() @@ -232,7 +272,7 @@ describe('DevToolbar', () => { describe('EventCard keyboard accessibility', () => { beforeEach(() => { - process.env.NODE_ENV = 'development' + process.env.NEXT_PUBLIC_ENVIRONMENT = 'local' localStorage.setItem('dev-telemetry-toolbar-enabled', 'true') }) @@ -246,7 +286,7 @@ describe('DevToolbar', () => { await user.click(triggerButton) await waitFor(() => { - expect(screen.getByText('Dev Telemetry')).toBeInTheDocument() + expect(screen.getByText('Dev Toolbar')).toBeInTheDocument() }) // Events tab should be active by default and show empty state @@ -256,7 +296,7 @@ describe('DevToolbar', () => { describe('Flag override UI', () => { beforeEach(() => { - process.env.NODE_ENV = 'development' + process.env.NEXT_PUBLIC_ENVIRONMENT = 'local' localStorage.setItem('dev-telemetry-toolbar-enabled', 'true') }) @@ -277,8 +317,8 @@ describe('DevToolbar', () => { await user.click(flagsTab) await waitFor(() => { - expect(screen.getByRole('tab', { name: /PostHog/i })).toBeInTheDocument() - expect(screen.getByRole('tab', { name: /ConfigCat/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /PostHog/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /ConfigCat/i })).toBeInTheDocument() }) }) }) diff --git a/packages/dev-tools/DevToolbar.tsx b/packages/dev-tools/DevToolbar.tsx index 3fcc9eac762..1a57be0399b 100644 --- a/packages/dev-tools/DevToolbar.tsx +++ b/packages/dev-tools/DevToolbar.tsx @@ -1,40 +1,44 @@ 'use client' import { useFeatureFlags } from 'common' -import { Activity, ChevronDown, ChevronUp, Flag } from 'lucide-react' +import { Copy, EyeOff, Search, X } from 'lucide-react' +import Image from 'next/image' import { - type ChangeEvent, - type Dispatch, - type SetStateAction, useCallback, useEffect, useState, + type ChangeEvent, + type Dispatch, + type SetStateAction, } from 'react' import { Badge, Button, - Input_Shadcn_ as Input, + cn, + Input, Sheet, + SheetClose, SheetContent, SheetDescription, SheetHeader, SheetTitle, Switch, Tabs_Shadcn_ as Tabs, - TabsContent_Shadcn_ as TabsContent, TabsList_Shadcn_ as TabsList, TabsTrigger_Shadcn_ as TabsTrigger, - cn, + Tooltip, + TooltipContent, + TooltipTrigger, } from 'ui' import { useDevToolbar } from './DevToolbarContext' import type { DevTelemetryEvent } from './types' import { CC_ORIGINALS_KEY, - PH_ORIGINALS_KEY, deleteCookie, getCookie, parseOverrideValue, + PH_ORIGINALS_KEY, readOriginals, safeJsonParse, setCookie, @@ -42,51 +46,88 @@ import { writeOriginals, } from './utils' -const IS_LOCAL_DEV = process.env.NODE_ENV === 'development' +// Duplicated for tree-shaking — bundler must see literal process.env reference. +// Keep in sync: index.ts, DevToolbarContext.tsx, DevToolbarTrigger.tsx, feature-flags.tsx +const env = process.env.NEXT_PUBLIC_ENVIRONMENT +const IS_TOOLBAR_ENABLED = env === 'local' || env === 'staging' +const IS_LOCAL_DEV = env === 'local' -function EventCard({ event }: { event: DevTelemetryEvent }) { +function EventRow({ event }: { event: DevTelemetryEvent }) { const [isExpanded, setIsExpanded] = useState(false) - const handleToggle = () => setIsExpanded((prev) => !prev) + const time = new Date(event.timestamp).toLocaleTimeString('en-US', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }) + + const handleCopy = (e: React.MouseEvent) => { + e.stopPropagation() + navigator.clipboard + .writeText(JSON.stringify({ name: event.eventName, properties: event.properties }, null, 2)) + .catch((error) => console.warn('Copy failed', error)) + } return ( -
-
- )} -
-
- - {new Date(event.timestamp).toLocaleTimeString()} + {event.source} + - {isExpanded ? : } -
- + + {time} + + {event.eventType} + + + {event.eventName} + + {event.distinctId && ( + + {event.distinctId} + + )} + + +
+ + + + + Copy JSON + +
+ {isExpanded && ( -
+        
           {JSON.stringify(event.properties, null, 2)}
         
)} @@ -94,7 +135,7 @@ function EventCard({ event }: { event: DevTelemetryEvent }) { ) } -function FlagCard({ +function FlagRow({ flagName, currentValue, originalValue, @@ -110,30 +151,44 @@ function FlagCard({ const valueType = typeof originalValue const isNull = originalValue === null const inputProps = { + size: 'tiny' as const, value: String(currentValue), onChange: (event: ChangeEvent) => onToggle(event.target.value), className: 'w-32', } return ( -
-
-
- {flagName} - {isOverridden && ( - - Overridden - - )} - {isNull && ( - - null - - )} +
+
+
+
+ + {flagName} + + {isOverridden && ( + + Overridden + + )} + {isNull && ( + + null + + )} +
+
+ Original:{' '} + {JSON.stringify(originalValue)} +
{isNull ? ( - + ) : valueType === 'boolean' ? ( )}
- - {isOverridden && ( -
- Original:{' '} - {JSON.stringify(originalValue)} -
- )}
) } export function DevToolbar() { - const { isEnabled, isOpen, setIsOpen, events, setEvents } = useDevToolbar() + const { isEnabled, isOpen, setIsOpen, events, setEvents, dismissToolbar } = useDevToolbar() const [activeTab, setActiveTab] = useState('events') const [flagsSubTab, setFlagsSubTab] = useState<'posthog' | 'configcat'>('posthog') const [eventFilter, setEventFilter] = useState('') @@ -218,6 +266,24 @@ export function DevToolbar() { setCcFlagOriginals(readOriginals(CC_ORIGINALS_KEY)) }, []) + useEffect(() => { + const STYLE_ID = 'dev-toolbar-hide-native-devtools' + const existing = document.getElementById(STYLE_ID) + + if (isOpen) { + if (!existing) { + const style = document.createElement('style') + style.id = STYLE_ID + style.textContent = ` + .tsqd-open-btn, .tsqd-open-btn-container { display: none !important; } + ` + document.head.appendChild(style) + } + } else { + existing?.remove() + } + }, [isOpen]) + const updatePhOriginals = updateOriginals(PH_ORIGINALS_KEY, setPhFlagOriginals) const updateCcOriginals = updateOriginals(CC_ORIGINALS_KEY, setCcFlagOriginals) @@ -284,7 +350,7 @@ export function DevToolbar() { const ccOverrideCount = Object.keys(ccFlagOverrides).length const totalOverrideCount = phOverrideCount + ccOverrideCount - if (!IS_LOCAL_DEV || !isEnabled) return null + if (!IS_TOOLBAR_ENABLED || !isEnabled) return null return ( @@ -292,146 +358,203 @@ export function DevToolbar() { side="bottom" size="lg" className="flex flex-col p-0 gap-0 overflow-hidden" + showClose={false} + hasOverlay={false} onOpenAutoFocus={(event) => event.preventDefault()} > - -
- - Dev Telemetry - Local Only -
- - View telemetry events and feature flags for local development - -
+ + + Dev Toolbar + + View telemetry events and feature flags for local development + +
+ + + + Events ({filteredEvents.length}) + + + Flags {totalOverrideCount > 0 && `(${totalOverrideCount})`} + + +
+ + +
+
+
-
- - - - - Events ({filteredEvents.length}) - - - - Flags {totalOverrideCount > 0 && `(${totalOverrideCount} overrides)`} - - +
{activeTab === 'events' && ( -
-
+
+
setEventFilter(e.target.value)} - className="flex-1" + icon={} + className="flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 max-w-96" /> -
-
+ {!IS_LOCAL_DEV && ( +
+ Server-side events are only visible when using the toolbar in local development +
+ )} + +
{filteredEvents.length === 0 ? ( -
+
No events yet. Interact with the app to see telemetry events.
) : ( - filteredEvents.map((event) => ( - - )) +
+ {filteredEvents.map((event) => ( + + ))} +
)}
)} {activeTab === 'flags' && ( -
- {totalOverrideCount > 0 && ( -
- - {totalOverrideCount} flag(s) overridden - {phOverrideCount > 0 && ccOverrideCount > 0 - ? ` (${phOverrideCount} PostHog, ${ccOverrideCount} ConfigCat)` - : ''} - - +
+ {/* Sidebar */} +
+ + {totalOverrideCount > 0 && ( +
+ +
+ )} +
+ + {/* Flag list */} +
+
+ {flagsSubTab === 'posthog' && + (Object.keys(posthogFlags).length === 0 ? ( +
+ No PostHog feature flags loaded yet. +
+ ) : ( + Object.entries(posthogFlags).map(([flagName, flagValue]) => ( + togglePhFlagOverride(flagName, value)} + /> + )) + ))} + {flagsSubTab === 'configcat' && + (Object.keys(configcatFlags).length === 0 ? ( +
+ No ConfigCat feature flags loaded yet. +
+ ) : ( + Object.entries(configcatFlags).map(([flagName, flagValue]) => ( + toggleCcFlagOverride(flagName, value)} + /> + )) + ))}
- )} - - setFlagsSubTab(v as 'posthog' | 'configcat')} - className="flex-1 flex flex-col min-h-0 overflow-hidden" - > - - - PostHog {phOverrideCount > 0 && `(${phOverrideCount})`} - - - ConfigCat {ccOverrideCount > 0 && `(${ccOverrideCount})`} - - - - {flagsSubTab === 'posthog' && ( -
-
- {Object.keys(posthogFlags).length === 0 ? ( -
- No PostHog feature flags loaded yet. -
- ) : ( - Object.entries(posthogFlags).map(([flagName, flagValue]) => ( - togglePhFlagOverride(flagName, value)} - /> - )) - )} -
-
- )} - {flagsSubTab === 'configcat' && ( -
-
- {Object.keys(configcatFlags).length === 0 ? ( -
- No ConfigCat feature flags loaded yet. -
- ) : ( - Object.entries(configcatFlags).map(([flagName, flagValue]) => ( - toggleCcFlagOverride(flagName, value)} - /> - )) - )} -
-
- )} -
+
)} - -
+
+ ) diff --git a/packages/dev-tools/DevToolbarContext.tsx b/packages/dev-tools/DevToolbarContext.tsx index e1ef961fd97..0c46a1d0323 100644 --- a/packages/dev-tools/DevToolbarContext.tsx +++ b/packages/dev-tools/DevToolbarContext.tsx @@ -1,14 +1,14 @@ 'use client' -import { type ClientTelemetryEvent, ensurePlatformSuffix, posthogClient } from 'common' +import { ensurePlatformSuffix, posthogClient, type ClientTelemetryEvent } from 'common' import { - type ReactNode, createContext, useCallback, useContext, useEffect, useRef, useState, + type ReactNode, } from 'react' import type { @@ -18,7 +18,11 @@ import type { } from './types' import { getCookie } from './utils' -const IS_LOCAL_DEV = process.env.NODE_ENV === 'development' +// Duplicated for tree-shaking — bundler must see literal process.env reference. +// Keep in sync: index.ts, DevToolbar.tsx, DevToolbarTrigger.tsx, feature-flags.tsx +const env = process.env.NEXT_PUBLIC_ENVIRONMENT +const IS_TOOLBAR_ENABLED = env === 'local' || env === 'staging' +const IS_LOCAL_DEV = env === 'local' const MAX_EVENTS = 200 const STORAGE_KEY = 'dev-telemetry-toolbar-enabled' @@ -56,7 +60,7 @@ export function DevToolbarProvider({ children, apiUrl }: DevToolbarProviderProps }, []) useEffect(() => { - if (!IS_LOCAL_DEV) return + if (!IS_TOOLBAR_ENABLED) return let stored: string | null = null try { @@ -105,7 +109,7 @@ export function DevToolbarProvider({ children, apiUrl }: DevToolbarProviderProps }, [appendEvent, isEnabled]) useEffect(() => { - if (!isEnabled || typeof EventSource === 'undefined') return + if (!IS_LOCAL_DEV || !isEnabled || typeof EventSource === 'undefined') return let eventSource: EventSource | null = null let isMounted = true @@ -176,7 +180,7 @@ export function DevToolbarProvider({ children, apiUrl }: DevToolbarProviderProps } }, [apiUrl, appendEvent, isEnabled]) - if (!IS_LOCAL_DEV) { + if (!IS_TOOLBAR_ENABLED) { return <>{children} } diff --git a/packages/dev-tools/DevToolbarTrigger.tsx b/packages/dev-tools/DevToolbarTrigger.tsx index 1a64eb43348..f6548c3c01c 100644 --- a/packages/dev-tools/DevToolbarTrigger.tsx +++ b/packages/dev-tools/DevToolbarTrigger.tsx @@ -1,113 +1,258 @@ 'use client' -import { Activity, EyeOff } from 'lucide-react' -import { useEffect, useRef, useState } from 'react' -import type { MouseEvent } from 'react' -import { Button, PopoverContent_Shadcn_, PopoverTrigger_Shadcn_, Popover_Shadcn_, cn } from 'ui' +import Image from 'next/image' +import { useCallback, useEffect, useRef, useState } from 'react' +import type { CSSProperties, PointerEvent } from 'react' +import { Button, cn } from 'ui' import { useDevToolbar } from './DevToolbarContext' -const IS_LOCAL_DEV = process.env.NODE_ENV === 'development' +// Duplicated for tree-shaking — bundler must see literal process.env reference. +// Keep in sync: index.ts, DevToolbarContext.tsx, DevToolbar.tsx, feature-flags.tsx +const env = process.env.NEXT_PUBLIC_ENVIRONMENT +const IS_TOOLBAR_ENABLED = env === 'local' || env === 'staging' +const POSITION_STORAGE_KEY = 'dev-telemetry-toolbar-position' +const DRAG_THRESHOLD = 4 +const MARGIN = 24 +const BUTTON_SIZE = 40 // h-10 w-10 + +// Spring easing: slight overshoot then settle +const SNAP_TRANSITION = + 'top 380ms cubic-bezier(0.34, 1.56, 0.64, 1), left 380ms cubic-bezier(0.34, 1.56, 0.64, 1)' + +type SnapPosition = + | 'top-left' + | 'top-center' + | 'top-right' + | 'middle-left' + | 'middle-center' + | 'middle-right' + | 'bottom-left' + | 'bottom-center' + | 'bottom-right' + +// All positions expressed as pixel top+left so transitions interpolate cleanly +function getSnapCoords( + position: SnapPosition, + vw: number, + vh: number +): { top: number; left: number } { + const [row, col] = position.split('-') + const top = + row === 'top' + ? MARGIN + : row === 'bottom' + ? vh - MARGIN - BUTTON_SIZE + : Math.round(vh / 2 - BUTTON_SIZE / 2) + const left = + col === 'left' + ? MARGIN + : col === 'right' + ? vw - MARGIN - BUTTON_SIZE + : Math.round(vw / 2 - BUTTON_SIZE / 2) + return { top, left } +} + +function getNearestSnapPosition(cx: number, cy: number): SnapPosition { + const vw = window.innerWidth + const vh = window.innerHeight + const row = cy < vh / 3 ? 'top' : cy > (2 * vh) / 3 ? 'bottom' : 'middle' + const col = cx < vw / 3 ? 'left' : cx > (2 * vw) / 3 ? 'right' : 'center' + return `${row}-${col}` as SnapPosition +} + +function readStoredPosition(): SnapPosition { + if (typeof window === 'undefined') return 'bottom-right' + return (localStorage.getItem(POSITION_STORAGE_KEY) as SnapPosition) ?? 'bottom-right' +} export function DevToolbarTrigger() { - const { isEnabled, isOpen, setIsOpen, events, dismissToolbar } = useDevToolbar() - const [popoverOpen, setPopoverOpen] = useState(false) - const closeTimeoutRef = useRef(null) + const { isEnabled, isOpen, setIsOpen, events } = useDevToolbar() + const [snapPosition, setSnapPosition] = useState('bottom-right') + const [hasHydrated, setHasHydrated] = useState(false) + const [dragPos, setDragPos] = useState<{ x: number; y: number } | null>(null) + // Holds the last drag pixel position for one RAF to prime the CSS transition + const [releasedAt, setReleasedAt] = useState<{ x: number; y: number } | null>(null) + const [viewport, setViewport] = useState(() => ({ + w: typeof window !== 'undefined' ? window.innerWidth : 1920, + h: typeof window !== 'undefined' ? window.innerHeight : 1080, + })) + const dragRef = useRef<{ + startPointerX: number + startPointerY: number + startButtonX: number + startButtonY: number + hasDragged: boolean + } | null>(null) + const wasDraggingRef = useRef(false) + + // Restore persisted position after mount to avoid SSR hydration mismatch. + // hasHydrated is set via RAF so the correct position is painted before transitions are enabled, + // preventing the spring animation from firing on initial load. useEffect(() => { - return () => { - if (closeTimeoutRef.current) { - clearTimeout(closeTimeoutRef.current) - closeTimeoutRef.current = null - } - } + const stored = readStoredPosition() + if (stored !== 'bottom-right') setSnapPosition(stored) + const id = requestAnimationFrame(() => setHasHydrated(true)) + return () => cancelAnimationFrame(id) }, []) - if (!IS_LOCAL_DEV || !isEnabled) { - return null - } + // Keep snap coords accurate on resize + useEffect(() => { + const onResize = () => setViewport({ w: window.innerWidth, h: window.innerHeight }) + window.addEventListener('resize', onResize) + return () => window.removeEventListener('resize', onResize) + }, []) + + // Two-phase snap: hold last drag position for one frame (primes the transition), + // then clear it so the spring fires from that position to the snap target + useEffect(() => { + if (releasedAt === null) return + const id = requestAnimationFrame(() => setReleasedAt(null)) + return () => cancelAnimationFrame(id) + }, [releasedAt]) + + const handlePointerDown = useCallback((e: PointerEvent) => { + const rect = e.currentTarget.getBoundingClientRect() + dragRef.current = { + startPointerX: e.clientX, + startPointerY: e.clientY, + startButtonX: rect.left, + startButtonY: rect.top, + hasDragged: false, + } + wasDraggingRef.current = false + e.currentTarget.setPointerCapture(e.pointerId) + }, []) + + const handlePointerMove = useCallback((e: PointerEvent) => { + if (!dragRef.current) return + const dx = e.clientX - dragRef.current.startPointerX + const dy = e.clientY - dragRef.current.startPointerY + if (!dragRef.current.hasDragged && Math.hypot(dx, dy) < DRAG_THRESHOLD) return + dragRef.current.hasDragged = true + setDragPos({ + x: dragRef.current.startButtonX + dx, + y: dragRef.current.startButtonY + dy, + }) + }, []) + + const handlePointerUp = useCallback((e: PointerEvent) => { + if (!dragRef.current) return + const { hasDragged } = dragRef.current + dragRef.current = null + wasDraggingRef.current = hasDragged + if (!hasDragged) return + const rect = e.currentTarget.getBoundingClientRect() + const cx = rect.left + rect.width / 2 + const cy = rect.top + rect.height / 2 + const newPosition = getNearestSnapPosition(cx, cy) + setSnapPosition(newPosition) + localStorage.setItem(POSITION_STORAGE_KEY, newPosition) + // Phase 1: park at last drag position with transition primed + setReleasedAt({ x: rect.left, y: rect.top }) + setDragPos(null) + }, []) + + const handlePointerCancel = useCallback(() => { + dragRef.current = null + wasDraggingRef.current = false + setDragPos(null) + setReleasedAt(null) + }, []) + + if (!IS_TOOLBAR_ENABLED || !isEnabled) return null const eventCount = events.length + const isDragging = dragPos !== null + const snapCoords = getSnapCoords(snapPosition, viewport.w, viewport.h) + const FULL_TRANSITION = `${SNAP_TRANSITION}, opacity 200ms ease` + + const containerStyle: CSSProperties = + dragPos !== null + ? { + position: 'fixed', + zIndex: 50, + left: dragPos.x, + top: dragPos.y, + transition: 'none', + opacity: 1, + } + : releasedAt !== null + ? // Phase 1: same pixel position as drag end, transition now defined + { + position: 'fixed', + zIndex: 50, + left: releasedAt.x, + top: releasedAt.y, + transition: FULL_TRANSITION, + opacity: isOpen ? 0 : 1, + pointerEvents: isOpen ? 'none' : undefined, + } + : // Phase 2: spring fires from releasedAt → snapCoords + { + position: 'fixed', + zIndex: 50, + ...snapCoords, + transition: hasHydrated ? FULL_TRANSITION : 'none', + opacity: isOpen ? 0 : 1, + pointerEvents: isOpen ? 'none' : undefined, + } const handleClick = () => { - setPopoverOpen(false) + if (wasDraggingRef.current) { + wasDraggingRef.current = false + return + } setIsOpen(true) } - const handleDismiss = () => { - setPopoverOpen(false) - dismissToolbar() - } - - const handleMouseEnter = () => { - if (closeTimeoutRef.current) { - clearTimeout(closeTimeoutRef.current) - closeTimeoutRef.current = null - } - setPopoverOpen(true) - } - - const handleMouseLeave = () => { - closeTimeoutRef.current = setTimeout(() => { - setPopoverOpen(false) - }, 100) - } - - const handleTriggerMouseLeave = (event: MouseEvent) => { - handleMouseLeave() - event.currentTarget.blur() - } - return ( - - - - - event.preventDefault()} +
+ - - + + {eventCount > 0 && ( + + {eventCount > 99 ? '99+' : eventCount} + + )} + +
) } diff --git a/packages/dev-tools/__mocks__/next-image.tsx b/packages/dev-tools/__mocks__/next-image.tsx new file mode 100644 index 00000000000..ec32634bea1 --- /dev/null +++ b/packages/dev-tools/__mocks__/next-image.tsx @@ -0,0 +1,8 @@ +import React from 'react' + +function MockImage(props: Record) { + // eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text + return )} /> +} + +export default MockImage diff --git a/packages/dev-tools/index.ts b/packages/dev-tools/index.ts index e682df49fa3..ff32f4a13ff 100644 --- a/packages/dev-tools/index.ts +++ b/packages/dev-tools/index.ts @@ -5,10 +5,14 @@ import * as DevToolbarContextModule from './DevToolbarContext' import * as DevToolbarTriggerModule from './DevToolbarTrigger' import type { DevTelemetryToolbarContextType } from './types' -// Tree-shaking pattern: conditionally export stubs in production -// The bundler replaces process.env.NODE_ENV at build time, making the -// ternary static. Combined with sideEffects: false, the implementation -// modules are eliminated from the production bundle. +// Tree-shaking pattern: conditionally export stubs outside local/staging. +// The bundler replaces NEXT_PUBLIC_ENVIRONMENT at build time, making the +// ternary static. In production builds (env === 'prod'), the implementation +// modules are eliminated from the bundle. +// Duplicated for tree-shaking — bundler must see literal process.env reference. +// Keep in sync: DevToolbarContext.tsx, DevToolbar.tsx, DevToolbarTrigger.tsx, feature-flags.tsx +const env = process.env.NEXT_PUBLIC_ENVIRONMENT +const isToolbarEnabled = env === 'local' || env === 'staging' const noopContext: DevTelemetryToolbarContextType = { isEnabled: false, @@ -19,18 +23,18 @@ const noopContext: DevTelemetryToolbarContextType = { dismissToolbar: () => {}, } -export const DevToolbarProvider = - process.env.NODE_ENV !== 'development' - ? ({ children }: { children: ReactNode; apiUrl?: string }) => children - : DevToolbarContextModule.DevToolbarProvider +export const DevToolbarProvider = !isToolbarEnabled + ? ({ children }: { children: ReactNode; apiUrl?: string }) => children + : DevToolbarContextModule.DevToolbarProvider -export const useDevToolbar = - process.env.NODE_ENV !== 'development' ? () => noopContext : DevToolbarContextModule.useDevToolbar +export const useDevToolbar = !isToolbarEnabled + ? () => noopContext + : DevToolbarContextModule.useDevToolbar -export const DevToolbar = - process.env.NODE_ENV !== 'development' ? () => null : DevToolbarModule.DevToolbar +export const DevToolbar = !isToolbarEnabled ? () => null : DevToolbarModule.DevToolbar -export const DevToolbarTrigger = - process.env.NODE_ENV !== 'development' ? () => null : DevToolbarTriggerModule.DevToolbarTrigger +export const DevToolbarTrigger = !isToolbarEnabled + ? () => null + : DevToolbarTriggerModule.DevToolbarTrigger export type { DevTelemetryEvent, DevToolbarConfig } from './types' diff --git a/packages/dev-tools/package.json b/packages/dev-tools/package.json index 1a8558d6212..fca2ebf7d8f 100644 --- a/packages/dev-tools/package.json +++ b/packages/dev-tools/package.json @@ -33,6 +33,7 @@ "vitest": "catalog:" }, "peerDependencies": { + "next": "catalog:", "react": "catalog:", "react-dom": "catalog:" } diff --git a/packages/dev-tools/vitest.config.ts b/packages/dev-tools/vitest.config.ts index 489cb49b282..699e17e79ee 100644 --- a/packages/dev-tools/vitest.config.ts +++ b/packages/dev-tools/vitest.config.ts @@ -1,6 +1,12 @@ +import path from 'path' import { defineConfig } from 'vitest/config' export default defineConfig({ + resolve: { + alias: { + 'next/image': path.resolve(__dirname, './__mocks__/next-image.tsx'), + }, + }, test: { environment: 'jsdom', setupFiles: ['./vitest.setup.ts'], diff --git a/packages/dev-tools/vitest.setup.ts b/packages/dev-tools/vitest.setup.ts index 3d8e21849f6..cf425062246 100644 --- a/packages/dev-tools/vitest.setup.ts +++ b/packages/dev-tools/vitest.setup.ts @@ -1,6 +1,7 @@ /// import '@testing-library/jest-dom/vitest' + import { cleanup } from '@testing-library/react' import { afterEach, vi } from 'vitest' @@ -20,6 +21,10 @@ Object.defineProperty(window, 'matchMedia', { vi.mock('next/navigation', () => require('next-router-mock/navigation')) +// jsdom doesn't implement pointer capture methods +Element.prototype.setPointerCapture = vi.fn() +Element.prototype.releasePointerCapture = vi.fn() + afterEach(() => { cleanup() }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24263a6d11d..999ad2c5d6e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2253,6 +2253,9 @@ importers: lucide-react: specifier: '*' version: 0.511.0(react@18.3.1) + next: + specifier: 'catalog:' + version: 16.2.3(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) react: specifier: 'catalog:' version: 18.3.1