feat(know-your-world): fix magnifier outline aspect ratio and add visual debug toggle
Magnifier outline fix: - Create shared magnifierDimensions.ts utility for responsive magnifier sizing - Adjust magnifier viewBox to match container aspect ratio (eliminates letterboxing) - Fix dotted outline dimensions to match magnifier's actual visible region - Fix zoom lines to connect to correct corners matching the adjusted aspect ratio - Fix useMagnifierZoom.ts to use actual magnifier dimensions instead of hardcoded 0.5 Visual debug toggle: - Add VisualDebugContext for global debug flag control (dev mode only) - Add Developer section to hamburger menu with visual debug toggle - Wire up all debug visualizations (bounding boxes, safe zones, magnifier info) - Persist preference to localStorage 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -136,10 +136,10 @@ export function GameInfoPanel({
|
||||
|
||||
// During give-up animation, show the given-up region's name instead of the next region
|
||||
const displayRegionName = isGiveUpAnimating
|
||||
? state.giveUpReveal?.regionName ?? currentRegionName
|
||||
? (state.giveUpReveal?.regionName ?? currentRegionName)
|
||||
: currentRegionName
|
||||
const displayRegionId = isGiveUpAnimating
|
||||
? state.giveUpReveal?.regionId ?? currentRegionId
|
||||
? (state.giveUpReveal?.regionId ?? currentRegionId)
|
||||
: currentRegionId
|
||||
|
||||
// Get flag emoji for the displayed region (not necessarily the current prompt)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { css } from '@styled/css'
|
||||
import { forceCollide, forceSimulation, forceX, forceY, type SimulationNodeDatum } from 'd3-force'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
import { useVisualDebugSafe } from '@/contexts/VisualDebugContext'
|
||||
import type { ContinentId } from '../continents'
|
||||
import { useHotColdFeedback } from '../hooks/useHotColdFeedback'
|
||||
import { useMagnifierZoom } from '../hooks/useMagnifierZoom'
|
||||
@@ -34,6 +35,12 @@ import { useKnowYourWorld } from '../Provider'
|
||||
import type { MapData, MapRegion } from '../types'
|
||||
import { type BoundingBox as DebugBoundingBox, findOptimalZoom } from '../utils/adaptiveZoomSearch'
|
||||
import type { FeedbackType } from '../utils/hotColdPhrases'
|
||||
import {
|
||||
getAdjustedMagnifiedDimensions,
|
||||
getMagnifierDimensions,
|
||||
MAGNIFIER_SIZE_LARGE,
|
||||
MAGNIFIER_SIZE_SMALL,
|
||||
} from '../utils/magnifierDimensions'
|
||||
import {
|
||||
calculateMaxZoomAtThreshold,
|
||||
calculateScreenPixelRatio,
|
||||
@@ -57,10 +64,6 @@ const PRECISION_MODE_THRESHOLD = 20
|
||||
const LABEL_FADE_RADIUS = 150 // pixels - labels within this radius fade
|
||||
const LABEL_MIN_OPACITY = 0.08 // minimum opacity for faded labels
|
||||
|
||||
// Magnifier size ratios - responsive to container aspect ratio
|
||||
const MAGNIFIER_SIZE_SMALL = 1 / 3 // Used for the constrained dimension
|
||||
const MAGNIFIER_SIZE_LARGE = 1 / 2 // Used for the unconstrained dimension
|
||||
|
||||
// Game nav height offset - buttons should appear below the nav when in full-viewport mode
|
||||
const NAV_HEIGHT_OFFSET = 150
|
||||
|
||||
@@ -74,19 +77,6 @@ const SAFE_ZONE_MARGINS: SafeZoneMargins = {
|
||||
left: 0, // Progress at top-left is small, doesn't need full-height margin
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate magnifier dimensions based on container aspect ratio.
|
||||
* - Landscape (wider): 1/3 width, 1/2 height (more vertical space available)
|
||||
* - Portrait (taller): 1/2 width, 1/3 height (more horizontal space available)
|
||||
*/
|
||||
function getMagnifierDimensions(containerWidth: number, containerHeight: number) {
|
||||
const isLandscape = containerWidth > containerHeight
|
||||
return {
|
||||
width: containerWidth * (isLandscape ? MAGNIFIER_SIZE_SMALL : MAGNIFIER_SIZE_LARGE),
|
||||
height: containerHeight * (isLandscape ? MAGNIFIER_SIZE_LARGE : MAGNIFIER_SIZE_SMALL),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get emoji for hot/cold feedback type
|
||||
* Returns emoji that matches the temperature/status of the last feedback
|
||||
@@ -378,6 +368,15 @@ export function MapRenderer({
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
|
||||
// Visual debug mode from global context (only enabled in dev AND when user toggles it on)
|
||||
const { isVisualDebugEnabled } = useVisualDebugSafe()
|
||||
|
||||
// Effective debug flags - combine prop with context
|
||||
// Props allow component-level override, context allows global toggle
|
||||
const effectiveShowDebugBoundingBoxes = showDebugBoundingBoxes && isVisualDebugEnabled
|
||||
const effectiveShowMagnifierDebugInfo = SHOW_MAGNIFIER_DEBUG_INFO && isVisualDebugEnabled
|
||||
const effectiveShowSafeZoneDebug = SHOW_SAFE_ZONE_DEBUG && isVisualDebugEnabled
|
||||
|
||||
// Calculate excluded regions (regions filtered out by size/continent)
|
||||
const excludedRegions = useMemo(() => {
|
||||
// Get full unfiltered map data
|
||||
@@ -2442,7 +2441,7 @@ export function MapRenderer({
|
||||
})}
|
||||
|
||||
{/* Debug: Render bounding boxes (only if enabled) */}
|
||||
{showDebugBoundingBoxes &&
|
||||
{effectiveShowDebugBoundingBoxes &&
|
||||
debugBoundingBoxes.map((bbox) => {
|
||||
// Color based on acceptance and importance
|
||||
// Green = accepted, Orange = high importance, Yellow = medium, Gray = low
|
||||
@@ -2541,7 +2540,13 @@ export function MapRenderer({
|
||||
)
|
||||
const svgOffsetX = svgRect.left - containerRect.left + viewport.letterboxX
|
||||
const cursorSvgX = (cursorPosition.x - svgOffsetX) / viewport.scale + viewBoxX
|
||||
const magnifiedWidth = viewBoxWidth / zoom
|
||||
const { width: magnifiedWidth } = getAdjustedMagnifiedDimensions(
|
||||
viewBoxWidth,
|
||||
viewBoxHeight,
|
||||
zoom,
|
||||
containerRect.width,
|
||||
containerRect.height
|
||||
)
|
||||
return cursorSvgX - magnifiedWidth / 2
|
||||
})}
|
||||
y={zoomSpring.to((zoom: number) => {
|
||||
@@ -2562,18 +2567,42 @@ export function MapRenderer({
|
||||
)
|
||||
const svgOffsetY = svgRect.top - containerRect.top + viewport.letterboxY
|
||||
const cursorSvgY = (cursorPosition.y - svgOffsetY) / viewport.scale + viewBoxY
|
||||
const magnifiedHeight = viewBoxHeight / zoom
|
||||
const { height: magnifiedHeight } = getAdjustedMagnifiedDimensions(
|
||||
viewBoxWidth,
|
||||
viewBoxHeight,
|
||||
zoom,
|
||||
containerRect.width,
|
||||
containerRect.height
|
||||
)
|
||||
return cursorSvgY - magnifiedHeight / 2
|
||||
})}
|
||||
width={zoomSpring.to((zoom: number) => {
|
||||
const containerRect = containerRef.current!.getBoundingClientRect()
|
||||
const viewBoxParts = displayViewBox.split(' ').map(Number)
|
||||
const viewBoxWidth = viewBoxParts[2] || 1000
|
||||
return viewBoxWidth / zoom
|
||||
const viewBoxHeight = viewBoxParts[3] || 1000
|
||||
const { width } = getAdjustedMagnifiedDimensions(
|
||||
viewBoxWidth,
|
||||
viewBoxHeight,
|
||||
zoom,
|
||||
containerRect.width,
|
||||
containerRect.height
|
||||
)
|
||||
return width
|
||||
})}
|
||||
height={zoomSpring.to((zoom: number) => {
|
||||
const containerRect = containerRef.current!.getBoundingClientRect()
|
||||
const viewBoxParts = displayViewBox.split(' ').map(Number)
|
||||
const viewBoxWidth = viewBoxParts[2] || 1000
|
||||
const viewBoxHeight = viewBoxParts[3] || 1000
|
||||
return viewBoxHeight / zoom
|
||||
const { height } = getAdjustedMagnifiedDimensions(
|
||||
viewBoxWidth,
|
||||
viewBoxHeight,
|
||||
zoom,
|
||||
containerRect.width,
|
||||
containerRect.height
|
||||
)
|
||||
return height
|
||||
})}
|
||||
fill="none"
|
||||
stroke={isDark ? '#60a5fa' : '#3b82f6'}
|
||||
@@ -2758,7 +2787,7 @@ export function MapRenderer({
|
||||
})}
|
||||
|
||||
{/* Debug: Bounding box labels as HTML overlays */}
|
||||
{showDebugBoundingBoxes &&
|
||||
{effectiveShowDebugBoundingBoxes &&
|
||||
containerRef.current &&
|
||||
svgRef.current &&
|
||||
debugBoundingBoxes.map((bbox) => {
|
||||
@@ -3003,9 +3032,16 @@ export function MapRenderer({
|
||||
const cursorSvgX = (cursorPosition.x - svgOffsetX) / viewport.scale + viewBoxX
|
||||
const cursorSvgY = (cursorPosition.y - svgOffsetY) / viewport.scale + viewBoxY
|
||||
|
||||
// Magnified view: adaptive zoom (using animated value)
|
||||
const magnifiedWidth = viewBoxWidth / zoom
|
||||
const magnifiedHeight = viewBoxHeight / zoom
|
||||
// Magnified view: adjust dimensions to match magnifier container aspect ratio
|
||||
// This eliminates letterboxing and ensures outline matches what's visible
|
||||
const { width: magnifiedWidth, height: magnifiedHeight } =
|
||||
getAdjustedMagnifiedDimensions(
|
||||
viewBoxWidth,
|
||||
viewBoxHeight,
|
||||
zoom,
|
||||
containerRect.width,
|
||||
containerRect.height
|
||||
)
|
||||
|
||||
// Center the magnified viewBox on the cursor
|
||||
const magnifiedViewBoxX = cursorSvgX - magnifiedWidth / 2
|
||||
@@ -3301,7 +3337,7 @@ export function MapRenderer({
|
||||
})()}
|
||||
|
||||
{/* Debug: Bounding boxes for detected regions in magnifier */}
|
||||
{SHOW_DEBUG_BOUNDING_BOXES &&
|
||||
{effectiveShowDebugBoundingBoxes &&
|
||||
debugBoundingBoxes.map((bbox) => {
|
||||
const importance = bbox.importance ?? 0
|
||||
|
||||
@@ -3333,7 +3369,7 @@ export function MapRenderer({
|
||||
</animated.svg>
|
||||
|
||||
{/* Debug: Bounding box labels as HTML overlays - positioned using animated values */}
|
||||
{SHOW_DEBUG_BOUNDING_BOXES &&
|
||||
{effectiveShowDebugBoundingBoxes &&
|
||||
debugBoundingBoxes.map((bbox) => {
|
||||
const importance = bbox.importance ?? 0
|
||||
let strokeColor = '#888888'
|
||||
@@ -3511,7 +3547,7 @@ export function MapRenderer({
|
||||
}
|
||||
|
||||
// Below threshold - show debug info in dev, simple zoom in prod
|
||||
if (SHOW_MAGNIFIER_DEBUG_INFO) {
|
||||
if (effectiveShowMagnifierDebugInfo) {
|
||||
return `${z.toFixed(1)}× | ${screenPixelRatio.toFixed(1)} px/px`
|
||||
}
|
||||
|
||||
@@ -3589,8 +3625,14 @@ export function MapRenderer({
|
||||
const viewBoxHeight = viewBoxParts[3] || 1000
|
||||
|
||||
const currentZoom = getCurrentZoom()
|
||||
const indicatorWidth = viewBoxWidth / currentZoom
|
||||
const indicatorHeight = viewBoxHeight / currentZoom
|
||||
// Use adjusted dimensions to match magnifier aspect ratio
|
||||
const { width: indicatorWidth, height: indicatorHeight } = getAdjustedMagnifiedDimensions(
|
||||
viewBoxWidth,
|
||||
viewBoxHeight,
|
||||
currentZoom,
|
||||
containerRect.width,
|
||||
containerRect.height
|
||||
)
|
||||
|
||||
// Convert cursor to SVG coordinates (accounting for preserveAspectRatio)
|
||||
const viewport = getRenderedViewport(
|
||||
@@ -3813,7 +3855,7 @@ export function MapRenderer({
|
||||
})()}
|
||||
|
||||
{/* Debug: Auto zoom detection visualization (dev only) */}
|
||||
{SHOW_MAGNIFIER_DEBUG_INFO && cursorPosition && containerRef.current && (
|
||||
{effectiveShowMagnifierDebugInfo && cursorPosition && containerRef.current && (
|
||||
<>
|
||||
{/* Detection box - 50px box around cursor */}
|
||||
<div
|
||||
@@ -4100,7 +4142,7 @@ export function MapRenderer({
|
||||
/>
|
||||
|
||||
{/* Debug overlay showing safe zone rectangles */}
|
||||
{SHOW_SAFE_ZONE_DEBUG &&
|
||||
{effectiveShowSafeZoneDebug &&
|
||||
fillContainer &&
|
||||
(() => {
|
||||
// Calculate the leftover rectangle (viewport minus margins)
|
||||
|
||||
@@ -6,11 +6,12 @@
|
||||
* coordinating with pointer lock state.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, type RefObject } from 'react'
|
||||
import { useSpring, useSpringRef } from '@react-spring/web'
|
||||
import { type RefObject, useEffect, useRef, useState } from 'react'
|
||||
import { getMagnifierDimensions } from '../utils/magnifierDimensions'
|
||||
import {
|
||||
calculateScreenPixelRatio,
|
||||
calculateMaxZoomAtThreshold,
|
||||
calculateScreenPixelRatio,
|
||||
isAboveThreshold,
|
||||
} from '../utils/screenPixelRatio'
|
||||
|
||||
@@ -91,7 +92,10 @@ export function useMagnifierZoom(options: UseMagnifierZoomOptions): UseMagnifier
|
||||
|
||||
const containerRect = containerElement.getBoundingClientRect()
|
||||
const svgRect = svgElement.getBoundingClientRect()
|
||||
const magnifierWidth = containerRect.width * 0.5
|
||||
const { width: magnifierWidth } = getMagnifierDimensions(
|
||||
containerRect.width,
|
||||
containerRect.height
|
||||
)
|
||||
const viewBoxParts = viewBox.split(' ').map(Number)
|
||||
const viewBoxWidth = viewBoxParts[2]
|
||||
|
||||
@@ -135,7 +139,10 @@ export function useMagnifierZoom(options: UseMagnifierZoomOptions): UseMagnifier
|
||||
(() => {
|
||||
const containerRect = containerRef.current.getBoundingClientRect()
|
||||
const svgRect = svgRef.current.getBoundingClientRect()
|
||||
const magnifierWidth = containerRect.width * 0.5
|
||||
const { width: magnifierWidth } = getMagnifierDimensions(
|
||||
containerRect.width,
|
||||
containerRect.height
|
||||
)
|
||||
const viewBoxParts = viewBox.split(' ').map(Number)
|
||||
const viewBoxWidth = viewBoxParts[2]
|
||||
|
||||
@@ -160,7 +167,10 @@ export function useMagnifierZoom(options: UseMagnifierZoomOptions): UseMagnifier
|
||||
(() => {
|
||||
const containerRect = containerRef.current.getBoundingClientRect()
|
||||
const svgRect = svgRef.current.getBoundingClientRect()
|
||||
const magnifierWidth = containerRect.width * 0.5
|
||||
const { width: magnifierWidth } = getMagnifierDimensions(
|
||||
containerRect.width,
|
||||
containerRect.height
|
||||
)
|
||||
const viewBoxParts = viewBox.split(' ').map(Number)
|
||||
const viewBoxWidth = viewBoxParts[2]
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Magnifier Dimension Utilities
|
||||
*
|
||||
* Shared functions for calculating magnifier dimensions that respond to
|
||||
* container aspect ratio. Used by MapRenderer and useMagnifierZoom.
|
||||
*/
|
||||
|
||||
// Magnifier size ratios - responsive to container aspect ratio
|
||||
export const MAGNIFIER_SIZE_SMALL = 1 / 3 // Used for the constrained dimension
|
||||
export const MAGNIFIER_SIZE_LARGE = 1 / 2 // Used for the unconstrained dimension
|
||||
|
||||
/**
|
||||
* Calculate magnifier dimensions based on container aspect ratio.
|
||||
* - Landscape (wider): 1/3 width, 1/2 height (more vertical space available)
|
||||
* - Portrait (taller): 1/2 width, 1/3 height (more horizontal space available)
|
||||
*/
|
||||
export function getMagnifierDimensions(containerWidth: number, containerHeight: number) {
|
||||
const isLandscape = containerWidth > containerHeight
|
||||
return {
|
||||
width: containerWidth * (isLandscape ? MAGNIFIER_SIZE_SMALL : MAGNIFIER_SIZE_LARGE),
|
||||
height: containerHeight * (isLandscape ? MAGNIFIER_SIZE_LARGE : MAGNIFIER_SIZE_SMALL),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the magnified viewBox dimensions that match the magnifier container's aspect ratio.
|
||||
*
|
||||
* The magnifier container has a responsive aspect ratio (varies with screen orientation),
|
||||
* but the map viewBox has a fixed aspect ratio (e.g., 2:1 for world map). Without adjustment,
|
||||
* this causes letterboxing in the magnifier SVG.
|
||||
*
|
||||
* This function expands the viewBox dimensions to match the container's aspect ratio,
|
||||
* eliminating letterboxing and ensuring the outline on the main map matches exactly
|
||||
* what's visible in the magnifier.
|
||||
*
|
||||
* @param viewBoxWidth - The map's viewBox width
|
||||
* @param viewBoxHeight - The map's viewBox height
|
||||
* @param zoom - Current zoom level
|
||||
* @param containerWidth - The game container's width in pixels
|
||||
* @param containerHeight - The game container's height in pixels
|
||||
* @returns Adjusted width and height for the magnified viewBox
|
||||
*/
|
||||
export function getAdjustedMagnifiedDimensions(
|
||||
viewBoxWidth: number,
|
||||
viewBoxHeight: number,
|
||||
zoom: number,
|
||||
containerWidth: number,
|
||||
containerHeight: number
|
||||
) {
|
||||
const { width: magWidth, height: magHeight } = getMagnifierDimensions(
|
||||
containerWidth,
|
||||
containerHeight
|
||||
)
|
||||
|
||||
// Base dimensions from zoom (what we'd show without aspect ratio adjustment)
|
||||
const baseWidth = viewBoxWidth / zoom
|
||||
const baseHeight = viewBoxHeight / zoom
|
||||
|
||||
// Compare aspect ratios
|
||||
const containerAspect = magWidth / magHeight
|
||||
const viewBoxAspect = baseWidth / baseHeight
|
||||
|
||||
if (containerAspect > viewBoxAspect) {
|
||||
// Container is wider than viewBox aspect ratio - expand width to fill
|
||||
return {
|
||||
width: baseHeight * containerAspect,
|
||||
height: baseHeight,
|
||||
}
|
||||
} else {
|
||||
// Container is taller than viewBox aspect ratio - expand height to fill
|
||||
return {
|
||||
width: baseWidth,
|
||||
height: baseWidth / containerAspect,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,17 +9,17 @@ import { createPortal } from 'react-dom'
|
||||
import { css } from '../../styled-system/css'
|
||||
import { container, hstack } from '../../styled-system/patterns'
|
||||
import { Z_INDEX } from '../constants/zIndex'
|
||||
import { useDeploymentInfo } from '../contexts/DeploymentInfoContext'
|
||||
import { useFullscreen } from '../contexts/FullscreenContext'
|
||||
import { useTheme } from '../contexts/ThemeContext'
|
||||
import { useDeploymentInfo } from '../contexts/DeploymentInfoContext'
|
||||
import { useVisualDebug } from '../contexts/VisualDebugContext'
|
||||
// Import HomeHeroContext for optional usage
|
||||
import type { Subtitle } from '../data/abaciOneSubtitles'
|
||||
import { getRandomSubtitle } from '../data/abaciOneSubtitles'
|
||||
import { AbacusDisplayDropdown } from './AbacusDisplayDropdown'
|
||||
import { LanguageSelector } from './LanguageSelector'
|
||||
import { ThemeToggle } from './ThemeToggle'
|
||||
|
||||
// Import HomeHeroContext for optional usage
|
||||
import type { Subtitle } from '../data/abaciOneSubtitles'
|
||||
|
||||
type HomeHeroContextValue = {
|
||||
subtitle: Subtitle
|
||||
isHeroVisible: boolean
|
||||
@@ -73,6 +73,7 @@ function MenuContent({
|
||||
}) {
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
const { open: openDeploymentInfo } = useDeploymentInfo()
|
||||
const { isVisualDebugEnabled, toggleVisualDebug, isDevelopment } = useVisualDebug()
|
||||
|
||||
const linkStyle = {
|
||||
display: 'flex',
|
||||
@@ -315,6 +316,38 @@ function MenuContent({
|
||||
<div style={{ padding: '0 6px' }}>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
{/* Developer Section - only in development */}
|
||||
{isDevelopment && (
|
||||
<>
|
||||
<div style={separatorStyle} />
|
||||
<div style={sectionHeaderStyle}>Developer</div>
|
||||
<div
|
||||
data-setting="visual-debug"
|
||||
onClick={() => {
|
||||
toggleVisualDebug()
|
||||
}}
|
||||
style={controlButtonStyle}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = isDark
|
||||
? 'rgba(234, 179, 8, 0.2)'
|
||||
: 'rgba(234, 179, 8, 0.1)'
|
||||
e.currentTarget.style.color = isDark
|
||||
? 'rgba(253, 224, 71, 1)'
|
||||
: 'rgba(161, 98, 7, 1)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent'
|
||||
e.currentTarget.style.color = isDark
|
||||
? 'rgba(209, 213, 219, 1)'
|
||||
: 'rgba(55, 65, 81, 1)'
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '18px' }}>{isVisualDebugEnabled ? '🔍' : '🐞'}</span>
|
||||
<span>Visual Debug {isVisualDebugEnabled ? 'ON' : 'OFF'}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -407,6 +440,36 @@ function MenuContent({
|
||||
<DropdownMenu.Item onSelect={(e) => e.preventDefault()} style={{ padding: '0 6px' }}>
|
||||
<ThemeToggle />
|
||||
</DropdownMenu.Item>
|
||||
|
||||
{/* Developer Section - only in development */}
|
||||
{isDevelopment && (
|
||||
<>
|
||||
<DropdownMenu.Separator style={separatorStyle} />
|
||||
<div style={sectionHeaderStyle}>Developer</div>
|
||||
<DropdownMenu.Item
|
||||
data-setting="visual-debug"
|
||||
onSelect={toggleVisualDebug}
|
||||
style={controlButtonStyle}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = isDark
|
||||
? 'rgba(234, 179, 8, 0.2)'
|
||||
: 'rgba(234, 179, 8, 0.1)'
|
||||
e.currentTarget.style.color = isDark
|
||||
? 'rgba(253, 224, 71, 1)'
|
||||
: 'rgba(161, 98, 7, 1)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent'
|
||||
e.currentTarget.style.color = isDark
|
||||
? 'rgba(209, 213, 219, 1)'
|
||||
: 'rgba(55, 65, 81, 1)'
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '16px' }}>{isVisualDebugEnabled ? '🔍' : '🐞'}</span>
|
||||
<span>Visual Debug {isVisualDebugEnabled ? 'ON' : 'OFF'}</span>
|
||||
</DropdownMenu.Item>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,19 +5,20 @@ import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { NextIntlClientProvider } from 'next-intl'
|
||||
import { type ReactNode, useState } from 'react'
|
||||
import { ToastProvider } from '@/components/common/ToastContext'
|
||||
import { DeploymentInfoProvider } from '@/contexts/DeploymentInfoContext'
|
||||
import { FullscreenProvider } from '@/contexts/FullscreenContext'
|
||||
import { GameModeProvider } from '@/contexts/GameModeContext'
|
||||
import { UserProfileProvider } from '@/contexts/UserProfileContext'
|
||||
import { HomeHeroProvider } from '@/contexts/HomeHeroContext'
|
||||
import { LocaleProvider, useLocaleContext } from '@/contexts/LocaleContext'
|
||||
import { MyAbacusProvider } from '@/contexts/MyAbacusContext'
|
||||
import { ThemeProvider } from '@/contexts/ThemeContext'
|
||||
import { createQueryClient } from '@/lib/queryClient'
|
||||
import { UserProfileProvider } from '@/contexts/UserProfileContext'
|
||||
import { VisualDebugProvider } from '@/contexts/VisualDebugContext'
|
||||
import type { Locale } from '@/i18n/messages'
|
||||
import { createQueryClient } from '@/lib/queryClient'
|
||||
import { AbacusSettingsSync } from './AbacusSettingsSync'
|
||||
import { DeploymentInfo } from './DeploymentInfo'
|
||||
import { DeploymentInfoProvider } from '@/contexts/DeploymentInfoContext'
|
||||
import { MyAbacusProvider } from '@/contexts/MyAbacusContext'
|
||||
import { MyAbacus } from './MyAbacus'
|
||||
import { HomeHeroProvider } from '@/contexts/HomeHeroContext'
|
||||
|
||||
interface ClientProvidersProps {
|
||||
children: ReactNode
|
||||
@@ -65,9 +66,11 @@ export function ClientProviders({
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<LocaleProvider initialLocale={initialLocale} initialMessages={initialMessages}>
|
||||
<InnerProviders>{children}</InnerProviders>
|
||||
</LocaleProvider>
|
||||
<VisualDebugProvider>
|
||||
<LocaleProvider initialLocale={initialLocale} initialMessages={initialMessages}>
|
||||
<InnerProviders>{children}</InnerProviders>
|
||||
</LocaleProvider>
|
||||
</VisualDebugProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
79
apps/web/src/contexts/VisualDebugContext.tsx
Normal file
79
apps/web/src/contexts/VisualDebugContext.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from 'react'
|
||||
|
||||
const STORAGE_KEY = 'visual-debug-enabled'
|
||||
|
||||
interface VisualDebugContextType {
|
||||
/** Whether visual debug elements are enabled (only functional in development) */
|
||||
isVisualDebugEnabled: boolean
|
||||
/** Toggle visual debug mode on/off */
|
||||
toggleVisualDebug: () => void
|
||||
/** Whether we're in development mode (visual debug toggle only shows in dev) */
|
||||
isDevelopment: boolean
|
||||
}
|
||||
|
||||
const VisualDebugContext = createContext<VisualDebugContextType | null>(null)
|
||||
|
||||
export function VisualDebugProvider({ children }: { children: ReactNode }) {
|
||||
const [isEnabled, setIsEnabled] = useState(false)
|
||||
const isDevelopment = process.env.NODE_ENV === 'development'
|
||||
|
||||
// Load from localStorage on mount
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored === 'true') {
|
||||
setIsEnabled(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Persist to localStorage
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
localStorage.setItem(STORAGE_KEY, String(isEnabled))
|
||||
}, [isEnabled])
|
||||
|
||||
const toggleVisualDebug = useCallback(() => {
|
||||
setIsEnabled((prev) => !prev)
|
||||
}, [])
|
||||
|
||||
// Only enable visual debug in development mode
|
||||
const isVisualDebugEnabled = isDevelopment && isEnabled
|
||||
|
||||
return (
|
||||
<VisualDebugContext.Provider
|
||||
value={{
|
||||
isVisualDebugEnabled,
|
||||
toggleVisualDebug,
|
||||
isDevelopment,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</VisualDebugContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useVisualDebug(): VisualDebugContextType {
|
||||
const context = useContext(VisualDebugContext)
|
||||
if (!context) {
|
||||
throw new Error('useVisualDebug must be used within a VisualDebugProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for components that may be rendered outside the provider.
|
||||
* Returns safe defaults (debug disabled) if no provider is found.
|
||||
*/
|
||||
export function useVisualDebugSafe(): VisualDebugContextType {
|
||||
const context = useContext(VisualDebugContext)
|
||||
if (!context) {
|
||||
return {
|
||||
isVisualDebugEnabled: false,
|
||||
toggleVisualDebug: () => {},
|
||||
isDevelopment: process.env.NODE_ENV === 'development',
|
||||
}
|
||||
}
|
||||
return context
|
||||
}
|
||||
Reference in New Issue
Block a user