Added color randomization logic, small design changes

master
AlacrisDevs 1 week ago
parent f36757ddb0
commit a3fa056c1f
  1. 6
      messages/en.json
  2. 2
      messages/et.json
  3. 1
      src/lib/components/ColorPicker.svelte
  4. 116
      src/lib/components/Settings.svelte
  5. 2
      src/lib/components/kuldvillak/ui/KvButtonSecondary.svelte
  6. 151
      src/lib/stores/theme.svelte.ts
  7. 2
      src/routes/kuldvillak/play/ProjectorView.svelte

@ -91,8 +91,10 @@
"kv_edit_rules": "Jeopardy Rules", "kv_edit_rules": "Jeopardy Rules",
"kv_edit_how_to": "How to Play?", "kv_edit_how_to": "How to Play?",
"kv_settings_colors": "Colors", "kv_settings_colors": "Colors",
"kv_settings_primary": "Primary", "kv_randomize": "Randomize",
"kv_settings_secondary": "Secondary", "kv_randomize_all_colors": "Randomize All Colors",
"kv_settings_primary": "Primary Color",
"kv_settings_secondary": "Secondary Color",
"kv_settings_text_color": "Text", "kv_settings_text_color": "Text",
"kv_settings_background": "Background", "kv_settings_background": "Background",
"kv_settings_reset": "Reset Settings", "kv_settings_reset": "Reset Settings",

@ -91,6 +91,8 @@
"kv_edit_rules": "Kuldvillaku reeglid", "kv_edit_rules": "Kuldvillaku reeglid",
"kv_edit_how_to": "Kuidas mängida?", "kv_edit_how_to": "Kuidas mängida?",
"kv_settings_colors": "Värvid", "kv_settings_colors": "Värvid",
"kv_randomize": "Suvaline",
"kv_randomize_all_colors": "Muuda suvaliselt kõiki värve",
"kv_settings_primary": "Primaarne", "kv_settings_primary": "Primaarne",
"kv_settings_secondary": "Sekundaarne", "kv_settings_secondary": "Sekundaarne",
"kv_settings_text_color": "Tekst", "kv_settings_text_color": "Tekst",

@ -670,7 +670,6 @@
<input <input
type="text" type="text"
bind:value={hexInput} bind:value={hexInput}
oninput={updateFromHex}
onblur={updateFromHex} onblur={updateFromHex}
onkeydown={(e) => e.key === "Enter" && updateFromHex()} onkeydown={(e) => 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" 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"

@ -217,7 +217,7 @@
</div> </div>
<!-- Color Swatches --> <!-- Color Swatches -->
<div class="flex flex-col gap-4 w-full"> <div class="flex flex-col gap-6 w-full">
<!-- Primary Color --> <!-- Primary Color -->
<div class="flex items-center justify-between w-full"> <div class="flex items-center justify-between w-full">
<span <span
@ -225,11 +225,21 @@
> >
{m.kv_settings_primary()} {m.kv_settings_primary()}
</span> </span>
<ColorPicker <div class="flex items-center gap-2">
bind:value={themeStore.primary} <ColorPicker
onchange={(c) => (themeStore.primary = c)} bind:value={themeStore.primary}
/> onchange={(c) => (themeStore.primary = c)}
/>
<KvButtonSecondary
onclick={() =>
(themeStore.primary = themeStore.getRandomColor())}
class="!px-3 !py-1 !text-sm"
>
{m.kv_randomize()}
</KvButtonSecondary>
</div>
</div> </div>
<!-- Secondary Color --> <!-- Secondary Color -->
<div class="flex items-center justify-between w-full"> <div class="flex items-center justify-between w-full">
<span <span
@ -237,11 +247,22 @@
> >
{m.kv_settings_secondary()} {m.kv_settings_secondary()}
</span> </span>
<ColorPicker <div class="flex items-center gap-2">
bind:value={themeStore.secondary} <ColorPicker
onchange={(c) => (themeStore.secondary = c)} bind:value={themeStore.secondary}
/> onchange={(c) => (themeStore.secondary = c)}
/>
<KvButtonSecondary
onclick={() =>
(themeStore.secondary =
themeStore.getRandomColor())}
class="!px-3 !py-1 !text-sm"
>
{m.kv_randomize()}
</KvButtonSecondary>
</div>
</div> </div>
<!-- Text Color --> <!-- Text Color -->
<div class="flex items-center justify-between w-full"> <div class="flex items-center justify-between w-full">
<span <span
@ -249,11 +270,21 @@
> >
{m.kv_settings_text_color()} {m.kv_settings_text_color()}
</span> </span>
<ColorPicker <div class="flex items-center gap-2">
bind:value={themeStore.text} <ColorPicker
onchange={(c) => (themeStore.text = c)} bind:value={themeStore.text}
/> onchange={(c) => (themeStore.text = c)}
/>
<KvButtonSecondary
onclick={() =>
(themeStore.text = themeStore.getRandomColor())}
class="!px-3 !py-1 !text-sm"
>
{m.kv_randomize()}
</KvButtonSecondary>
</div>
</div> </div>
<!-- Background Color --> <!-- Background Color -->
<div class="flex items-center justify-between w-full"> <div class="flex items-center justify-between w-full">
<span <span
@ -261,28 +292,51 @@
> >
{m.kv_settings_background()} {m.kv_settings_background()}
</span> </span>
<ColorPicker <div class="flex items-center gap-2">
bind:value={themeStore.background} <ColorPicker
onchange={(c) => (themeStore.background = c)} bind:value={themeStore.background}
/> onchange={(c) => (themeStore.background = c)}
/>
<KvButtonSecondary
onclick={() =>
(themeStore.background =
themeStore.getRandomColor())}
class="!px-3 !py-1 !text-sm"
>
{m.kv_randomize()}
</KvButtonSecondary>
</div>
</div> </div>
</div> </div>
<!-- Reset Settings Button --> <!-- Randomize All Button -->
<KvButtonPrimary <div class="flex justify-center w-full mt-2">
onclick={handleResetSettings} <KvButtonSecondary
class="!text-2xl !py-4 !px-4" onclick={themeStore.randomizeColors}
> class="!px-4 !py-2"
{m.kv_settings_reset()} >
</KvButtonPrimary> {m.kv_randomize_all_colors()}
</KvButtonSecondary>
</div>
<!-- Save and Exit Button --> <!-- Buttons Container -->
<KvButtonSecondary <div class="flex flex-row gap-4 w-full">
onclick={handleSaveAndExit} <!-- Reset Settings Button -->
class="!text-2xl !py-4 !px-4" <KvButtonPrimary
> onclick={handleResetSettings}
{m.kv_settings_save_exit()} class="!text-2xl !py-4 !px-4 flex-1"
</KvButtonSecondary> >
{m.kv_settings_reset()}
</KvButtonPrimary>
<!-- Save and Exit Button -->
<KvButtonSecondary
onclick={handleSaveAndExit}
class="!text-2xl !py-4 !px-4 flex-1"
>
{m.kv_settings_save_exit()}
</KvButtonSecondary>
</div>
</div> </div>
<!-- Confirmation Dialog --> <!-- Confirmation Dialog -->

@ -22,7 +22,7 @@
}: Props = $props(); }: Props = $props();
const baseClasses = 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";
</script> </script>
{#if href && !disabled} {#if href && !disabled}

@ -17,6 +17,78 @@ export const DEFAULT_THEME = {
background: "#000000", 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 // Load initial values from localStorage
function getInitialTheme() { function getInitialTheme() {
if (browser) { if (browser) {
@ -113,7 +185,82 @@ function resetToDefaults() {
applyTheme(); 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() { function reset() {
resetToDefaults(); resetToDefaults();
save(); save();
@ -133,4 +280,6 @@ export const themeStore = {
revert, revert,
reset, reset,
resetToDefaults, resetToDefaults,
getRandomColor,
randomizeColors,
}; };

@ -771,7 +771,7 @@
{@const bottomRowCount = count > 3 ? count - 3 : 0} {@const bottomRowCount = count > 3 ? count - 3 : 0}
{@const topRow = sorted.slice(0, topRowCount)} {@const topRow = sorted.slice(0, topRowCount)}
{@const bottomRow = sorted.slice(topRowCount)} {@const bottomRow = sorted.slice(topRowCount)}
<div class="flex-1 flex flex-col bg-kv-black p-8 gap-4"> <div class="flex-1 flex flex-col bg-kv-black gap-4">
<!-- Top row --> <!-- Top row -->
<div <div
class="flex-1 grid gap-4" class="flex-1 grid gap-4"

Loading…
Cancel
Save