From a3fa056c1ff5463356e4411d01c6397838cab800 Mon Sep 17 00:00:00 2001 From: AlacrisDevs Date: Wed, 10 Dec 2025 21:34:04 +0200 Subject: [PATCH] Added color randomization logic, small design changes --- messages/en.json | 6 +- messages/et.json | 2 + src/lib/components/ColorPicker.svelte | 1 - src/lib/components/Settings.svelte | 116 +++++++++---- .../kuldvillak/ui/KvButtonSecondary.svelte | 2 +- src/lib/stores/theme.svelte.ts | 153 +++++++++++++++++- .../kuldvillak/play/ProjectorView.svelte | 2 +- 7 files changed, 244 insertions(+), 38 deletions(-) diff --git a/messages/en.json b/messages/en.json index 4be10a4..e21233f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -91,8 +91,10 @@ "kv_edit_rules": "Jeopardy Rules", "kv_edit_how_to": "How to Play?", "kv_settings_colors": "Colors", - "kv_settings_primary": "Primary", - "kv_settings_secondary": "Secondary", + "kv_randomize": "Randomize", + "kv_randomize_all_colors": "Randomize All Colors", + "kv_settings_primary": "Primary Color", + "kv_settings_secondary": "Secondary Color", "kv_settings_text_color": "Text", "kv_settings_background": "Background", "kv_settings_reset": "Reset Settings", diff --git a/messages/et.json b/messages/et.json index 7554dd0..8b0b0a9 100644 --- a/messages/et.json +++ b/messages/et.json @@ -91,6 +91,8 @@ "kv_edit_rules": "Kuldvillaku reeglid", "kv_edit_how_to": "Kuidas mängida?", "kv_settings_colors": "Värvid", + "kv_randomize": "Suvaline", + "kv_randomize_all_colors": "Muuda suvaliselt kõiki värve", "kv_settings_primary": "Primaarne", "kv_settings_secondary": "Sekundaarne", "kv_settings_text_color": "Tekst", diff --git a/src/lib/components/ColorPicker.svelte b/src/lib/components/ColorPicker.svelte index d71538b..ec0851c 100644 --- a/src/lib/components/ColorPicker.svelte +++ b/src/lib/components/ColorPicker.svelte @@ -670,7 +670,6 @@ e.key === "Enter" && updateFromHex()} class="w-full bg-kv-black border-2 md:border-4 border-black px-2 md:px-3 py-1.5 md:py-2 text-kv-white font-kv-body text-base md:text-lg uppercase" diff --git a/src/lib/components/Settings.svelte b/src/lib/components/Settings.svelte index 3b7e3a0..14c72f2 100644 --- a/src/lib/components/Settings.svelte +++ b/src/lib/components/Settings.svelte @@ -217,7 +217,7 @@ -
+
{m.kv_settings_primary()} - (themeStore.primary = c)} - /> +
+ (themeStore.primary = c)} + /> + + (themeStore.primary = themeStore.getRandomColor())} + class="!px-3 !py-1 !text-sm" + > + {m.kv_randomize()} + +
+
{m.kv_settings_secondary()} - (themeStore.secondary = c)} - /> +
+ (themeStore.secondary = c)} + /> + + (themeStore.secondary = + themeStore.getRandomColor())} + class="!px-3 !py-1 !text-sm" + > + {m.kv_randomize()} + +
+
{m.kv_settings_text_color()} - (themeStore.text = c)} - /> +
+ (themeStore.text = c)} + /> + + (themeStore.text = themeStore.getRandomColor())} + class="!px-3 !py-1 !text-sm" + > + {m.kv_randomize()} + +
+
{m.kv_settings_background()} - (themeStore.background = c)} - /> +
+ (themeStore.background = c)} + /> + + (themeStore.background = + themeStore.getRandomColor())} + class="!px-3 !py-1 !text-sm" + > + {m.kv_randomize()} + +
- - - {m.kv_settings_reset()} - + +
+ + {m.kv_randomize_all_colors()} + +
- - - {m.kv_settings_save_exit()} - + +
+ + + {m.kv_settings_reset()} + + + + + {m.kv_settings_save_exit()} + +
diff --git a/src/lib/components/kuldvillak/ui/KvButtonSecondary.svelte b/src/lib/components/kuldvillak/ui/KvButtonSecondary.svelte index b932944..966b81f 100644 --- a/src/lib/components/kuldvillak/ui/KvButtonSecondary.svelte +++ b/src/lib/components/kuldvillak/ui/KvButtonSecondary.svelte @@ -22,7 +22,7 @@ }: Props = $props(); const baseClasses = - "inline-flex items-center justify-center px-6 py-4 bg-kv-yellow text-black kv-btn-text cursor-pointer transition-opacity hover:opacity-80 disabled:opacity-50 disabled:cursor-not-allowed kv-shadow-button border-4 border-black box-border focus:outline-none focus:ring-2 focus:ring-kv-blue focus:ring-offset-2 focus:ring-offset-black"; + "inline-flex items-center justify-center px-6 py-4 bg-kv-yellow text-kv-black kv-btn-text cursor-pointer transition-opacity hover:opacity-80 disabled:opacity-50 disabled:cursor-not-allowed kv-shadow-button border-4 border-black box-border focus:outline-none focus:ring-2 focus:ring-kv-blue focus:ring-offset-2 focus:ring-offset-black"; {#if href && !disabled} diff --git a/src/lib/stores/theme.svelte.ts b/src/lib/stores/theme.svelte.ts index e1301f3..17bb914 100644 --- a/src/lib/stores/theme.svelte.ts +++ b/src/lib/stores/theme.svelte.ts @@ -17,6 +17,78 @@ export const DEFAULT_THEME = { background: "#000000", }; +// --- WCAG & Color Utilities (Updated for HSL generation/checking) --- +const ColorUtils = { + // Convert Hex to RGB array + hexToRgb(hex: string) { + const bigint = parseInt(hex.replace('#', ''), 16); + return [(bigint >> 16) & 255, (bigint >> 8) & 255, bigint & 255]; + }, + + // Calculate Relative Luminance (WCAG 2.0 formula) + getLuminance(r: number, g: number, b: number) { + const a = [r, g, b].map((v) => { + v /= 255; + return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); + }); + return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722; + }, + + // Calculate Contrast Ratio + getContrast(hex1: string, hex2: string) { + const rgb1 = this.hexToRgb(hex1); + const rgb2 = this.hexToRgb(hex2); + const l1 = this.getLuminance(rgb1[0], rgb1[1], rgb1[2]); + const l2 = this.getLuminance(rgb2[0], rgb2[1], rgb2[2]); + return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); + }, + + // Convert Hex to HSL for checking hue distance + hexToHsl(hex: string): [number, number, number] { + let r = 0, g = 0, b = 0; + const rgb = this.hexToRgb(hex); + r = rgb[0] / 255; g = rgb[1] / 255; b = rgb[2] / 255; + const max = Math.max(r, g, b), min = Math.min(r, g, b); + let h = 0, s = 0, l = (max + min) / 2; + + if (max !== min) { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + switch (max) { + case r: h = (g - b) / d + (g < b ? 6 : 0); break; + case g: h = (b - r) / d + 2; break; + case b: h = (r - g) / d + 4; break; + } + h /= 6; + } + return [h * 360, s, l]; // Return H in degrees, S/L as 0-1. + }, + + // Generate a color with strictly controlled HSL (Luminance and Saturation) + generateHslColor(hueRange: [number, number] | null, satRange: [number, number], lumRange: [number, number]): string { + const h = hueRange + ? Math.floor(Math.random() * (hueRange[1] - hueRange[0])) + hueRange[0] + : Math.floor(Math.random() * 360); + + const s = Math.floor(Math.random() * (satRange[1] - satRange[0])) + satRange[0]; + const l = Math.floor(Math.random() * (lumRange[1] - lumRange[0])) + lumRange[0]; + + return this.hslToHex(h, s, l); + }, + + hslToHex(h: number, s: number, l: number) { + l /= 100; + const a = s * Math.min(l, 1 - l) / 100; + const f = (n: number) => { + const k = (n + h / 30) % 12; + const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1); + return Math.round(255 * color).toString(16).padStart(2, '0'); + }; + return `#${f(0)}${f(8)}${f(4)}`; + } +}; + + // Load initial values from localStorage function getInitialTheme() { if (browser) { @@ -113,7 +185,82 @@ function resetToDefaults() { applyTheme(); } -// Reset and save (for full reset) +// Returns a simple random hex (utility for single color needs) +function getRandomColor() { + // High saturation for a beautiful single random color + return ColorUtils.generateHslColor(null, [70, 100], [40, 60]); +} + +// Smart Randomization with WCAG and Vibrancy Enforcement +function randomizeColors() { + let attempts = 0; + const maxAttempts = 50; + + while (attempts < maxAttempts) { + attempts++; + + // 1. Decide Polarity + const isDarkTheme = Math.random() < 0.5; + + // 2. Generate Background (Low Saturation, Extreme Luminance) + const newBg = ColorUtils.generateHslColor( + null, + [10, 30], // Low Saturation for a neutral BG + isDarkTheme ? [4, 10] : [95, 99] // Extreme Dark or Light + ); + + // 3. Generate Text (Pure White or Near Black) + const newText = isDarkTheme ? "#FFFFFF" : "#050505"; + + // 4. Generate Secondary (The "Pop" Color - 7:1 against BG) + const newSec = ColorUtils.generateHslColor( + null, + [75, 100], // High Saturation (Vibrant!) + isDarkTheme ? [60, 85] : [20, 45] // Opposite L to BG + ); + + // 5. Generate Primary (The "Brand" Color - Mid-Tone Vibrancy) + const newPrim = ColorUtils.generateHslColor( + null, + [70, 100], // High Saturation + isDarkTheme ? [30, 60] : [40, 60] // Mid-Tone Luminance for True Color + ); + + // --- VALIDATION --- + + // Check Contrast + const ratio_Prim_Text = ColorUtils.getContrast(newPrim, newText); + const ratio_Sec_Bg = ColorUtils.getContrast(newSec, newBg); + const ratio_Prim_Sec = ColorUtils.getContrast(newPrim, newSec); + + // Check Hue Diversity (Ensures Primary and Secondary don't look like the same color) + const primHsl = ColorUtils.hexToHsl(newPrim); + const secHsl = ColorUtils.hexToHsl(newSec); + // We use Math.min to handle the 360-degree color wheel wrap + const hueDiff = Math.min(Math.abs(primHsl[0] - secHsl[0]), 360 - Math.abs(primHsl[0] - secHsl[0])); + // Require at least 40 degrees difference for visual distinction + const distinctHues = hueDiff >= 40; + + if ( + ratio_Prim_Text >= 4.5 && // WCAG AA for normal text (Relaxed for vibrancy) + ratio_Sec_Bg >= 7.0 && // WCAG AAA (Ensures Secondary/CTA pops) + ratio_Prim_Sec >= 3.0 && // WCAG for non-text/graphical elements (Ensures distinction) + distinctHues // Ensures visual diversity + ) { + primary = newPrim; + secondary = newSec; + text = newText; + background = newBg; + applyTheme(); + return; + } + } + + // Fallback if 50 attempts fail + console.warn("Could not generate vibrant palette with accessibility. Resetting to defaults."); + resetToDefaults(); +} + function reset() { resetToDefaults(); save(); @@ -133,4 +280,6 @@ export const themeStore = { revert, reset, resetToDefaults, -}; + getRandomColor, + randomizeColors, +}; \ No newline at end of file diff --git a/src/routes/kuldvillak/play/ProjectorView.svelte b/src/routes/kuldvillak/play/ProjectorView.svelte index d6b5200..5add611 100644 --- a/src/routes/kuldvillak/play/ProjectorView.svelte +++ b/src/routes/kuldvillak/play/ProjectorView.svelte @@ -771,7 +771,7 @@ {@const bottomRowCount = count > 3 ? count - 3 : 0} {@const topRow = sorted.slice(0, topRowCount)} {@const bottomRow = sorted.slice(topRowCount)} -
+