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)}
-