diff --git a/app/game.tsx b/app/game.tsx index 2b2cbf8..d6aefbc 100644 --- a/app/game.tsx +++ b/app/game.tsx @@ -41,7 +41,7 @@ export default function GameScreen() { const { t } = useTranslation(); const router = useRouter(); const params = useLocalSearchParams(); - const { colors } = useTheme(); + const { colors, theme } = useTheme(); const insets = useSafeAreaInsets(); const layout = useScreenLayout(); const { screenWidth, isSmall } = layout; @@ -343,6 +343,7 @@ export default function GameScreen() { size={Math.min(screenWidth - 80, 280)} mode={variant === 'outline' ? 'outline' : 'normal'} mirror={variant === 'mirror'} + theme={theme} /> )} diff --git a/components/LevelImageDisplay.tsx b/components/LevelImageDisplay.tsx index 8961c95..61f8987 100644 --- a/components/LevelImageDisplay.tsx +++ b/components/LevelImageDisplay.tsx @@ -10,25 +10,61 @@ interface Props { revealStep?: number; // If set, only show SVG children 0..revealStep (for progressive reveal) mode?: 'normal' | 'outline'; // 'outline' strips colors, showing only the silhouette (game variant "Nur Umriss merken") mirror?: boolean; // Horizontally flips the image (game variant "Spiegelbild") + theme?: 'light' | 'dark'; // Adjusts pure-black, unfilled strokes so they stay visible on a dark background (Issue #335) } -const OUTLINE_COLOR = '#3a3a3a'; +const OUTLINE_COLOR_LIGHT = '#3a3a3a'; +const OUTLINE_COLOR_DARK = '#D8D4E8'; + +// Some images (e.g. the stick figure) are drawn entirely as unfilled black +// strokes. On the dark theme's surface colors those are nearly invisible, so +// they get remapped to a light color instead (Issue #335). +const UNFILLED_STROKE_DARK_FALLBACK = '#D8D4E8'; /** * Recursively strips fill colors from an SVG element tree and forces a uniform * outline stroke, so only the silhouette remains visible (no color information). */ -function toOutlineElement(node: React.ReactNode): React.ReactNode { +function toOutlineElement(node: React.ReactNode, outlineColor: string): React.ReactNode { if (!React.isValidElement(node)) return node; const props: Record = { ...(node.props as Record) }; if ('fill' in props) props.fill = 'none'; - props.stroke = OUTLINE_COLOR; + props.stroke = outlineColor; if (!props.strokeWidth || Number(props.strokeWidth) < 2) props.strokeWidth = '2'; delete props.opacity; if (props.children) { - props.children = React.Children.map(props.children as React.ReactNode, toOutlineElement); + props.children = React.Children.map(props.children as React.ReactNode, child => + toOutlineElement(child, outlineColor), + ); + } + + return React.cloneElement(node, props); +} + +/** + * Recursively remaps unfilled black strokes (fill="none", stroke="#000000") + * to a light color, so silhouette-only images stay visible against a dark + * background (Issue #335). + */ +function fixUnfilledStrokeForDarkTheme(node: React.ReactNode): React.ReactNode { + if (!React.isValidElement(node)) return node; + const props: Record = { ...(node.props as Record) }; + + // has no fill concept at all (never set in JSX), so it wouldn't + // match `fill === 'none'` even though it's just as "unfilled" as a + // fill="none" Circle/Path — both need the same dark-theme fallback. + const isUnfilled = props.fill === 'none' || node.type === Line; + if (isUnfilled && (props.stroke === '#000000' || props.stroke === 'black')) { + props.stroke = UNFILLED_STROKE_DARK_FALLBACK; + } + + if (props.children) { + props.children = React.Children.map( + props.children as React.ReactNode, + fixUnfilledStrokeForDarkTheme, + ); } return React.cloneElement(node, props); @@ -177,6 +213,7 @@ export default function LevelImageDisplay({ revealStep, mode = 'normal', mirror = false, + theme = 'light', }: Props) { const svgSize = size; const viewBox = '0 0 200 200'; @@ -188,11 +225,18 @@ export default function LevelImageDisplay({ } if (mode === 'outline') { + const outlineColor = theme === 'dark' ? OUTLINE_COLOR_DARK : OUTLINE_COLOR_LIGHT; const outlinedChildren = React.Children.map( (svgElement.props as React.ComponentProps).children, - toOutlineElement, + child => toOutlineElement(child, outlineColor), ); svgElement = React.cloneElement(svgElement, {}, outlinedChildren); + } else if (theme === 'dark') { + const fixedChildren = React.Children.map( + (svgElement.props as React.ComponentProps).children, + fixUnfilledStrokeForDarkTheme, + ); + svgElement = React.cloneElement(svgElement, {}, fixedChildren); } const containerStyle = [ diff --git a/components/__tests__/LevelImageDisplay.test.tsx b/components/__tests__/LevelImageDisplay.test.tsx index 20c56aa..157a8f5 100644 --- a/components/__tests__/LevelImageDisplay.test.tsx +++ b/components/__tests__/LevelImageDisplay.test.tsx @@ -35,6 +35,15 @@ const sunImage: LevelImage = { colors: ['#FFD700'], }; +const stickFigureImage: LevelImage = { + filename: 'extra-01-stick-figure.svg', + difficulty: 1, + displayName: 'Strichmännchen', + displayNameEn: 'Stick figure', + strokeCount: 6, + colors: ['#000000'], +}; + describe('LevelImageDisplay', () => { it('renders the full-color image by default', () => { const { UNSAFE_root } = render(); @@ -72,4 +81,33 @@ describe('LevelImageDisplay', () => { const unknown: LevelImage = { ...sunImage, filename: 'does-not-exist.svg' }; expect(() => render()).not.toThrow(); }); + + it('recolors unfilled black strokes in dark theme, including elements without a fill prop (#335)', () => { + const { UNSAFE_root } = render(); + const stillBlack = UNSAFE_root.findAll( + (node: any) => node.props.stroke === '#000000' || node.props.stroke === 'black', + ); + expect(stillBlack.length).toBe(0); + const recolored = UNSAFE_root.findAll((node: any) => node.props.stroke === '#D8D4E8'); + // Kopf (Circle, fill="none") + 5 Gliedmaßen (Line, ohne fill-Prop) + expect(recolored.length).toBeGreaterThanOrEqual(6); + }); + + it('keeps black strokes unchanged in light theme (default)', () => { + const { UNSAFE_root } = render(); + const black = UNSAFE_root.findAll( + (node: any) => node.props.stroke === '#000000' || node.props.stroke === 'black', + ); + expect(black.length).toBeGreaterThan(0); + }); + + it('uses a theme-appropriate outline color in outline mode', () => { + const { UNSAFE_root: darkRoot } = render( + , + ); + expect(darkRoot.findAll((node: any) => node.props.stroke === '#D8D4E8').length).toBeGreaterThan( + 0, + ); + expect(darkRoot.findAll((node: any) => node.props.stroke === '#3a3a3a').length).toBe(0); + }); }); diff --git a/components/game/MemorizePhase.tsx b/components/game/MemorizePhase.tsx index fae44ee..0c26090 100644 --- a/components/game/MemorizePhase.tsx +++ b/components/game/MemorizePhase.tsx @@ -21,7 +21,7 @@ export default function MemorizePhase({ variant = 'normal', }: MemorizePhaseProps) { const { t } = useTranslation(); - const { colors } = useTheme(); + const { colors, theme } = useTheme(); return ( @@ -50,6 +50,7 @@ export default function MemorizePhase({ revealStep={revealStep} mode={variant === 'outline' ? 'outline' : 'normal'} mirror={variant === 'mirror'} + theme={theme} /> diff --git a/components/game/ResultPhase.tsx b/components/game/ResultPhase.tsx index 1bfa0d7..89b87fe 100644 --- a/components/game/ResultPhase.tsx +++ b/components/game/ResultPhase.tsx @@ -39,7 +39,7 @@ export default function ResultPhase({ onRestartFromLevel1, }: ResultPhaseProps) { const { t } = useTranslation(); - const { colors } = useTheme(); + const { colors, theme } = useTheme(); const isLastLevel = levelNumber >= getTotalLevels(); const [showCompletionModal, setShowCompletionModal] = useState(false); @@ -121,7 +121,9 @@ export default function ResultPhase({ - {currentImage && } + {currentImage && ( + + )} diff --git a/constants/Colors.ts b/constants/Colors.ts index e76b5cc..cd20998 100644 --- a/constants/Colors.ts +++ b/constants/Colors.ts @@ -6,19 +6,25 @@ export const Colors = { // Primärfarben - Gradient-ready - primary: '#7C5CFF', // Vivid Purple - kräftiger, kinderfreundlicher (Issue #176) - primaryLight: '#9E84FF', // Hellere Variante - primaryDark: '#5A3FE0', // Dunklere Variante + // Blau-Familie passend zur App-Icon-/Store-Markenfarbe #60D5FA (Issue #306). + // #60D5FA selbst ist zu hell für Text/Buttons (Kontrast <2:1 gegen Weiß) — + // primary/primaryDark sind daher dieselbe Farbfamilie in einem Ton, der + // WCAG-Kontrast für weißen Button-Text einhält (~4:1, analog zum bisherigen + // Violett-Wert); primaryLight bleibt die reine Icon-Farbe für dekorative + // Flächen ohne Text (Kreise, Icon-Referenz, Splash-Hintergrund). + primary: '#0585AD', + primaryLight: '#60D5FA', + primaryDark: '#045B77', secondary: '#F093FB', // Rosa - Spielerisch & Warm accent: '#4ECDC4', // Mint-Teal - frischer Akzent accentWarm: '#FFB547', // Apricot - spielerisch warm // Gradient-Kombinationen (für LinearGradient) gradient: { - primary: ['#7C5CFF', '#F093FB'] as const, + primary: ['#0585AD', '#045B77'] as const, secondary: ['#F093FB', '#f5576c'] as const, warm: ['#FFB547', '#FF6B6B'] as const, - cta: ['#7C5CFF', '#F093FB'] as const, + cta: ['#0585AD', '#045B77'] as const, teal: ['#4ECDC4', '#44CF6C'] as const, }, @@ -50,7 +56,7 @@ export const Colors = { elevation: 8, }, buttonPrimary: { - boxShadow: '0 4px 20px rgba(124, 92, 255, 0.40)', + boxShadow: '0 4px 20px rgba(5, 133, 173, 0.40)', elevation: 6, }, }, @@ -62,7 +68,7 @@ export const Colors = { lightBorder: 'rgba(255, 255, 255, 0.70)', darkBorder: 'rgba(255, 255, 255, 0.10)', lightShadow: { - boxShadow: '0 6px 24px rgba(124, 92, 255, 0.14)', + boxShadow: '0 6px 24px rgba(96, 213, 250, 0.20)', elevation: 5, }, darkShadow: { diff --git a/services/ThemeContext.tsx b/services/ThemeContext.tsx index ecd33e8..eec1f9d 100644 --- a/services/ThemeContext.tsx +++ b/services/ThemeContext.tsx @@ -73,10 +73,13 @@ interface ThemeContextType { const ThemeContext = createContext(undefined); // Light theme colors +// primary ist eine kontraststarke Blau-Variante (WCAG-safe für Text/Buttons +// auf hellem Grund), primaryLight die eigentliche Icon-/Markenfarbe #60D5FA +// für dekorative Flächen ohne Text (Issue #306). const LIGHT_COLORS: ThemeColors = { - primary: '#7C5CFF', - primaryLight: '#9E84FF', - primaryDark: '#5A3FE0', + primary: '#0585AD', + primaryLight: '#60D5FA', + primaryDark: '#045B77', secondary: '#F093FB', accent: '#4ECDC4', accentWarm: '#FFB547', @@ -129,11 +132,11 @@ const LIGHT_COLORS: ThemeColors = { }, }; -// Dark theme colors — wärmere, leicht lila getönte Töne (Issue #176 §1.2) +// Dark theme colors — hellere, leicht entsättigte Töne für Kontrast auf dunklem Grund (Issue #176 §1.2 / #306) const DARK_COLORS: ThemeColors = { - primary: '#9E84FF', - primaryLight: '#B8A3FF', - primaryDark: '#7C5CFF', + primary: '#7DE0FF', + primaryLight: '#A8ECFF', + primaryDark: '#60D5FA', secondary: '#F5B3FC', accent: '#4ECDC4', accentWarm: '#FFB547',