Skip to content
3 changes: 2 additions & 1 deletion app/game.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -343,6 +343,7 @@ export default function GameScreen() {
size={Math.min(screenWidth - 80, 280)}
mode={variant === 'outline' ? 'outline' : 'normal'}
mirror={variant === 'mirror'}
theme={theme}
/>
</ErrorBoundary>
)}
Expand Down
54 changes: 49 additions & 5 deletions components/LevelImageDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = { ...(node.props as Record<string, unknown>) };

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<string, unknown> = { ...(node.props as Record<string, unknown>) };

// <Line> 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);
Expand Down Expand Up @@ -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';
Expand All @@ -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<typeof Svg>).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<typeof Svg>).children,
fixUnfilledStrokeForDarkTheme,
);
svgElement = React.cloneElement(svgElement, {}, fixedChildren);
}

const containerStyle = [
Expand Down
38 changes: 38 additions & 0 deletions components/__tests__/LevelImageDisplay.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<LevelImageDisplay image={sunImage} />);
Expand Down Expand Up @@ -72,4 +81,33 @@ describe('LevelImageDisplay', () => {
const unknown: LevelImage = { ...sunImage, filename: 'does-not-exist.svg' };
expect(() => render(<LevelImageDisplay image={unknown} mode="outline" mirror />)).not.toThrow();
});

it('recolors unfilled black strokes in dark theme, including <Line> elements without a fill prop (#335)', () => {
const { UNSAFE_root } = render(<LevelImageDisplay image={stickFigureImage} theme="dark" />);
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(<LevelImageDisplay image={stickFigureImage} />);
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(
<LevelImageDisplay image={sunImage} mode="outline" theme="dark" />,
);
expect(darkRoot.findAll((node: any) => node.props.stroke === '#D8D4E8').length).toBeGreaterThan(
0,
);
expect(darkRoot.findAll((node: any) => node.props.stroke === '#3a3a3a').length).toBe(0);
});
});
3 changes: 2 additions & 1 deletion components/game/MemorizePhase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export default function MemorizePhase({
variant = 'normal',
}: MemorizePhaseProps) {
const { t } = useTranslation();
const { colors } = useTheme();
const { colors, theme } = useTheme();

return (
<View style={styles.phaseContainer}>
Expand Down Expand Up @@ -50,6 +50,7 @@ export default function MemorizePhase({
revealStep={revealStep}
mode={variant === 'outline' ? 'outline' : 'normal'}
mirror={variant === 'mirror'}
theme={theme}
/>
</ErrorBoundary>
<Text style={[styles.imageName, { color: colors.text.primary }]}>
Expand Down
6 changes: 4 additions & 2 deletions components/game/ResultPhase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -121,7 +121,9 @@ export default function ResultPhase({
</Text>
</View>
<View style={[styles.comparisonImage, { width: imageSize, height: imageSize }]}>
{currentImage && <LevelImageDisplay image={currentImage} size={imageSize} />}
{currentImage && (
<LevelImageDisplay image={currentImage} size={imageSize} theme={theme} />
)}
</View>
</View>

Expand Down
20 changes: 13 additions & 7 deletions constants/Colors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},

Expand Down Expand Up @@ -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,
},
},
Expand All @@ -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: {
Expand Down
17 changes: 10 additions & 7 deletions services/ThemeContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,13 @@ interface ThemeContextType {
const ThemeContext = createContext<ThemeContextType | undefined>(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',
Expand Down Expand Up @@ -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',
Expand Down
Loading