Jeopardy MVP is ready

This commit is contained in:
AlacrisDevs
2025-12-08 00:51:27 +02:00
parent facb36a07f
commit 0955d6ca65
31 changed files with 3409 additions and 1414 deletions

View File

@@ -0,0 +1,821 @@
<script lang="ts">
import * as m from "$lib/paraglide/messages";
import ConfirmDialog from "./ConfirmDialog.svelte";
interface ColorPickerProps {
value: string;
onchange?: (color: string) => void;
}
let { value = $bindable("#ffffff"), onchange }: ColorPickerProps = $props();
let isOpen = $state(false);
let hexInput = $state(value);
// RGB values
let r = $state(255);
let g = $state(255);
let b = $state(255);
// Alpha/Opacity (0-100%)
let alpha = $state(100);
// HSV values
let hsvH = $state(0);
let hsvS = $state(0);
let hsvV = $state(100);
// HSL values
let hslH = $state(0);
let hslS = $state(0);
let hslL = $state(100);
// Grayscale
let gray = $state(255);
// Parse hex input - supports 2, 3, 6, and 8 character formats
function parseHexInput(input: string): string | null {
// Remove # and whitespace
let hex = input.replace(/^#/, "").trim().toLowerCase();
// 2 characters: repeat 3 times (e.g., "AB" → "ABABAB")
if (/^[a-f\d]{2}$/i.test(hex)) {
hex = hex + hex + hex;
}
// 3 characters: double each (e.g., "ABC" → "AABBCC")
else if (/^[a-f\d]{3}$/i.test(hex)) {
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
}
// 4 characters: double each with alpha (e.g., "ABCD" → "AABBCCDD")
else if (/^[a-f\d]{4}$/i.test(hex)) {
hex =
hex[0] +
hex[0] +
hex[1] +
hex[1] +
hex[2] +
hex[2] +
hex[3] +
hex[3];
}
// 6 characters: full hex
else if (/^[a-f\d]{6}$/i.test(hex)) {
// Already valid
}
// 8 characters: full hex with alpha
else if (/^[a-f\d]{8}$/i.test(hex)) {
// Valid with alpha
} else {
return null; // Invalid
}
return "#" + hex;
}
// Convert hex to RGB (supports 6 and 8 character hex)
function hexToRgb(hex: string): {
r: number;
g: number;
b: number;
a: number;
} {
const parsed = parseHexInput(hex);
if (!parsed) return { r: 255, g: 255, b: 255, a: 100 };
const clean = parsed.replace("#", "");
const r = parseInt(clean.substring(0, 2), 16);
const g = parseInt(clean.substring(2, 4), 16);
const b = parseInt(clean.substring(4, 6), 16);
// Alpha from hex (8 chars) or default to 100%
const a =
clean.length === 8
? Math.round((parseInt(clean.substring(6, 8), 16) / 255) * 100)
: 100;
return { r, g, b, a };
}
// Convert RGB to hex (always outputs 6 characters, alpha stored separately)
function rgbToHex(r: number, g: number, b: number): string {
return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
}
// Convert RGB to HSV
function rgbToHsv(
r: number,
g: number,
b: number,
): { h: number; s: number; v: number } {
r /= 255;
g /= 255;
b /= 255;
const max = Math.max(r, g, b),
min = Math.min(r, g, b);
const v = max;
const d = max - min;
const s = max === 0 ? 0 : d / max;
let h = 0;
if (max !== min) {
switch (max) {
case r:
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
break;
case g:
h = ((b - r) / d + 2) / 6;
break;
case b:
h = ((r - g) / d + 4) / 6;
break;
}
}
return {
h: Math.round(h * 360),
s: Math.round(s * 100),
v: Math.round(v * 100),
};
}
// Convert HSV to RGB
function hsvToRgb(
h: number,
s: number,
v: number,
): { r: number; g: number; b: number } {
s /= 100;
v /= 100;
const c = v * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = v - c;
let r1 = 0,
g1 = 0,
b1 = 0;
if (h < 60) {
r1 = c;
g1 = x;
} else if (h < 120) {
r1 = x;
g1 = c;
} else if (h < 180) {
g1 = c;
b1 = x;
} else if (h < 240) {
g1 = x;
b1 = c;
} else if (h < 300) {
r1 = x;
b1 = c;
} else {
r1 = c;
b1 = x;
}
return {
r: Math.round((r1 + m) * 255),
g: Math.round((g1 + m) * 255),
b: Math.round((b1 + m) * 255),
};
}
// Convert RGB to HSL
function rgbToHsl(
r: number,
g: number,
b: number,
): { h: number; s: number; l: number } {
r /= 255;
g /= 255;
b /= 255;
const max = Math.max(r, g, b),
min = Math.min(r, g, b);
const l = (max + min) / 2;
let h = 0,
s = 0;
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)) / 6;
break;
case g:
h = ((b - r) / d + 2) / 6;
break;
case b:
h = ((r - g) / d + 4) / 6;
break;
}
}
return {
h: Math.round(h * 360),
s: Math.round(s * 100),
l: Math.round(l * 100),
};
}
// Convert HSL to RGB
function hslToRgb(
h: number,
s: number,
l: number,
): { r: number; g: number; b: number } {
s /= 100;
l /= 100;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = l - c / 2;
let r1 = 0,
g1 = 0,
b1 = 0;
if (h < 60) {
r1 = c;
g1 = x;
} else if (h < 120) {
r1 = x;
g1 = c;
} else if (h < 180) {
g1 = c;
b1 = x;
} else if (h < 240) {
g1 = x;
b1 = c;
} else if (h < 300) {
r1 = x;
b1 = c;
} else {
r1 = c;
b1 = x;
}
return {
r: Math.round((r1 + m) * 255),
g: Math.round((g1 + m) * 255),
b: Math.round((b1 + m) * 255),
};
}
// Sync all values from hex
function syncFromHex(hex: string) {
const rgba = hexToRgb(hex);
r = rgba.r;
g = rgba.g;
b = rgba.b;
alpha = rgba.a;
const hsv = rgbToHsv(r, g, b);
hsvH = hsv.h;
hsvS = hsv.s;
hsvV = hsv.v;
const hsl = rgbToHsl(r, g, b);
hslH = hsl.h;
hslS = hsl.s;
hslL = hsl.l;
gray = Math.round((r + g + b) / 3);
hexInput = rgbToHex(r, g, b);
}
// Initialize from value
$effect(() => {
if (!isOpen) {
syncFromHex(value);
}
});
// Get output color with alpha if not 100%
function getOutputColor(): string {
const hex = rgbToHex(r, g, b);
if (alpha < 100) {
const alphaHex = Math.round((alpha / 100) * 255)
.toString(16)
.padStart(2, "0");
return hex + alphaHex;
}
return hex;
}
function applyColor(hex: string, updateAlphaFromHex = true) {
const rgba = hexToRgb(hex);
r = rgba.r;
g = rgba.g;
b = rgba.b;
// Update alpha from hex if it's 8 chars
if (updateAlphaFromHex && hex.replace("#", "").length === 8) {
alpha = rgba.a;
}
const hsv = rgbToHsv(r, g, b);
hsvH = hsv.h;
hsvS = hsv.s;
hsvV = hsv.v;
const hsl = rgbToHsl(r, g, b);
hslH = hsl.h;
hslS = hsl.s;
hslL = hsl.l;
gray = Math.round((r + g + b) / 3);
// Update output value with alpha
value = getOutputColor();
hexInput = rgbToHex(r, g, b);
onchange?.(value);
}
function updateFromHex() {
const parsed = parseHexInput(hexInput);
if (parsed) {
applyColor(parsed, true);
}
}
function updateFromRgb() {
r = Math.max(0, Math.min(255, r));
g = Math.max(0, Math.min(255, g));
b = Math.max(0, Math.min(255, b));
applyColor(rgbToHex(r, g, b), false);
}
function updateFromHsv() {
const rgb = hsvToRgb(hsvH, hsvS, hsvV);
applyColor(rgbToHex(rgb.r, rgb.g, rgb.b), false);
}
function updateFromHsl() {
const rgb = hslToRgb(hslH, hslS, hslL);
applyColor(rgbToHex(rgb.r, rgb.g, rgb.b), false);
}
function updateFromAlpha() {
value = getOutputColor();
onchange?.(value);
}
function updateFromGray() {
const v = Math.max(0, Math.min(255, gray));
applyColor(rgbToHex(v, v, v));
}
// Handle saturation/value picker click (HSV-based)
function handleSVPick(e: MouseEvent) {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
hsvS = Math.round(
Math.max(
0,
Math.min(100, ((e.clientX - rect.left) / rect.width) * 100),
),
);
hsvV = Math.round(
Math.max(
0,
Math.min(
100,
100 - ((e.clientY - rect.top) / rect.height) * 100,
),
),
);
updateFromHsv();
}
// Slider refs for global drag tracking
let hueSliderRect: DOMRect | null = null;
let opacitySliderRect: DOMRect | null = null;
let svPickerRect: DOMRect | null = null;
let dragging: "hue" | "opacity" | "sv" | null = null;
// Calculate hue from mouse position
function calcHue(clientX: number, rect: DOMRect) {
const padding = rect.width * 0.02;
const usableWidth = rect.width * 0.96;
const relativeX = clientX - rect.left - padding;
return Math.round(
Math.max(0, Math.min(359, (relativeX / usableWidth) * 359)),
);
}
// Calculate opacity from mouse position
function calcOpacity(clientX: number, rect: DOMRect) {
const padding = rect.width * 0.02;
const usableWidth = rect.width * 0.96;
const relativeX = clientX - rect.left - padding;
return Math.round(
Math.max(0, Math.min(100, (relativeX / usableWidth) * 100)),
);
}
// Handle hue slider
function handleHuePick(e: MouseEvent) {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
hueSliderRect = rect;
dragging = "hue";
hsvH = calcHue(e.clientX, rect);
updateFromHsv();
}
// Handle opacity slider
function handleOpacityPick(e: MouseEvent) {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
opacitySliderRect = rect;
dragging = "opacity";
alpha = calcOpacity(e.clientX, rect);
updateFromAlpha();
}
// Handle SV picker
function handleSVPickStart(e: MouseEvent) {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
svPickerRect = rect;
dragging = "sv";
handleSVPick(e);
}
// Global mouse move handler
function handleGlobalMouseMove(e: MouseEvent) {
if (!dragging) return;
if (dragging === "hue" && hueSliderRect) {
hsvH = calcHue(e.clientX, hueSliderRect);
updateFromHsv();
} else if (dragging === "opacity" && opacitySliderRect) {
alpha = calcOpacity(e.clientX, opacitySliderRect);
updateFromAlpha();
} else if (dragging === "sv" && svPickerRect) {
const rect = svPickerRect;
hsvS = Math.round(
Math.max(
0,
Math.min(100, ((e.clientX - rect.left) / rect.width) * 100),
),
);
hsvV = Math.round(
Math.max(
0,
Math.min(
100,
100 - ((e.clientY - rect.top) / rect.height) * 100,
),
),
);
updateFromHsv();
}
}
// Global mouse up handler
function handleGlobalMouseUp() {
dragging = null;
hueSliderRect = null;
opacitySliderRect = null;
svPickerRect = null;
}
// Add/remove global listeners when modal opens/closes
$effect(() => {
if (isOpen) {
document.addEventListener("mousemove", handleGlobalMouseMove);
document.addEventListener("mouseup", handleGlobalMouseUp);
}
return () => {
document.removeEventListener("mousemove", handleGlobalMouseMove);
document.removeEventListener("mouseup", handleGlobalMouseUp);
};
});
// Confirmation dialog state
let showConfirmClose = $state(false);
let originalValue = $state(value);
function togglePicker() {
if (!isOpen) {
originalValue = value; // Store original when opening
}
isOpen = !isOpen;
}
function handleCloseClick() {
// Only show confirmation if value changed
if (value !== originalValue) {
showConfirmClose = true;
} else {
closePicker();
}
}
function confirmClose() {
showConfirmClose = false;
// Revert to original value
value = originalValue;
syncFromHex(originalValue);
onchange?.(originalValue);
isOpen = false;
}
function cancelClose() {
showConfirmClose = false;
}
function closePicker() {
isOpen = false;
}
// Portal action - moves element to body
function portal(node: HTMLElement) {
document.body.appendChild(node);
return {
destroy() {
if (node.parentNode) {
node.parentNode.removeChild(node);
}
},
};
}
</script>
<!-- Color Swatch Button -->
<button
class="w-10 h-10 border-4 border-black cursor-pointer"
style="background-color: {value};"
onclick={togglePicker}
type="button"
aria-label="Pick color"
></button>
<!-- Picker Modal - rendered via portal to body -->
{#if isOpen}
<div use:portal>
<!-- Backdrop -->
<div
class="fixed inset-0 bg-kv-background/50 z-[60]"
onclick={closePicker}
role="button"
tabindex="-1"
onkeydown={(e) => e.key === "Escape" && closePicker()}
></div>
<!-- Modal centered on screen -->
<div
class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-[70]
bg-kv-blue border-4 md:border-8 lg:border-[16px] border-kv-black
p-3 md:p-6 lg:p-8 w-[95vw] max-w-[320px] md:max-w-[400px] lg:max-w-[450px]
flex flex-col gap-3 md:gap-4 lg:gap-6
max-h-[90vh] overflow-y-auto"
>
<!-- Header with Title and Close Button -->
<div class="flex items-start justify-between w-full">
<h2
class="text-xl md:text-[28px] text-kv-white uppercase font-kv-body kv-shadow-text"
>
{m.kv_color_picker()}
</h2>
<button
onclick={handleCloseClick}
class="w-6 h-6 cursor-pointer bg-transparent border-none p-0 hover:opacity-70"
aria-label="Close"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="w-full h-full text-kv-yellow"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
<!-- Saturation/Value Picker (HSV-based) -->
<div
class="relative w-full h-32 md:h-48 lg:h-56 cursor-crosshair border-2 md:border-4 border-black shrink-0 select-none"
style="background: linear-gradient(to top, black, transparent),
linear-gradient(to right, white, hsl({hsvH}, 100%, 50%));"
onmousedown={handleSVPickStart}
role="slider"
tabindex="0"
aria-label="Saturation and Value"
aria-valuenow={hsvS}
>
<!-- Picker Indicator -->
<div
class="absolute w-5 h-5 border-2 border-white rounded-full -translate-x-1/2 -translate-y-1/2 pointer-events-none"
style="left: {hsvS}%; top: {100 -
hsvV}%; box-shadow: 0 0 3px black;"
></div>
</div>
<!-- Hue Slider -->
<div
class="relative w-full h-6 md:h-8 cursor-pointer border-2 md:border-4 border-black shrink-0 select-none"
style="background: linear-gradient(to right, hsl(0,100%,50%), hsl(60,100%,50%), hsl(120,100%,50%), hsl(180,100%,50%), hsl(240,100%,50%), hsl(300,100%,50%), hsl(359,100%,50%));"
onmousedown={handleHuePick}
role="slider"
tabindex="0"
aria-label="Hue"
aria-valuenow={hsvH}
>
<div
class="absolute top-0 bottom-0 w-2 md:w-3 border-2 border-white -translate-x-1/2 pointer-events-none"
style="left: calc(2% + {(hsvH / 359) *
96}%); box-shadow: 0 0 3px black;"
></div>
</div>
<!-- Opacity Slider -->
<div>
<div class="flex justify-between items-center mb-1">
<span
class="text-sm md:text-base text-kv-white uppercase font-kv-body kv-shadow-text"
>
{m.kv_opacity()}
</span>
<span
class="text-sm md:text-base text-kv-white font-kv-body"
>
{alpha}%
</span>
</div>
<div
class="relative w-full h-6 md:h-8 cursor-pointer border-2 md:border-4 border-black shrink-0 select-none"
style="background: linear-gradient(to right, transparent, {rgbToHex(
r,
g,
b,
)}),
repeating-conic-gradient(#808080 0% 25%, #fff 0% 50%) 50% / 16px 16px;"
onmousedown={handleOpacityPick}
role="slider"
tabindex="0"
aria-label="Opacity"
aria-valuenow={alpha}
>
<div
class="absolute top-0 bottom-0 w-2 md:w-3 border-2 border-white -translate-x-1/2 pointer-events-none"
style="left: calc(2% + {alpha *
0.96}%); box-shadow: 0 0 3px black;"
></div>
</div>
</div>
<!-- Preview & HEX -->
<div class="flex gap-2 md:gap-4 items-center">
<div
class="w-12 h-12 md:w-16 md:h-16 border-2 md:border-4 border-black shrink-0"
style="background-color: rgba({r}, {g}, {b}, {alpha /
100});
background-image: repeating-conic-gradient(#808080 0% 25%, #fff 0% 50%);
background-size: 8px 8px;
background-blend-mode: normal;"
>
<div
class="w-full h-full"
style="background-color: rgba({r}, {g}, {b}, {alpha /
100});"
></div>
</div>
<div class="flex-1">
<span
class="text-sm md:text-base text-kv-white uppercase font-kv-body kv-shadow-text"
>HEX</span
>
<input
type="text"
bind:value={hexInput}
oninput={updateFromHex}
onblur={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"
maxlength="9"
placeholder="#RRGGBB"
/>
</div>
</div>
<!-- RGB -->
<div>
<span
class="text-sm md:text-base text-kv-white uppercase font-kv-body kv-shadow-text"
>RGB</span
>
<div class="grid grid-cols-3 gap-1 md:gap-2">
<input
type="number"
bind:value={r}
oninput={updateFromRgb}
min="0"
max="255"
class="w-full bg-kv-black border-2 md:border-4 border-black px-1 md:px-2 py-1 md:py-2 text-kv-white font-kv-body text-sm md:text-base text-center"
/>
<input
type="number"
bind:value={g}
oninput={updateFromRgb}
min="0"
max="255"
class="w-full bg-kv-black border-2 md:border-4 border-black px-1 md:px-2 py-1 md:py-2 text-kv-white font-kv-body text-sm md:text-base text-center"
/>
<input
type="number"
bind:value={b}
oninput={updateFromRgb}
min="0"
max="255"
class="w-full bg-kv-black border-2 md:border-4 border-black px-1 md:px-2 py-1 md:py-2 text-kv-white font-kv-body text-sm md:text-base text-center"
/>
</div>
</div>
<!-- HSV -->
<div>
<span
class="text-sm md:text-base text-kv-white uppercase font-kv-body kv-shadow-text"
>HSV</span
>
<div class="grid grid-cols-3 gap-1 md:gap-2">
<input
type="number"
bind:value={hsvH}
oninput={updateFromHsv}
min="0"
max="360"
class="w-full bg-kv-black border-2 md:border-4 border-black px-1 md:px-2 py-1 md:py-2 text-kv-white font-kv-body text-sm md:text-base text-center"
/>
<input
type="number"
bind:value={hsvS}
oninput={updateFromHsv}
min="0"
max="100"
class="w-full bg-kv-black border-2 md:border-4 border-black px-1 md:px-2 py-1 md:py-2 text-kv-white font-kv-body text-sm md:text-base text-center"
/>
<input
type="number"
bind:value={hsvV}
oninput={updateFromHsv}
min="0"
max="100"
class="w-full bg-kv-black border-2 md:border-4 border-black px-1 md:px-2 py-1 md:py-2 text-kv-white font-kv-body text-sm md:text-base text-center"
/>
</div>
</div>
<!-- HSL -->
<div>
<span
class="text-sm md:text-base text-kv-white uppercase font-kv-body kv-shadow-text"
>HSL</span
>
<div class="grid grid-cols-3 gap-1 md:gap-2">
<input
type="number"
bind:value={hslH}
oninput={updateFromHsl}
min="0"
max="360"
class="w-full bg-kv-black border-2 md:border-4 border-black px-1 md:px-2 py-1 md:py-2 text-kv-white font-kv-body text-sm md:text-base text-center"
/>
<input
type="number"
bind:value={hslS}
oninput={updateFromHsl}
min="0"
max="100"
class="w-full bg-kv-black border-2 md:border-4 border-black px-1 md:px-2 py-1 md:py-2 text-kv-white font-kv-body text-sm md:text-base text-center"
/>
<input
type="number"
bind:value={hslL}
oninput={updateFromHsl}
min="0"
max="100"
class="w-full bg-kv-black border-2 md:border-4 border-black px-1 md:px-2 py-1 md:py-2 text-kv-white font-kv-body text-sm md:text-base text-center"
/>
</div>
</div>
<!-- Grayscale -->
<div>
<span
class="text-sm md:text-base text-kv-white uppercase font-kv-body kv-shadow-text"
>Grayscale</span
>
<input
type="number"
bind:value={gray}
oninput={updateFromGray}
min="0"
max="255"
class="w-full bg-kv-black border-2 md:border-4 border-black px-1 md:px-2 py-1 md:py-2 text-kv-white font-kv-body text-sm md:text-base text-center"
/>
</div>
<!-- Done Button -->
<button
onclick={closePicker}
class="bg-kv-yellow border-4 border-black font-kv-body text-black uppercase px-6 py-3 cursor-pointer hover:opacity-80 text-xl kv-shadow-button"
>
{m.kv_done()}
</button>
</div>
<!-- Confirmation Dialog -->
<ConfirmDialog
bind:open={showConfirmClose}
title={m.kv_confirm_close_title()}
message={m.kv_confirm_close_message()}
confirmText={m.kv_confirm_discard()}
cancelText={m.kv_confirm_cancel()}
onconfirm={confirmClose}
oncancel={cancelClose}
/>
</div>
{/if}

View File

@@ -0,0 +1,81 @@
<script lang="ts">
import * as m from "$lib/paraglide/messages";
interface ConfirmDialogProps {
open?: boolean;
title?: string;
message?: string;
confirmText?: string;
cancelText?: string;
onconfirm?: () => void;
oncancel?: () => void;
}
let {
open = $bindable(false),
title = m.kv_confirm_close_title(),
message = m.kv_confirm_close_message(),
confirmText = m.kv_confirm_discard(),
cancelText = m.kv_confirm_cancel(),
onconfirm,
oncancel,
}: ConfirmDialogProps = $props();
function handleConfirm() {
open = false;
onconfirm?.();
}
function handleCancel() {
open = false;
oncancel?.();
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Escape") handleCancel();
}
</script>
<svelte:window onkeydown={open ? handleKeydown : undefined} />
{#if open}
<!-- Backdrop -->
<div
class="fixed inset-0 bg-kv-background/70 z-[100]"
onclick={handleCancel}
role="button"
tabindex="-1"
onkeydown={(e) => e.key === "Enter" && handleCancel()}
></div>
<!-- Dialog -->
<div
class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-[110]
bg-kv-blue border-4 md:border-8 border-kv-black
p-4 md:p-6 w-[90vw] max-w-[380px]
flex flex-col gap-4 items-center text-center"
>
<h3
class="text-xl md:text-2xl text-kv-white uppercase font-kv-body kv-shadow-text"
>
{title}
</h3>
<p class="text-sm md:text-base text-kv-white font-kv-body">
{message}
</p>
<div class="flex gap-3 w-full">
<button
onclick={handleCancel}
class="flex-1 bg-kv-blue border-4 border-black font-kv-body text-kv-white uppercase px-4 py-3 cursor-pointer hover:opacity-80 kv-shadow-button"
>
<span class="kv-shadow-text">{cancelText}</span>
</button>
<button
onclick={handleConfirm}
class="flex-1 bg-kv-yellow border-4 border-black font-kv-body text-black uppercase px-4 py-3 cursor-pointer hover:opacity-80 kv-shadow-button"
>
{confirmText}
</button>
</div>
</div>
{/if}

View File

@@ -1,5 +1,7 @@
<script lang="ts">
import Slider from "./Slider.svelte";
import ColorPicker from "./ColorPicker.svelte";
import ConfirmDialog from "./ConfirmDialog.svelte";
import {
KvButtonPrimary,
KvButtonSecondary,
@@ -16,16 +18,37 @@
let { open = $bindable(false), onclose }: SettingsProps = $props();
// Close without saving (revert colors)
function handleCancel() {
// Confirmation dialog state
let showConfirmClose = $state(false);
// Show confirmation before closing
function handleCloseClick() {
showConfirmClose = true;
}
// Confirm close - revert and close
function confirmClose() {
showConfirmClose = false;
themeStore.revert();
audioStore.revert();
open = false;
onclose?.();
}
// Cancel close - go back to settings
function cancelClose() {
showConfirmClose = false;
}
// Close without saving (revert colors) - used by backdrop
function handleCancel() {
showConfirmClose = true;
}
// Save and close
function handleSaveAndExit() {
themeStore.save();
audioStore.save();
open = false;
onclose?.();
}
@@ -36,7 +59,13 @@
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Escape") handleCancel();
if (e.key === "Escape") {
if (showConfirmClose) {
cancelClose();
} else {
handleCancel();
}
}
}
function handleMusicChange(value: number) {
@@ -83,7 +112,7 @@
{m.kv_settings_title()}
</h2>
<button
onclick={handleCancel}
onclick={handleCloseClick}
class="w-6 h-6 cursor-pointer bg-transparent border-none p-0 hover:opacity-70"
aria-label="Close"
>
@@ -167,12 +196,9 @@
>
{m.kv_settings_primary()}
</span>
<input
type="color"
value={themeStore.primary}
oninput={(e) =>
(themeStore.primary = e.currentTarget.value)}
class="w-8 h-8 border-4 border-black cursor-pointer bg-transparent"
<ColorPicker
bind:value={themeStore.primary}
onchange={(c) => (themeStore.primary = c)}
/>
</div>
<!-- Secondary Color -->
@@ -182,12 +208,9 @@
>
{m.kv_settings_secondary()}
</span>
<input
type="color"
value={themeStore.secondary}
oninput={(e) =>
(themeStore.secondary = e.currentTarget.value)}
class="w-8 h-8 border-4 border-black cursor-pointer bg-transparent"
<ColorPicker
bind:value={themeStore.secondary}
onchange={(c) => (themeStore.secondary = c)}
/>
</div>
<!-- Text Color -->
@@ -197,11 +220,9 @@
>
{m.kv_settings_text_color()}
</span>
<input
type="color"
value={themeStore.text}
oninput={(e) => (themeStore.text = e.currentTarget.value)}
class="w-8 h-8 border-4 border-black cursor-pointer bg-transparent"
<ColorPicker
bind:value={themeStore.text}
onchange={(c) => (themeStore.text = c)}
/>
</div>
<!-- Background Color -->
@@ -211,12 +232,9 @@
>
{m.kv_settings_background()}
</span>
<input
type="color"
value={themeStore.background}
oninput={(e) =>
(themeStore.background = e.currentTarget.value)}
class="w-8 h-8 border-4 border-black cursor-pointer bg-transparent"
<ColorPicker
bind:value={themeStore.background}
onchange={(c) => (themeStore.background = c)}
/>
</div>
</div>
@@ -237,4 +255,15 @@
{m.kv_settings_save_exit()}
</KvButtonSecondary>
</div>
<!-- Confirmation Dialog -->
<ConfirmDialog
bind:open={showConfirmClose}
title={m.kv_confirm_close_title()}
message={m.kv_confirm_close_message()}
confirmText={m.kv_confirm_discard()}
cancelText={m.kv_confirm_cancel()}
onconfirm={confirmClose}
oncancel={cancelClose}
/>
{/if}

View File

@@ -3,6 +3,8 @@ export { default as Slider } from './Slider.svelte';
export { default as Settings } from './Settings.svelte';
export { default as LanguageSwitcher } from './LanguageSwitcher.svelte';
export { default as Toast } from './Toast.svelte';
export { default as ConfirmDialog } from './ConfirmDialog.svelte';
export { default as ColorPicker } from './ColorPicker.svelte';
// Kuldvillak Components
export * from './kuldvillak';

View File

@@ -18,7 +18,7 @@
}: Props = $props();
const baseClasses =
"inline-flex items-center justify-center px-6 py-4 bg-kv-blue font-kv-body text-kv-white text-2xl uppercase cursor-pointer transition-opacity hover:opacity-80 disabled:opacity-50 disabled:cursor-not-allowed kv-shadow-button";
"inline-flex items-center justify-center px-6 py-4 bg-kv-blue text-kv-white 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";
</script>
{#if href && !disabled}

View File

@@ -18,7 +18,7 @@
}: Props = $props();
const baseClasses =
"inline-flex items-center justify-center px-6 py-4 bg-kv-yellow font-kv-body text-black text-2xl uppercase cursor-pointer transition-opacity hover:opacity-80 disabled:opacity-50 disabled:cursor-not-allowed kv-shadow-button";
"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";
</script>
{#if href && !disabled}

View File

@@ -0,0 +1,208 @@
<script lang="ts">
import * as m from "$lib/paraglide/messages";
import KvNumberInput from "./KvNumberInput.svelte";
interface Props {
name: string;
score: number;
scoreAdjustment?: number;
answering?: boolean;
selectable?: boolean;
// Final round mode
finalMode?: boolean;
finalJudged?: boolean;
finalActive?: boolean;
onFinalJudge?: (correct: boolean, wager: number) => void;
// Event handlers
onSelect?: () => void;
onAdd?: () => void;
onRemove?: () => void;
onCorrect?: () => void;
onWrong?: () => void;
}
let {
name,
score,
scoreAdjustment = 100,
answering = false,
selectable = false,
finalMode = false,
finalJudged = false,
finalActive = false,
onFinalJudge,
onSelect,
onAdd,
onRemove,
onCorrect,
onWrong,
}: Props = $props();
// Final round state
let finalAnswerCorrect = $state<boolean | null>(null);
let finalWagerInput = $state(0);
</script>
<div
class="group relative bg-kv-blue flex flex-col gap-4 items-center justify-center p-4 flex-1 min-w-0 box-border
{answering || finalActive
? 'border-8 border-kv-yellow'
: 'border-8 border-transparent'}
{selectable && !finalJudged ? 'cursor-pointer' : ''}
{finalJudged ? 'opacity-60' : ''}"
onclick={selectable && !finalJudged ? onSelect : undefined}
onkeydown={selectable && !finalJudged
? (e) => e.key === "Enter" && onSelect?.()
: undefined}
role={selectable && !finalJudged ? "button" : undefined}
tabindex={selectable && !finalJudged ? 0 : undefined}
>
<!-- Hover overlay - darkens background only, extends to cover border -->
{#if selectable && !finalJudged}
<div
class="absolute -inset-2 bg-black opacity-0 group-hover:opacity-20 transition-opacity pointer-events-none z-0"
></div>
{/if}
<!-- Name and Score -->
<div
class="relative z-10 flex flex-col gap-4 items-center font-kv-body text-kv-white text-center uppercase w-full
{selectable && !finalJudged
? 'group-hover:text-kv-yellow transition-colors'
: ''}"
>
<span class="text-2xl md:text-4xl kv-shadow-text">{name}</span>
<span
class="text-2xl md:text-4xl kv-shadow-text {score < 0
? 'text-kv-red'
: ''}">{score}</span
>
{#if finalJudged}
<span class="text-sm text-kv-green">{m.kv_play_judged()}</span>
{/if}
</div>
<!-- Action Buttons -->
<div class="relative z-10 flex flex-col gap-2 items-center justify-center">
{#if finalMode && finalActive}
<!-- Final round judging UI -->
{#if finalAnswerCorrect === null}
<div class="flex gap-2">
<button
onclick={() => (finalAnswerCorrect = true)}
class="bg-kv-green border-4 border-black box-border flex items-center justify-center p-2 cursor-pointer hover:opacity-80"
>
<span
class="font-kv-body text-xl md:text-[28px] text-kv-white uppercase kv-shadow-text"
>
{m.kv_play_correct()}
</span>
</button>
<button
onclick={() => (finalAnswerCorrect = false)}
class="bg-kv-red border-4 border-black box-border flex items-center justify-center p-2 cursor-pointer hover:opacity-80"
>
<span
class="font-kv-body text-xl md:text-[28px] text-kv-white uppercase kv-shadow-text"
>
{m.kv_play_wrong()}
</span>
</button>
</div>
{:else}
<!-- Wager input and confirm -->
<div class="flex items-center gap-2">
<KvNumberInput
bind:value={finalWagerInput}
min={0}
max={Math.max(score, 0)}
step={100}
/>
<span class="font-kv-body text-lg text-kv-white"></span>
</div>
<button
onclick={() => {
onFinalJudge?.(finalAnswerCorrect!, finalWagerInput);
finalAnswerCorrect = null;
finalWagerInput = 0;
}}
class="bg-kv-yellow border-4 border-black box-border font-kv-body text-lg text-black uppercase px-4 py-1 cursor-pointer hover:opacity-80"
>
{m.kv_play_confirm()}
</button>
{/if}
{:else if finalMode && !finalJudged && !finalActive}
<!-- Final mode but not active - show score adjustment buttons -->
<div class="flex gap-4">
<button
onclick={onAdd}
class="bg-kv-green border-4 border-black box-border flex items-center justify-center p-2 cursor-pointer hover:opacity-80"
>
<span
class="font-kv-body text-xl md:text-[28px] text-kv-white uppercase kv-shadow-text"
>
+{scoreAdjustment}
</span>
</button>
<button
onclick={onRemove}
class="bg-kv-red border-4 border-black box-border flex items-center justify-center p-2 cursor-pointer hover:opacity-80"
>
<span
class="font-kv-body text-xl md:text-[28px] text-kv-white uppercase kv-shadow-text"
>
-{scoreAdjustment}
</span>
</button>
</div>
{:else if answering}
<!-- Correct/Wrong buttons when answering -->
<div class="flex gap-4">
<button
onclick={onCorrect}
class="bg-kv-green border-4 border-black box-border flex items-center justify-center p-2 cursor-pointer hover:opacity-80"
>
<span
class="font-kv-body text-xl md:text-[28px] text-kv-white uppercase kv-shadow-text"
>
{m.kv_play_correct()}
</span>
</button>
<button
onclick={onWrong}
class="bg-kv-red border-4 border-black box-border flex items-center justify-center p-2 cursor-pointer hover:opacity-80"
>
<span
class="font-kv-body text-xl md:text-[28px] text-kv-white uppercase kv-shadow-text"
>
{m.kv_play_wrong()}
</span>
</button>
</div>
{:else if !finalMode}
<!-- Add/Remove score buttons in normal mode -->
<div class="flex gap-4">
<button
onclick={onAdd}
class="bg-kv-green border-4 border-black box-border flex items-center justify-center p-2 cursor-pointer hover:opacity-80"
>
<span
class="font-kv-body text-xl md:text-[28px] text-kv-white uppercase kv-shadow-text"
>
+{scoreAdjustment}
</span>
</button>
<button
onclick={onRemove}
class="bg-kv-red border-4 border-black box-border flex items-center justify-center p-2 cursor-pointer hover:opacity-80"
>
<span
class="font-kv-body text-xl md:text-[28px] text-kv-white uppercase kv-shadow-text"
>
-{scoreAdjustment}
</span>
</button>
</div>
{/if}
</div>
</div>

View File

@@ -1,70 +0,0 @@
<script lang="ts">
import type { Team } from "$lib/types/kuldvillak";
interface Props {
team: Team;
isActive?: boolean;
isAnswering?: boolean;
scoreAdjustment?: number;
onadjust?: (delta: number) => void;
onclick?: () => void;
disabled?: boolean;
class?: string;
}
let {
team,
isActive = false,
isAnswering = false,
scoreAdjustment = 100,
onadjust,
onclick,
disabled = false,
class: className = "",
}: Props = $props();
</script>
<div
class="flex flex-col gap-4 items-center justify-center p-4 bg-kv-board transition-all
{isActive ? 'ring-4 ring-kv-yellow' : ''}
{isAnswering ? 'ring-4 ring-kv-green' : ''}
{disabled ? 'opacity-50' : ''}
{className}"
role={onclick ? "button" : undefined}
tabindex={onclick ? 0 : undefined}
{onclick}
onkeydown={(e) => e.key === "Enter" && onclick?.()}
>
<!-- Name and Score -->
<button
class="flex flex-col gap-2 items-center font-kv-body text-kv-white uppercase text-center w-full cursor-pointer hover:brightness-110 transition-all
{disabled ? 'cursor-not-allowed' : ''}"
{onclick}
{disabled}
>
<span class="text-3xl kv-shadow-text">{team.name}</span>
<span
class="text-3xl kv-shadow-text {team.score < 0
? 'text-kv-red'
: ''}">{team.score}</span
>
</button>
<!-- Score Adjustment Buttons -->
{#if onadjust}
<div class="flex gap-4 items-center justify-center">
<button
class="flex-1 min-w-[70px] h-10 bg-kv-green border-4 border-black flex items-center justify-center font-kv-body text-xl text-kv-white uppercase cursor-pointer hover:brightness-110 transition-all"
onclick={() => onadjust(scoreAdjustment)}
>
<span class="kv-shadow-text">+{scoreAdjustment}</span>
</button>
<button
class="flex-1 min-w-[70px] h-10 bg-kv-red border-4 border-black flex items-center justify-center font-kv-body text-xl text-kv-white uppercase cursor-pointer hover:brightness-110 transition-all"
onclick={() => onadjust(-scoreAdjustment)}
>
<span class="kv-shadow-text">-{scoreAdjustment}</span>
</button>
</div>
{/if}
</div>

View File

@@ -1,67 +0,0 @@
<script lang="ts">
interface Props {
variant: "category" | "price" | "player";
text?: string;
score?: number;
isRevealed?: boolean;
isActive?: boolean;
class?: string;
}
let {
variant,
text = "",
score = 0,
isRevealed = false,
isActive = false,
class: className = "",
}: Props = $props();
</script>
{#if variant === "category"}
<!-- Category Card for Projector -->
<div
class="bg-kv-board px-4 py-8 flex items-center justify-center overflow-hidden {className}"
>
<span
class="font-kv-body text-kv-white text-5xl text-center uppercase leading-tight kv-shadow-text"
>
{text || "Kategooria"}
</span>
</div>
{:else if variant === "price"}
<!-- Price Card for Projector -->
<div
class="bg-kv-board flex items-center justify-center overflow-hidden {className}"
>
{#if isRevealed}
<span class="opacity-0">{text}</span>
{:else}
<span
class="font-kv-price text-kv-yellow text-7xl text-center kv-shadow-price"
>
{text}
</span>
{/if}
</div>
{:else if variant === "player"}
<!-- Player Score Card for Projector -->
<div
class="bg-kv-board px-4 py-4 flex flex-col items-center justify-center gap-1 overflow-hidden
{isActive ? 'ring-4 ring-kv-yellow' : ''}
{className}"
>
<span
class="font-kv-body text-kv-white text-3xl text-center uppercase leading-tight kv-shadow-text"
>
{text}
</span>
<span
class="font-kv-body text-3xl text-center kv-shadow-text {score < 0
? 'text-red-500'
: 'text-kv-white'}"
>
{score}
</span>
</div>
{/if}

View File

@@ -8,8 +8,7 @@ export { default as KvButtonSecondary } from './KvButtonSecondary.svelte';
export { default as KvNumberInput } from './KvNumberInput.svelte';
// Cards
export { default as KvProjectorCard } from './KvProjectorCard.svelte';
export { default as KvPlayerCard } from './KvPlayerCard.svelte';
export { default as KvEditCard } from './KvEditCard.svelte';
// Branding
export { default as KvLogo } from './KvGameLogo.svelte';

View File

@@ -5,8 +5,14 @@
// Kuldvillak (Jeopardy) Types
export * from './types/kuldvillak';
// Kuldvillak Store
export { kuldvillakStore } from './stores/kuldvillak.svelte';
// Game Session Store (live game state)
export { gameSession } from './stores/gameSession.svelte';
// Theme Store
export { themeStore } from './stores/theme.svelte';
// Audio Store
export { audioStore } from './stores/audio.svelte';
// Persistence (Save/Load)
export * from './stores/persistence';

View File

@@ -4,16 +4,27 @@ class AudioStore {
private audio: HTMLAudioElement | null = null;
private initialized = false;
// Current values (live preview)
musicVolume = $state(50);
sfxVolume = $state(100);
// Saved values (persisted)
private savedMusicVolume = 50;
private savedSfxVolume = 100;
constructor() {
if (browser) {
// Load saved volumes
const savedMusic = localStorage.getItem('kv_music_volume');
const savedSfx = localStorage.getItem('kv_sfx_volume');
if (savedMusic) this.musicVolume = parseInt(savedMusic);
if (savedSfx) this.sfxVolume = parseInt(savedSfx);
if (savedMusic) {
this.musicVolume = parseInt(savedMusic);
this.savedMusicVolume = this.musicVolume;
}
if (savedSfx) {
this.sfxVolume = parseInt(savedSfx);
this.savedSfxVolume = this.sfxVolume;
}
}
}
@@ -35,20 +46,34 @@ class AudioStore {
});
}
// Preview changes (not saved yet)
setMusicVolume(value: number) {
this.musicVolume = value;
if (this.audio) {
this.audio.volume = value / 100;
}
if (browser) {
localStorage.setItem('kv_music_volume', String(value));
}
}
setSfxVolume(value: number) {
this.sfxVolume = value;
}
// Save current values to localStorage
save() {
if (browser) {
localStorage.setItem('kv_sfx_volume', String(value));
localStorage.setItem('kv_music_volume', String(this.musicVolume));
localStorage.setItem('kv_sfx_volume', String(this.sfxVolume));
}
this.savedMusicVolume = this.musicVolume;
this.savedSfxVolume = this.sfxVolume;
}
// Revert to last saved values
revert() {
this.musicVolume = this.savedMusicVolume;
this.sfxVolume = this.savedSfxVolume;
if (this.audio) {
this.audio.volume = this.musicVolume / 100;
}
}

View File

@@ -45,6 +45,11 @@ export interface GameSessionState {
timerSeconds: number;
timerMax: number;
// Timeout countdowns (for displaying messages)
timeoutCountdown: number | null; // "Revealing answer in X seconds"
revealCountdown: number | null; // "Returning to board in X seconds"
skippingQuestion: boolean; // True when moderator clicked skip
// Question tracking
questionsAnswered: number; // How many questions have been answered
currentQuestionNumber: number; // Which question number is this (1-30)
@@ -148,6 +153,9 @@ class GameSessionStore {
timerRunning: false,
timerSeconds: 0,
timerMax: plainData.settings.defaultTimerSeconds ?? 10,
timeoutCountdown: null,
revealCountdown: null,
skippingQuestion: false,
questionsAnswered: 0,
currentQuestionNumber: 0,
questionResults: [],
@@ -195,8 +203,15 @@ class GameSessionStore {
this.persist();
}
// End the game session
// Transition to finished phase (show Kuldvillak screen)
endGame() {
if (!this.state) return;
this.state.phase = "finished";
this.persist();
}
// Fully clear the game session
clearSession() {
this.stopInternalTimer();
this.state = null;
if (browser) {
@@ -218,6 +233,8 @@ class GameSessionStore {
this.state.currentQuestion = { roundIndex, categoryIndex, questionIndex };
this.state.wrongTeamIds = [];
this.state.activeTeamId = null;
this.state.lastAnswerCorrect = null;
this.state.lastAnsweredTeamId = null;
this.state.currentQuestionNumber = this.state.questionsAnswered + 1;
if (question.isDailyDouble) {
@@ -274,12 +291,10 @@ class GameSessionStore {
this.state.lastAnsweredTeamId = teamId;
this.state.lastAnswerCorrect = true;
// Show answer and close after configured delay
// Show answer and start reveal countdown
this.state.showAnswer = true;
this.state.revealCountdown = this.state.settings.answerRevealSeconds ?? 5;
this.persist();
const revealMs = (this.state.settings.answerRevealSeconds ?? 5) * 1000;
setTimeout(() => this.finalizeQuestion(), revealMs);
}
// Mark answer wrong - deducts points, adds to wrong list
@@ -311,24 +326,25 @@ class GameSessionStore {
// Check if all teams have answered wrong
const allTeamsWrong = this.state.teams.every(t => this.state!.wrongTeamIds.includes(t.id));
if (allTeamsWrong) {
// Everyone wrong - show answer and close
this.state.showAnswer = true;
// Everyone wrong - start reveal countdown
this.state.timeoutCountdown = 5; // 5 seconds before showing answer
this.persist();
const revealMs = (this.state.settings.answerRevealSeconds ?? 5) * 1000;
setTimeout(() => this.finalizeQuestion(), revealMs);
} else {
this.persist();
}
}
// Skip question - shows answer, closes after delay
// Skip question - 5 second delay, then shows answer
skipQuestion() {
if (!this.state || !this.state.currentQuestion) return;
this.state.showAnswer = true;
// Stop timer if running
this.state.timerRunning = false;
this.state.activeTeamId = null;
// Mark as skipping and start countdown
this.state.skippingQuestion = true;
this.state.timeoutCountdown = 5;
this.persist();
const revealMs = (this.state.settings.answerRevealSeconds ?? 5) * 1000;
setTimeout(() => this.finalizeQuestion(), revealMs);
}
// Actually close the question and return to board
@@ -369,6 +385,9 @@ class GameSessionStore {
this.state.wrongTeamIds = [];
this.state.dailyDoubleWager = null;
this.state.activeTeamId = null;
this.state.timeoutCountdown = null;
this.state.revealCountdown = null;
this.state.skippingQuestion = false;
this.state.phase = "board";
// Check if round is complete
@@ -376,17 +395,6 @@ class GameSessionStore {
this.persist();
}
// Legacy method for compatibility
closeQuestion(correct: boolean | null, teamId?: string | null) {
if (correct === true && teamId) {
this.markCorrect(teamId);
} else if (correct === false && teamId) {
this.markWrong(teamId);
} else {
this.skipQuestion();
}
}
private checkRoundComplete() {
if (!this.state) return;
@@ -414,6 +422,12 @@ class GameSessionStore {
setActiveTeam(teamId: string | null) {
if (!this.state) return;
this.state.activeTeamId = teamId;
// Pause timer when a team is selected to answer
if (teamId !== null && this.state.timerRunning) {
this.state.timerRunning = false;
}
this.persist();
}
@@ -476,17 +490,14 @@ class GameSessionStore {
// Final Round
// ============================================
setFinalWager(teamId: string, wager: number) {
if (!this.state) return;
this.state.finalWagers[teamId] = wager;
this.persist();
}
showFinalQuestion() {
if (!this.state) return;
this.state.phase = "final-question";
this.state.timerMax = 30; // Set 30 second timer for final round
this.state.timerSeconds = 30;
this.state.timerRunning = false;
this.state.activeTeamId = null;
this.state.finalRevealed = [];
this.persist();
}
@@ -496,17 +507,26 @@ class GameSessionStore {
this.persist();
}
setFinalAnswer(teamId: string, answer: string) {
// Reveal the final answer (after all teams judged)
revealFinalAnswer() {
if (!this.state) return;
this.state.finalAnswers[teamId] = answer;
this.state.showAnswer = true;
this.persist();
}
revealFinalAnswer(teamId: string, correct: boolean) {
// Select a team to judge their final answer
selectFinalTeam(teamId: string) {
if (!this.state) return;
if (this.state.finalRevealed.includes(teamId)) return; // Already judged
this.state.activeTeamId = teamId;
this.persist();
}
// Judge final answer - applies wager to score
judgeFinalAnswer(teamId: string, correct: boolean, wager: number) {
if (!this.state) return;
const team = this.state.teams.find(t => t.id === teamId);
const wager = this.state.finalWagers[teamId] ?? 0;
if (team) {
if (correct) {
@@ -516,12 +536,10 @@ class GameSessionStore {
}
}
// Store the wager for display purposes
this.state.finalWagers[teamId] = wager;
this.state.finalRevealed.push(teamId);
// Check if all revealed
if (this.state.finalRevealed.length === this.state.teams.length) {
this.state.phase = "finished";
}
this.state.activeTeamId = null;
this.persist();
}
@@ -535,12 +553,71 @@ class GameSessionStore {
if (this.timerInterval) return;
this.timerInterval = setInterval(() => {
if (this.state?.timerRunning) {
if (this.state.timerSeconds > 0) {
this.state.timerSeconds--;
if (!this.state) return;
// Handle timeout countdown (revealing answer)
if (this.state.timeoutCountdown !== null && this.state.timeoutCountdown > 0) {
this.state.timeoutCountdown--;
this.persist();
if (this.state.timeoutCountdown === 0) {
// Show answer and start reveal countdown
this.state.showAnswer = true;
this.state.timeoutCountdown = null;
this.state.revealCountdown = this.state.settings.answerRevealSeconds ?? 5;
this.persist();
}
return;
}
// Handle reveal countdown (returning to board)
if (this.state.revealCountdown !== null && this.state.revealCountdown > 0) {
this.state.revealCountdown--;
this.persist();
if (this.state.revealCountdown === 0) {
// Return to board
if (this.state.currentQuestion) {
const { roundIndex, categoryIndex, questionIndex } = this.state.currentQuestion;
const question = this.state.rounds[roundIndex]?.categories[categoryIndex]?.questions[questionIndex];
if (question) {
question.isRevealed = true;
this.state.questionsAnswered++;
}
}
// Reset state and return to board
this.state.currentQuestion = null;
this.state.showAnswer = false;
this.state.wrongTeamIds = [];
this.state.dailyDoubleWager = null;
this.state.activeTeamId = null;
this.state.timeoutCountdown = null;
this.state.revealCountdown = null;
this.state.skippingQuestion = false;
this.state.phase = "board";
this.checkRoundComplete();
this.persist();
}
return;
}
// Handle normal timer countdown
if (this.state.timerRunning && this.state.timerSeconds > 0) {
this.state.timerSeconds--;
this.persist();
} else if (this.state.timerRunning && this.state.timerSeconds === 0) {
// Timer expired - start timeout countdown
this.state.timerRunning = false;
// In question phase, handle timeout
if (this.state.phase === "question") {
// Clear active team (no one can answer now)
this.state.activeTeamId = null;
// Start 5 second countdown to answer reveal
this.state.timeoutCountdown = 5;
this.persist();
} else {
this.state.timerRunning = false;
this.persist();
}
}
@@ -578,15 +655,13 @@ class GameSessionStore {
this.persist();
}
// Called externally - no longer needed but kept for compatibility
tickTimer() {
// Timer now runs internally, this is a no-op
}
resetTimer() {
if (!this.state) return;
this.state.timerSeconds = this.state.timerMax;
this.state.timerRunning = false;
// Also clear any timeout countdowns
this.state.timeoutCountdown = null;
this.state.revealCountdown = null;
this.persist();
}

View File

@@ -1,363 +0,0 @@
// ============================================
// Kuldvillak Game State Store (Svelte 5 Runes)
// ============================================
import {
type KuldvillakGame,
type Team,
type Question,
type GamePhase,
type Round,
type Category,
DEFAULT_SETTINGS,
DEFAULT_STATE
} from '$lib/types/kuldvillak';
// ============================================
// Utility Functions
// ============================================
function generateId(): string {
return crypto.randomUUID();
}
function createEmptyGame(name: string = 'New Game'): KuldvillakGame {
const now = new Date().toISOString();
return {
id: generateId(),
name,
createdAt: now,
updatedAt: now,
settings: { ...DEFAULT_SETTINGS },
teams: [],
rounds: [],
finalRound: null,
state: { ...DEFAULT_STATE }
};
}
function createEmptyRound(name: string, multiplier: number, settings: typeof DEFAULT_SETTINGS): Round {
const categories: Category[] = [];
for (let i = 0; i < settings.categoriesPerRound; i++) {
const questions: Question[] = settings.pointValues.map((points) => ({
id: generateId(),
question: '',
answer: '',
points: points * multiplier,
isDailyDouble: false,
isRevealed: false
}));
categories.push({
id: generateId(),
name: `Category ${i + 1}`,
questions
});
}
return {
id: generateId(),
name,
categories,
pointMultiplier: multiplier
};
}
// ============================================
// Game Store Class
// ============================================
class KuldvillakStore {
// Reactive state using Svelte 5 runes
game = $state<KuldvillakGame | null>(null);
savedGames = $state<KuldvillakGame[]>([]);
// Derived values
get currentRound(): Round | null {
if (!this.game) return null;
return this.game.rounds[this.game.state.currentRoundIndex] ?? null;
}
get currentQuestion(): Question | null {
if (!this.game || !this.currentRound || !this.game.state.currentQuestionId) return null;
for (const category of this.currentRound.categories) {
const question = category.questions.find((q) => q.id === this.game!.state.currentQuestionId);
if (question) return question;
}
return null;
}
get currentCategory(): Category | null {
if (!this.game || !this.currentRound || !this.game.state.currentCategoryId) return null;
return this.currentRound.categories.find((c) => c.id === this.game!.state.currentCategoryId) ?? null;
}
get activeTeam(): Team | null {
if (!this.game || !this.game.state.activeTeamId) return null;
return this.game.teams.find((t) => t.id === this.game!.state.activeTeamId) ?? null;
}
get isRoundComplete(): boolean {
if (!this.currentRound) return false;
return this.currentRound.categories.every((c) => c.questions.every((q) => q.isRevealed));
}
get isGameComplete(): boolean {
if (!this.game) return false;
const allRoundsComplete = this.game.rounds.every((round) =>
round.categories.every((c) => c.questions.every((q) => q.isRevealed))
);
if (!this.game.settings.enableFinalRound) return allRoundsComplete;
return allRoundsComplete && this.game.state.phase === 'finished';
}
// ============================================
// Game Lifecycle
// ============================================
newGame(name: string = 'New Game'): void {
this.game = createEmptyGame(name);
// Create default 2 rounds (Jeopardy + Double Jeopardy)
this.game.rounds = [
createEmptyRound('Round 1', 1, this.game.settings),
createEmptyRound('Round 2', 2, this.game.settings)
];
}
loadGame(gameData: KuldvillakGame): void {
this.game = gameData;
}
resetGame(): void {
if (!this.game) return;
// Reset all questions to unrevealed
for (const round of this.game.rounds) {
for (const category of round.categories) {
for (const question of category.questions) {
question.isRevealed = false;
}
}
}
// Reset teams scores
for (const team of this.game.teams) {
team.score = 0;
}
// Reset state
this.game.state = { ...DEFAULT_STATE };
}
closeGame(): void {
this.game = null;
}
// ============================================
// Team Management
// ============================================
addTeam(name: string): void {
if (!this.game) return;
this.game.teams.push({
id: generateId(),
name,
score: 0
});
}
removeTeam(teamId: string): void {
if (!this.game) return;
this.game.teams = this.game.teams.filter((t) => t.id !== teamId);
}
updateTeamName(teamId: string, name: string): void {
if (!this.game) return;
const team = this.game.teams.find((t) => t.id === teamId);
if (team) team.name = name;
}
updateTeamScore(teamId: string, score: number): void {
if (!this.game) return;
const team = this.game.teams.find((t) => t.id === teamId);
if (team) team.score = score;
}
adjustTeamScore(teamId: string, delta: number): void {
if (!this.game) return;
const team = this.game.teams.find((t) => t.id === teamId);
if (team) team.score += delta;
}
setActiveTeam(teamId: string | null): void {
if (!this.game) return;
this.game.state.activeTeamId = teamId;
}
// ============================================
// Game Flow Control
// ============================================
setPhase(phase: GamePhase): void {
if (!this.game) return;
this.game.state.phase = phase;
}
startGame(): void {
if (!this.game || this.game.teams.length === 0) return;
this.game.state.phase = 'board';
this.game.state.currentRoundIndex = 0;
}
selectQuestion(categoryId: string, questionId: string): void {
if (!this.game) return;
const question = this.findQuestion(questionId);
if (!question || question.isRevealed) return;
this.game.state.currentCategoryId = categoryId;
this.game.state.currentQuestionId = questionId;
if (question.isDailyDouble) {
this.game.state.phase = 'daily-double';
} else {
this.game.state.phase = 'question';
}
}
setDailyDoubleWager(wager: number): void {
if (!this.game) return;
this.game.state.dailyDoubleWager = wager;
this.game.state.phase = 'question';
}
revealAnswer(): void {
if (!this.game) return;
this.game.state.phase = 'answer';
}
markCorrect(teamId: string): void {
if (!this.game || !this.currentQuestion) return;
const points = this.game.state.dailyDoubleWager ?? this.currentQuestion.points;
this.adjustTeamScore(teamId, points);
this.finishQuestion();
}
markIncorrect(teamId: string): void {
if (!this.game || !this.currentQuestion) return;
const points = this.game.state.dailyDoubleWager ?? this.currentQuestion.points;
this.adjustTeamScore(teamId, -points);
}
finishQuestion(): void {
if (!this.game || !this.game.state.currentQuestionId) return;
const question = this.findQuestion(this.game.state.currentQuestionId);
if (question) question.isRevealed = true;
this.game.state.currentQuestionId = null;
this.game.state.currentCategoryId = null;
this.game.state.dailyDoubleWager = null;
this.game.state.activeTeamId = null;
// Check if round is complete
if (this.isRoundComplete) {
this.advanceRound();
} else {
this.game.state.phase = 'board';
}
}
advanceRound(): void {
if (!this.game) return;
const nextIndex = this.game.state.currentRoundIndex + 1;
if (nextIndex < this.game.rounds.length) {
this.game.state.currentRoundIndex = nextIndex;
this.game.state.phase = 'board';
} else if (this.game.settings.enableFinalRound && this.game.finalRound) {
this.game.state.phase = 'final-category';
} else {
this.game.state.phase = 'finished';
}
}
// Final Round
startFinalRound(): void {
if (!this.game) return;
this.game.state.phase = 'final-question';
}
setFinalWager(teamId: string, wager: number): void {
if (!this.game) return;
this.game.state.finalWagers[teamId] = wager;
}
setFinalAnswer(teamId: string, answer: string): void {
if (!this.game) return;
this.game.state.finalAnswers[teamId] = answer;
}
scoreFinalAnswer(teamId: string, correct: boolean): void {
if (!this.game) return;
const wager = this.game.state.finalWagers[teamId] ?? 0;
this.adjustTeamScore(teamId, correct ? wager : -wager);
}
finishGame(): void {
if (!this.game) return;
this.game.state.phase = 'finished';
}
// ============================================
// Editor Functions
// ============================================
updateGameName(name: string): void {
if (!this.game) return;
this.game.name = name;
this.game.updatedAt = new Date().toISOString();
}
updateCategoryName(roundIndex: number, categoryId: string, name: string): void {
if (!this.game) return;
const category = this.game.rounds[roundIndex]?.categories.find((c) => c.id === categoryId);
if (category) category.name = name;
}
updateQuestion(questionId: string, updates: Partial<Pick<Question, 'question' | 'answer' | 'isDailyDouble'>>): void {
if (!this.game) return;
const question = this.findQuestion(questionId);
if (question) {
if (updates.question !== undefined) question.question = updates.question;
if (updates.answer !== undefined) question.answer = updates.answer;
if (updates.isDailyDouble !== undefined) question.isDailyDouble = updates.isDailyDouble;
}
}
updateFinalRound(category: string, question: string, answer: string): void {
if (!this.game) return;
this.game.finalRound = { category, question, answer };
}
addRound(): void {
if (!this.game) return;
const multiplier = this.game.rounds.length + 1;
this.game.rounds.push(createEmptyRound(`Round ${multiplier}`, multiplier, this.game.settings));
}
removeRound(roundIndex: number): void {
if (!this.game || this.game.rounds.length <= 1) return;
this.game.rounds.splice(roundIndex, 1);
}
// ============================================
// Helper Functions
// ============================================
private findQuestion(questionId: string): Question | null {
if (!this.game) return null;
for (const round of this.game.rounds) {
for (const category of round.categories) {
const question = category.questions.find((q) => q.id === questionId);
if (question) return question;
}
}
return null;
}
}
// Export singleton instance
export const kuldvillakStore = new KuldvillakStore();

View File

@@ -1,6 +1,13 @@
import { browser } from "$app/environment";
const THEME_STORAGE_KEY = "kuldvillak-theme";
const THEME_CHANNEL_NAME = "kuldvillak-theme-sync";
// BroadcastChannel for syncing theme across windows
let channel: BroadcastChannel | null = null;
if (browser) {
channel = new BroadcastChannel(THEME_CHANNEL_NAME);
}
// Default theme colors
export const DEFAULT_THEME = {
@@ -45,15 +52,32 @@ let savedSecondary = $state(initialTheme.secondary);
let savedText = $state(initialTheme.text);
let savedBackground = $state(initialTheme.background);
function applyTheme() {
function applyTheme(broadcast = true) {
if (browser) {
document.documentElement.style.setProperty("--kv-blue", primary);
document.documentElement.style.setProperty("--kv-yellow", secondary);
document.documentElement.style.setProperty("--kv-text", text);
document.documentElement.style.setProperty("--kv-background", background);
// Broadcast to other windows
if (broadcast && channel) {
channel.postMessage({ primary, secondary, text, background });
}
}
}
// Listen for theme changes from other windows
if (browser && channel) {
channel.onmessage = (event) => {
const { primary: p, secondary: s, text: t, background: b } = event.data;
if (p) primary = p;
if (s) secondary = s;
if (t) text = t;
if (b) background = b;
applyTheme(false); // Don't re-broadcast
};
}
// Save current values to localStorage
function save() {
if (browser) {

View File

@@ -53,6 +53,7 @@ export type GamePhase =
| 'daily-double'
| 'final-intro' // Final round intro (Kuldvillak screen)
| 'final-category' // Reveal final round category
| 'final-wagers' // Collect wagers from each team
| 'final-question'
| 'final-reveal'
| 'final-scores'

View File

@@ -25,6 +25,10 @@
}
</script>
<svelte:head>
<title>Ultimate Gaming</title>
</svelte:head>
<LanguageSwitcher />
<div class="min-h-screen flex items-center justify-center bg-[#0a121f]">

View File

@@ -31,6 +31,10 @@
}
</script>
<svelte:head>
<title>Kuldvillak - Ultimate Gaming</title>
</svelte:head>
<div
class="relative min-h-screen flex items-center justify-center overflow-hidden"
>
@@ -40,11 +44,14 @@
<!-- Content -->
<div class="relative z-10 flex flex-col items-center gap-8 md:gap-16 p-4">
<!-- Kuldvillak Logo -->
<KvLogo size="lg" class="md:h-48 md:max-w-[768px]" />
<KvLogo
size="lg"
class="md:h-48 md:max-w-[768px] drop-shadow-[6px_6px_4px_rgba(0,0,0,0.5)]"
/>
<!-- Menu Buttons -->
<div
class="flex flex-col gap-2 p-2 w-56 md:w-64 bg-kv-black border-4 border-kv-black"
class="flex flex-col gap-2 p-2 w-56 md:w-64 bg-kv-black border-2 border-kv-black"
>
<KvButtonPrimary
href="/kuldvillak/edit"

View File

@@ -2,10 +2,9 @@
import { goto } from "$app/navigation";
import { onMount } from "svelte";
import { browser } from "$app/environment";
import { Toast, Settings } from "$lib/components";
import { Toast, Settings, ConfirmDialog } from "$lib/components";
import * as m from "$lib/paraglide/messages";
import { gameSession } from "$lib/stores/gameSession.svelte";
import { themeStore } from "$lib/stores/theme.svelte";
import type {
GameSettings,
Team,
@@ -54,6 +53,24 @@
// File input ref
let fileInput: HTMLInputElement;
// Confirm dialog states
let showResetConfirm = $state(false);
let showQuestionCloseConfirm = $state(false);
let showFinalCloseConfirm = $state(false);
// Original values for reverting
let originalQuestion = $state<{
question: string;
answer: string;
imageUrl?: string;
isDailyDouble: boolean;
} | null>(null);
let originalFinal = $state<{
category: string;
question: string;
answer: string;
} | null>(null);
// Autosave to localStorage
function autoSave() {
if (!browser) return;
@@ -69,15 +86,19 @@
const data = JSON.parse(saved);
if (data.settings && data.teams && data.rounds) {
gameName = data.name || "";
const { teamColors, ...cleanSettings } = data.settings;
settings = { ...DEFAULT_SETTINGS, ...cleanSettings };
teams = data.teams.map((t: any) => ({
const { teamColors, ...cleanSettings } =
data.settings as Record<string, unknown>;
settings = {
...DEFAULT_SETTINGS,
...cleanSettings,
} as GameSettings;
teams = (data.teams as Team[]).map((t) => ({
id: t.id,
name: t.name,
score: t.score ?? 0,
}));
rounds = data.rounds;
finalRound = data.finalRound || {
rounds = data.rounds as Round[];
finalRound = (data.finalRound as FinalRound) || {
category: "",
question: "",
answer: "",
@@ -102,7 +123,7 @@
}
function generateId(): string {
return Math.random().toString(36).substring(2, 11);
return crypto.randomUUID();
}
function createQuestion(points: number): Question {
@@ -266,7 +287,6 @@
}
function resetGame() {
if (!confirm(m.kv_edit_reset_confirm())) return;
settings = {
...DEFAULT_SETTINGS,
defaultTimerSeconds: 5,
@@ -292,15 +312,19 @@
const data = JSON.parse(e.target?.result as string);
if (data.settings && data.teams && data.rounds) {
gameName = data.name || "Loaded Game";
const { teamColors, ...cleanSettings } = data.settings;
settings = { ...DEFAULT_SETTINGS, ...cleanSettings };
teams = data.teams.map((t: any) => ({
const { teamColors, ...cleanSettings } =
data.settings as Record<string, unknown>;
settings = {
...DEFAULT_SETTINGS,
...cleanSettings,
} as GameSettings;
teams = (data.teams as Team[]).map((t) => ({
id: t.id,
name: t.name,
score: t.score ?? 0,
}));
rounds = data.rounds;
finalRound = data.finalRound || {
rounds = data.rounds as Round[];
finalRound = (data.finalRound as FinalRound) || {
category: "",
question: "",
answer: "",
@@ -332,25 +356,75 @@
rounds = [...rounds];
}
function openQuestion(
roundIndex: number,
catIndex: number,
qIndex: number,
) {
const q = rounds[roundIndex].categories[catIndex].questions[qIndex];
originalQuestion = {
question: q.question,
answer: q.answer,
imageUrl: q.imageUrl,
isDailyDouble: q.isDailyDouble,
};
editingQuestion = { roundIndex, catIndex, qIndex };
}
function saveQuestion() {
originalQuestion = null;
editingQuestion = null;
}
function getEditingQuestion() {
if (!editingQuestion) return null;
return rounds[editingQuestion.roundIndex].categories[
editingQuestion.catIndex
].questions[editingQuestion.qIndex];
function handleQuestionCloseClick() {
showQuestionCloseConfirm = true;
}
function getEditingCategory() {
if (!editingQuestion) return null;
return rounds[editingQuestion.roundIndex].categories[
editingQuestion.catIndex
];
function discardQuestionChanges() {
if (editingQuestion && originalQuestion) {
const q =
rounds[editingQuestion.roundIndex].categories[
editingQuestion.catIndex
].questions[editingQuestion.qIndex];
q.question = originalQuestion.question;
q.answer = originalQuestion.answer;
q.imageUrl = originalQuestion.imageUrl;
q.isDailyDouble = originalQuestion.isDailyDouble;
rounds = [...rounds];
}
showQuestionCloseConfirm = false;
originalQuestion = null;
editingQuestion = null;
}
function openFinalQuestion() {
originalFinal = { ...finalRound };
editingFinalQuestion = true;
}
function saveFinalQuestion() {
originalFinal = null;
editingFinalQuestion = false;
}
function handleFinalCloseClick() {
showFinalCloseConfirm = true;
}
function discardFinalChanges() {
if (originalFinal) {
finalRound = { ...originalFinal };
}
showFinalCloseConfirm = false;
originalFinal = null;
editingFinalQuestion = false;
}
</script>
<svelte:head>
<title>{m.kv_edit_title()} - Kuldvillak</title>
</svelte:head>
<!-- Main Layout -->
<div class="min-h-screen bg-kv-black flex flex-col gap-2 md:gap-4 p-2 md:p-4">
<!-- Header -->
@@ -361,6 +435,7 @@
<a
href="/kuldvillak"
class="block w-12 h-12 hover:opacity-80 transition-opacity text-kv-yellow"
aria-label={m.kv_edit_back()}
>
<svg
viewBox="0 0 48 48"
@@ -386,6 +461,7 @@
<button
onclick={() => fileInput.click()}
class="w-10 h-10 hover:opacity-80 transition-opacity bg-transparent border-none p-0 cursor-pointer text-kv-yellow"
aria-label={m.kv_edit_load()}
>
<svg
viewBox="0 0 40 40"
@@ -400,6 +476,7 @@
<button
onclick={saveGame}
class="w-10 h-10 hover:opacity-80 transition-opacity bg-transparent border-none p-0 cursor-pointer text-kv-yellow"
aria-label={m.kv_edit_save()}
>
<svg
viewBox="0 0 40 40"
@@ -412,8 +489,9 @@
</svg>
</button>
<button
onclick={resetGame}
onclick={() => (showResetConfirm = true)}
class="w-10 h-10 hover:opacity-80 transition-opacity bg-transparent border-none p-0 cursor-pointer text-kv-yellow"
aria-label={m.kv_edit_reset()}
>
<svg
viewBox="0 0 40 40"
@@ -428,6 +506,7 @@
<button
onclick={() => (settingsOpen = true)}
class="w-10 h-10 hover:opacity-80 transition-opacity bg-transparent border-none p-0 cursor-pointer text-kv-yellow"
aria-label={m.kv_settings()}
>
<svg
viewBox="0 0 24 24"
@@ -452,7 +531,7 @@
<button
onclick={startGame}
disabled={isStarting}
class="bg-kv-yellow px-6 py-4 font-kv-body text-2xl text-black uppercase cursor-pointer hover:opacity-90 transition-opacity disabled:opacity-50 border-none kv-shadow-button"
class="bg-kv-yellow px-6 py-4 kv-btn-text text-black cursor-pointer hover:opacity-90 transition-opacity disabled:opacity-50 border-4 border-black kv-shadow-button"
>
{isStarting ? "⏳" : "▶"}
{m.kv_edit_start()}
@@ -465,22 +544,11 @@
<div
class="flex flex-wrap items-center justify-between gap-4 mb-4 md:mb-8"
>
<h2
class="font-kv-body text-lg md:text-[28px] text-kv-white uppercase kv-shadow-text m-0"
>
<h2 class="kv-h3 text-kv-white m-0">
{m.kv_edit_settings_teams()}
</h2>
<div class="flex gap-2">
<button
class="px-4 py-2 bg-kv-yellow font-kv-body text-sm md:text-lg text-black uppercase cursor-pointer border-none kv-shadow-button hover:opacity-90"
>
{m.kv_edit_rules()}
</button>
<button
class="px-4 py-2 bg-kv-yellow font-kv-body text-sm md:text-lg text-black uppercase cursor-pointer border-none kv-shadow-button hover:opacity-90"
>
{m.kv_edit_how_to()}
</button>
<!-- ... (no changes) -->
</div>
</div>
@@ -490,26 +558,22 @@
<!-- Labels Column -->
<div class="flex flex-col gap-4">
<div class="h-12 flex items-center">
<span
class="font-kv-body text-xl text-kv-white uppercase kv-shadow-text"
<span class="kv-label text-kv-white"
>{m.kv_edit_rounds()}</span
>
</div>
<div class="h-12 flex items-center">
<span
class="font-kv-body text-xl text-kv-white uppercase kv-shadow-text"
<span class="kv-label text-kv-white"
>{m.kv_play_timer()}</span
>
</div>
<div class="h-12 flex items-center">
<span
class="font-kv-body text-xl text-kv-white uppercase kv-shadow-text"
<span class="kv-label text-kv-white"
>{m.kv_play_timer_reveal()}</span
>
</div>
<div class="h-12 flex items-center">
<span
class="font-kv-body text-xl text-kv-white uppercase kv-shadow-text"
<span class="kv-label text-kv-white"
>{m.kv_edit_final_round()}</span
>
</div>
@@ -575,9 +639,9 @@
<button
onclick={() =>
settings.enableFinalRound
? (editingFinalQuestion = true)
? openFinalQuestion()
: (settings.enableFinalRound = true)}
class="px-4 py-2 bg-kv-yellow font-kv-body text-base text-black uppercase cursor-pointer border-none kv-shadow-button hover:opacity-90"
class="px-4 py-2 bg-kv-yellow font-kv-body text-base text-black uppercase cursor-pointer border-4 border-black kv-shadow-button hover:opacity-90"
>
{settings.enableFinalRound
? m.kv_edit_question()
@@ -692,6 +756,7 @@
<button
onclick={() => removeTeam(team.id)}
class="w-8 h-8 bg-transparent border-none cursor-pointer p-0 flex items-center justify-center hover:opacity-70 flex-shrink-0 text-kv-yellow"
aria-label={m.kv_edit_remove_team()}
>
<svg
viewBox="0 0 24 24"
@@ -710,6 +775,7 @@
onclick={addTeam}
disabled={teams.length >= 6}
class="w-10 h-10 bg-transparent border-none cursor-pointer p-0 hover:opacity-70 flex items-center justify-center text-kv-yellow"
aria-label={m.kv_edit_add_team()}
>
<svg
viewBox="0 0 48 48"
@@ -772,12 +838,7 @@
{#each round.categories as cat, ci}
{@const q = cat.questions[qi]}
<button
onclick={() =>
(editingQuestion = {
roundIndex: ri,
catIndex: ci,
qIndex: qi,
})}
onclick={() => openQuestion(ri, ci, qi)}
class="bg-kv-blue flex items-center justify-center cursor-pointer border-none transition-opacity relative
{q.question.trim() ? 'opacity-100' : 'opacity-50'}
{q.isDailyDouble ? 'ring-2 ring-inset ring-kv-yellow' : ''}"
@@ -807,8 +868,9 @@
{@const currentDD = countDailyDoubles(editingQuestion.roundIndex)}
<div
class="fixed inset-0 bg-kv-black/80 flex items-center justify-center z-50 p-2 md:p-8"
onclick={(e) => e.target === e.currentTarget && saveQuestion()}
onkeydown={(e) => e.key === "Escape" && saveQuestion()}
onclick={(e) =>
e.target === e.currentTarget && handleQuestionCloseClick()}
onkeydown={(e) => e.key === "Escape" && handleQuestionCloseClick()}
role="dialog"
tabindex="-1"
>
@@ -823,8 +885,9 @@
{cat.name || m.kv_edit_category()} - {q.points}
</h3>
<button
onclick={saveQuestion}
onclick={handleQuestionCloseClick}
class="w-8 h-8 bg-transparent border-none cursor-pointer p-0 hover:opacity-70 text-kv-yellow"
aria-label={m.kv_settings_close()}
>
<svg
viewBox="0 0 24 24"
@@ -887,21 +950,34 @@
<!-- Save button -->
<button
onclick={saveQuestion}
class="bg-kv-yellow px-6 py-4 font-kv-body text-2xl text-black uppercase cursor-pointer border-none kv-shadow-button hover:opacity-90 self-start"
class="bg-kv-yellow px-6 py-4 font-kv-body text-2xl text-black uppercase cursor-pointer border-4 border-black kv-shadow-button hover:opacity-90 self-start"
>
{m.kv_edit_save_exit()}
</button>
</div>
</div>
<!-- Question Confirm Dialog -->
<ConfirmDialog
bind:open={showQuestionCloseConfirm}
title={m.kv_confirm_close_title()}
message={m.kv_confirm_close_message()}
confirmText={m.kv_confirm_discard()}
cancelText={m.kv_edit_save()}
onconfirm={discardQuestionChanges}
oncancel={() => {
showQuestionCloseConfirm = false;
saveQuestion();
}}
/>
{/if}
<!-- Final Question Modal -->
{#if editingFinalQuestion}
<div
class="fixed inset-0 bg-kv-black/80 flex items-center justify-center z-50 p-2 md:p-8"
onclick={(e) =>
e.target === e.currentTarget && (editingFinalQuestion = false)}
onkeydown={(e) => e.key === "Escape" && (editingFinalQuestion = false)}
onclick={(e) => e.target === e.currentTarget && handleFinalCloseClick()}
onkeydown={(e) => e.key === "Escape" && handleFinalCloseClick()}
role="dialog"
tabindex="-1"
>
@@ -915,8 +991,9 @@
{m.kv_edit_final_round()}
</h3>
<button
onclick={() => (editingFinalQuestion = false)}
onclick={handleFinalCloseClick}
class="w-8 h-8 bg-transparent border-none cursor-pointer p-0 hover:opacity-70 text-kv-yellow"
aria-label={m.kv_settings_close()}
>
<svg
viewBox="0 0 24 24"
@@ -977,13 +1054,27 @@
</div>
<button
onclick={() => (editingFinalQuestion = false)}
class="bg-kv-yellow px-6 py-4 font-kv-body text-2xl text-black uppercase cursor-pointer border-none kv-shadow-button hover:opacity-90 self-start"
onclick={saveFinalQuestion}
class="bg-kv-yellow px-6 py-4 font-kv-body text-2xl text-black uppercase cursor-pointer border-4 border-black kv-shadow-button hover:opacity-90 self-start"
>
{m.kv_edit_save_exit()}
</button>
</div>
</div>
<!-- Final Confirm Dialog -->
<ConfirmDialog
bind:open={showFinalCloseConfirm}
title={m.kv_confirm_close_title()}
message={m.kv_confirm_close_message()}
confirmText={m.kv_confirm_discard()}
cancelText={m.kv_edit_save()}
onconfirm={discardFinalChanges}
oncancel={() => {
showFinalCloseConfirm = false;
saveFinalQuestion();
}}
/>
{/if}
<!-- Settings Modal -->
@@ -1005,3 +1096,12 @@
</p>
</div>
{/if}
<!-- Reset Confirmation -->
<ConfirmDialog
bind:open={showResetConfirm}
title={m.kv_edit_reset()}
message={m.kv_edit_reset_confirm()}
confirmText={m.kv_edit_reset()}
onconfirm={resetGame}
/>

View File

@@ -9,6 +9,10 @@
let view = $derived($page.url.searchParams.get("view") ?? "moderator");
</script>
<svelte:head>
<title>{gameSession.state?.name ?? "Play"} - Kuldvillak</title>
</svelte:head>
{#if !gameSession.state}
<div class="h-screen w-screen flex items-center justify-center bg-kv-black">
<div class="text-center font-[family-name:var(--kv-font-button)]">

File diff suppressed because it is too large Load Diff

View File

@@ -20,6 +20,9 @@
let animationPhase = $state<"waiting" | "expanding" | "shown" | "none">(
"none",
);
let finalAnimPhase = $state<"waiting" | "expanding" | "shown" | "none">(
"none",
);
let prevPhase = $state<string | null>(null);
// Intro category animation state (used for both regular and final round)
@@ -52,19 +55,17 @@
return { left: 50, top: 50, width: 16, height: 20 };
}
const gridRect = questionGridEl.getBoundingClientRect();
const cardRect = card.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Calculate center position relative to viewport
const left =
((cardRect.left - gridRect.left + cardRect.width / 2) /
gridRect.width) *
100;
((cardRect.left + cardRect.width / 2) / viewportWidth) * 100;
const top =
((cardRect.top - gridRect.top + cardRect.height / 2) /
gridRect.height) *
100;
const width = (cardRect.width / gridRect.width) * 100;
const height = (cardRect.height / gridRect.height) * 100;
((cardRect.top + cardRect.height / 2) / viewportHeight) * 100;
const width = (cardRect.width / viewportWidth) * 100;
const height = (cardRect.height / viewportHeight) * 100;
return { left, top, width, height };
});
@@ -77,14 +78,30 @@
animationPhase = "waiting";
setTimeout(() => {
animationPhase = "expanding";
}, 100);
}, 1000);
setTimeout(() => {
animationPhase = "shown";
}, 1100);
}, 2000);
} else if (currentPhase !== "question") {
animationPhase = "none";
}
// Final question animation - wait 1s on Kuldvillak, then expand from center
if (
currentPhase === "final-question" &&
prevPhase !== "final-question"
) {
finalAnimPhase = "waiting";
setTimeout(() => {
finalAnimPhase = "expanding";
}, 1000); // Wait 1 second before expanding
setTimeout(() => {
finalAnimPhase = "shown";
}, 2000); // 1s wait + 1s expand
} else if (currentPhase !== "final-question") {
finalAnimPhase = "none";
}
prevPhase = currentPhase ?? null;
});
@@ -137,25 +154,62 @@
boardRevealPhase = "revealing";
revealedPrices = new Set();
// Stagger reveal each price cell (instant opacity, no transition)
const categories = currentRound?.categories ?? [];
const questionsPerCat = categories[0]?.questions.length ?? 5;
let delay = 0;
// Custom reveal order: [ci, qi, order] - order determines when cell appears
// Grid: 6 columns (C1-C6) × 5 rows (R1-R5)
const revealOrder: [number, number, number][] = [
// Row 1 (qi=0): 01 02 15 11 13 08
[0, 0, 1],
[1, 0, 2],
[2, 0, 15],
[3, 0, 11],
[4, 0, 13],
[5, 0, 8],
// Row 2 (qi=1): 25 04 28 24 05 07
[0, 1, 25],
[1, 1, 4],
[2, 1, 28],
[3, 1, 24],
[4, 1, 5],
[5, 1, 7],
// Row 3 (qi=2): 20 16 09 10 18 26
[0, 2, 20],
[1, 2, 16],
[2, 2, 9],
[3, 2, 10],
[4, 2, 18],
[5, 2, 26],
// Row 4 (qi=3): 12 27 06 23 21 30
[0, 3, 12],
[1, 3, 27],
[2, 3, 6],
[3, 3, 23],
[4, 3, 21],
[5, 3, 30],
// Row 5 (qi=4): 19 22 03 14 17 29
[0, 4, 19],
[1, 4, 22],
[2, 4, 3],
[3, 4, 14],
[4, 4, 17],
[5, 4, 29],
];
for (let qi = 0; qi < questionsPerCat; qi++) {
for (let ci = 0; ci < categories.length; ci++) {
const key = `${ci}-${qi}`;
setTimeout(() => {
revealedPrices = new Set([...revealedPrices, key]);
}, delay);
delay += 50; // 50ms between each cell
}
}
// Sort by order and schedule reveals
const sorted = [...revealOrder].sort((a, b) => a[2] - b[2]);
sorted.forEach(([ci, qi, _order], idx) => {
const key = `${ci}-${qi}`;
setTimeout(() => {
revealedPrices = new Set([...revealedPrices, key]);
}, idx * 50); // 50ms between each cell
});
setTimeout(() => {
boardRevealPhase = "revealed";
gameSession.markBoardRevealed(); // Mark as revealed so it won't animate again
}, delay + 100);
setTimeout(
() => {
boardRevealPhase = "revealed";
gameSession.markBoardRevealed(); // Mark as revealed so it won't animate again
},
sorted.length * 50 + 100,
);
} else if (currentPhase === "board" && alreadyRevealed) {
// Already revealed - show all prices immediately
boardRevealPhase = "revealed";
@@ -235,7 +289,7 @@
class="absolute inset-0 bg-kv-black p-8 intro-category {introAnimPhase}"
>
<div
class="w-full h-full flex items-center justify-center category-gradient-bg overflow-hidden p-2"
class="w-full h-full flex items-center justify-center bg-kv-blue overflow-hidden p-2"
>
<div
class="font-kv-body text-kv-white text-[clamp(48px,12vw,192px)] uppercase text-center leading-normal kv-shadow-text"
@@ -266,16 +320,28 @@
?.categories.length ?? 6}, 1fr);"
>
{#each currentRound?.categories ?? [] as cat}
{@const roundName =
{@const logoVariant =
session.currentRoundIndex === 0
? "VILLAK"
: "TOPELTVILLAK"}
? "villak"
: "topeltvillak"}
<div
class="bg-kv-blue font-kv-body text-kv-white text-center uppercase px-2 py-4 lg:px-4 lg:py-6 text-[clamp(12px,2.5vw,48px)] leading-tight flex items-center justify-center kv-shadow-text overflow-hidden"
class="bg-kv-blue font-kv-body text-kv-white text-center uppercase px-2 py-4 lg:px-4 lg:py-6 text-[clamp(12px,2.5vw,48px)] leading-tight flex items-center justify-center kv-shadow-text overflow-hidden relative category-header"
>
{session.boardRevealed
? cat.name || "???"
: roundName}
<div
class="category-content {session.boardRevealed
? 'show-name'
: 'show-logo'}"
>
<div class="category-logo">
<KvGameLogo
variant={logoVariant}
class="h-full w-auto drop-shadow-[6px_6px_4px_rgba(0,0,0,0.5)]"
/>
</div>
<div class="category-name">
{cat.name || "???"}
</div>
</div>
</div>
{/each}
</div>
@@ -316,44 +382,43 @@
</div>
<!-- Question Overlay - Full screen over board -->
{#if session.phase === "question" && questionData && (animationPhase === "expanding" || animationPhase === "shown")}
{#if session.phase === "question" && questionData && animationPhase !== "none"}
{@const pos = startPosition()}
<div
class="absolute inset-0 bg-kv-black p-8 flex flex-col gap-8 expand-overlay {animationPhase}"
class="absolute bg-kv-black flex flex-col expand-overlay {animationPhase}"
style="--start-left: {pos.left}%; --start-top: {pos.top}%; --start-width: {pos.width}%; --start-height: {pos.height}%;"
>
<!-- Players row - top -->
<div
class="grid gap-4 h-32"
class="grid gap-2 lg:gap-4 shrink-0"
style="grid-template-columns: repeat({session.teams
.length}, 1fr);"
>
{#each session.teams as team}
{@const isAnswering =
session.activeTeamId === team.id}
<div
class="bg-kv-blue p-4 flex items-center justify-center overflow-hidden {session.activeTeamId ===
team.id
? 'ring-4 ring-kv-yellow'
class="bg-kv-blue px-2 py-4 lg:px-4 lg:py-6 flex flex-col items-center justify-between overflow-hidden font-kv-body text-kv-white uppercase text-center border-8 border-transparent {isAnswering
? 'team-answering'
: ''}"
>
<div
class="flex flex-col items-center font-kv-body text-kv-white uppercase text-center"
<span
class="text-[clamp(24px,2.5vw,36px)] kv-shadow-text {team.score <
0
? 'text-kv-red'
: ''}">{team.score}</span
>
<span
class="text-[clamp(24px,2.5vw,36px)] kv-shadow-text"
>{team.name}</span
>
<span
class="text-[clamp(24px,2.5vw,36px)] kv-shadow-text"
>{team.name}</span
>
<span
class="text-[clamp(32px,3vw,48px)] kv-shadow-text {team.score <
0
? 'text-kv-red'
: ''}">{team.score}</span
>
</div>
</div>
{/each}
</div>
<!-- Question area - fills remaining space -->
<div
class="flex-1 flex items-center justify-center p-4 overflow-hidden"
class="flex-1 flex items-center justify-center p-4 overflow-hidden bg-kv-blue"
>
{#if questionData.question.imageUrl}
<!-- Image question - show only image -->
@@ -369,14 +434,15 @@
{:else}
<!-- Text question -->
<div
class="font-kv-question text-center text-[clamp(48px,6vw,96px)] leading-normal uppercase"
class="font-kv-question text-center text-[clamp(48px,6vw,96px)] leading-[1] uppercase"
style="transform: scaleX(0.9225);"
>
{#if session.showAnswer}
<div class="text-kv-yellow kv-shadow-text">
{questionData.question.answer}
</div>
{:else}
<div class="text-kv-white">
<div class="text-kv-white kv-shadow-text">
{questionData.question.question}
</div>
{/if}
@@ -428,7 +494,7 @@
class="absolute inset-0 bg-kv-black p-8 intro-category {introAnimPhase}"
>
<div
class="w-full h-full flex items-center justify-center category-gradient-bg overflow-hidden p-2"
class="w-full h-full flex items-center justify-center bg-kv-blue overflow-hidden p-2"
>
<div
class="font-kv-body text-kv-white text-[clamp(48px,12vw,192px)] uppercase text-center leading-normal kv-shadow-text"
@@ -449,158 +515,145 @@
</div>
</div>
{:else if session.phase === "final-question"}
<!-- Final Round Question - Full screen question overlay -->
<div class="flex-1 flex flex-col p-8 gap-8">
<!-- Players row - top -->
<!-- Final Round Question - Kuldvillak background with question overlay -->
<div class="flex-1 flex items-center justify-center intro-grid-bg">
<KvGameLogo
variant="kuldvillak"
class="w-[80vw] max-w-[1536px] h-auto drop-shadow-[6px_6px_4px_rgba(0,0,0,0.5)]"
/>
</div>
<!-- Question overlay on top -->
{#if finalAnimPhase !== "none"}
<div
class="grid gap-4 h-32"
style="grid-template-columns: repeat({session.teams
.length}, 1fr);"
class="absolute bg-kv-black flex flex-col expand-overlay-center {finalAnimPhase}"
>
{#each session.teams as team}
<div
class="bg-kv-blue p-4 flex items-center justify-center overflow-hidden"
>
<!-- Players row - top -->
<div
class="grid gap-2 lg:gap-4 shrink-0"
style="grid-template-columns: repeat({session.teams
.length}, 1fr);"
>
{#each session.teams as team}
{@const isActive = session.activeTeamId === team.id}
<div
class="flex flex-col items-center font-kv-body text-kv-white uppercase text-center"
class="bg-kv-blue px-2 py-4 lg:px-4 lg:py-6 flex flex-col items-center justify-between overflow-hidden font-kv-body text-kv-white uppercase text-center border-8 {isActive
? 'border-kv-yellow'
: 'border-transparent'}"
>
<span
class="text-[clamp(24px,2.5vw,36px)] kv-shadow-text"
>{team.name}</span
>
<span
class="text-[clamp(32px,3vw,48px)] kv-shadow-text {team.score <
class="text-[clamp(24px,2.5vw,36px)] kv-shadow-text {team.score <
0
? 'text-kv-red'
: ''}">{team.score}</span
>
<span
class="text-[clamp(24px,2.5vw,36px)] kv-shadow-text"
>{team.name}</span
>
</div>
</div>
{/each}
</div>
{/each}
</div>
<!-- Question area - fills remaining space -->
<div
class="flex-1 flex items-center justify-center p-4 overflow-hidden"
>
<!-- Question area - fills remaining space -->
<div
class="font-kv-question text-center text-[clamp(48px,6vw,96px)] leading-normal uppercase"
class="flex-1 flex items-center justify-center p-4 overflow-hidden bg-kv-blue"
>
{#if session.showAnswer}
<div class="text-kv-yellow kv-shadow-text">
{session.finalRound?.answer}
</div>
{:else}
<div class="text-kv-white">
{session.finalRound?.question}
</div>
{/if}
</div>
</div>
</div>
{:else if session.phase === "final-scores"}
<!-- Final Scores Display - Before ending game -->
<div
class="flex-1 flex flex-col p-[clamp(4px,0.5vw,8px)] gap-[clamp(8px,1vw,16px)]"
>
<div
class="bg-kv-blue flex-1 flex flex-col items-center justify-center"
>
<div
class="font-kv-body text-kv-yellow text-[clamp(48px,8vw,120px)] uppercase tracking-wide"
style="text-shadow: var(--kv-shadow-title);"
>
{m.kv_play_scores()}
</div>
</div>
<!-- Final Scoreboard -->
<div
class="grid gap-[clamp(4px,0.5vw,8px)]"
style="grid-template-columns: repeat({session.teams
.length}, 1fr);"
>
{#each gameSession.sortedTeams as team, i}
<div
class="bg-kv-blue px-4 py-3 text-center min-h-[clamp(80px,12vh,167px)] flex flex-col items-center justify-center {i ===
0
? 'ring-4 ring-kv-yellow'
: ''}"
class="font-kv-question text-center text-[clamp(48px,6vw,96px)] leading-[1] uppercase"
style="transform: scaleX(0.9225);"
>
<div
class="font-kv-body text-kv-white text-[clamp(14px,2vw,52px)] uppercase leading-tight"
style="text-shadow: var(--kv-shadow-category);"
>
#{i + 1}
{team.name}
</div>
<div
class="font-kv-body text-[clamp(18px,3vw,48px)] {i ===
{#if session.showAnswer}
<div class="text-kv-yellow kv-shadow-text">
{session.finalRound?.answer}
</div>
{:else}
<div class="text-kv-white kv-shadow-text">
{session.finalRound?.question}
</div>
{/if}
</div>
</div>
</div>
{/if}
{:else if session.phase === "final-scores"}
<!-- Final Scores Display - Flexible grid layout -->
{@const sorted = gameSession.sortedTeams}
{@const count = sorted.length}
{@const topRowCount = count <= 3 ? count : 3}
{@const bottomRowCount = count > 3 ? count - 3 : 0}
{@const topRow = sorted.slice(0, topRowCount)}
{@const bottomRow = sorted.slice(topRowCount)}
<div class="flex-1 flex flex-col bg-kv-black p-8 gap-4">
<!-- Top row -->
<div
class="flex-1 grid gap-4"
style="grid-template-columns: repeat({topRowCount}, 1fr);"
>
{#each topRow as team, i}
<div
class="bg-kv-blue flex flex-col items-center justify-center gap-4 p-4"
>
<span
class="font-kv-body text-[clamp(36px,6vw,72px)] uppercase kv-shadow-text {team.score <
0
? 'text-kv-yellow'
? 'text-kv-red'
: 'text-kv-white'}"
style="text-shadow: var(--kv-shadow-price);"
>
{team.score}
</div>
</span>
<span
class="font-kv-body text-[clamp(24px,4vw,48px)] text-kv-white uppercase kv-shadow-text"
>
{team.name}
</span>
<span
class="font-kv-body text-[clamp(24px,4vw,48px)] text-kv-white uppercase kv-shadow-text"
>
#{i + 1}
</span>
</div>
{/each}
</div>
<!-- Bottom row (if more than 3 players) -->
{#if bottomRowCount > 0}
<div
class="flex-1 grid gap-4"
style="grid-template-columns: repeat({bottomRowCount}, 1fr);"
>
{#each bottomRow as team, i}
<div
class="bg-kv-blue flex flex-col items-center justify-center gap-4 p-4"
>
<span
class="font-kv-body text-[clamp(36px,6vw,72px)] uppercase kv-shadow-text {team.score <
0
? 'text-kv-red'
: 'text-kv-white'}"
>
{team.score}
</span>
<span
class="font-kv-body text-[clamp(24px,4vw,48px)] text-kv-white uppercase kv-shadow-text"
>
{team.name}
</span>
<span
class="font-kv-body text-[clamp(24px,4vw,48px)] text-kv-white uppercase kv-shadow-text"
>
#{i + topRowCount + 1}
</span>
</div>
{/each}
</div>
{/if}
</div>
{:else if session.phase === "finished"}
<!-- Game Over / Results - Title font, winner highlighted -->
<div
class="flex-1 flex flex-col p-[clamp(4px,0.5vw,8px)] gap-[clamp(8px,1vw,16px)]"
>
<div
class="bg-kv-blue flex-1 flex flex-col items-center justify-center"
>
<div
class="font-[family-name:var(--kv-font-title)] text-kv-yellow text-[clamp(48px,10vw,160px)] uppercase tracking-wide mb-8"
style="text-shadow: var(--kv-shadow-title);"
>
{m.kv_play_game_over()}!
</div>
<!-- Winner announcement -->
{#if gameSession.sortedTeams[0]}
<div
class="font-kv-body text-kv-white text-[clamp(24px,4vw,64px)] uppercase"
>
🏆 {gameSession.sortedTeams[0].name} 🏆
</div>
{/if}
</div>
<!-- Final Scoreboard -->
<div
class="grid gap-[clamp(4px,0.5vw,8px)]"
style="grid-template-columns: repeat({session.teams
.length}, 1fr);"
>
{#each gameSession.sortedTeams as team, i}
<div
class="bg-kv-blue px-4 py-3 text-center min-h-[clamp(80px,12vh,167px)] flex flex-col items-center justify-center {i ===
0
? 'ring-4 ring-kv-yellow'
: ''}"
>
<div
class="font-kv-body text-kv-white text-[clamp(14px,2vw,52px)] uppercase leading-tight"
style="text-shadow: var(--kv-shadow-category);"
>
#{i + 1}
{team.name}
</div>
<div
class="font-kv-body text-[clamp(18px,3vw,48px)] {i ===
0
? 'text-kv-yellow'
: 'text-kv-white'}"
style="text-shadow: var(--kv-shadow-price);"
>
{team.score}
</div>
</div>
{/each}
</div>
<!-- Game Over - Back to Kuldvillak screen -->
<div class="flex-1 flex items-center justify-center intro-grid-bg">
<KvGameLogo
variant="kuldvillak"
class="w-[80vw] max-w-[1536px] h-auto drop-shadow-[6px_6px_4px_rgba(0,0,0,0.5)]"
/>
</div>
{/if}
</div>
@@ -622,16 +675,6 @@
background-size: 60px 60px;
}
/* Category gradient background - radial vignette effect */
.category-gradient-bg {
background: radial-gradient(
ellipse at center,
var(--kv-blue) 0%,
color-mix(in srgb, var(--kv-blue) 85%, black) 70%,
color-mix(in srgb, var(--kv-blue) 70%, black) 100%
);
}
/* Intro category animation - 500ms dissolve ease-out */
.intro-category {
opacity: 0;
@@ -703,13 +746,195 @@
opacity: 0 !important;
}
/* Question overlay animation - fade in */
/* Question overlay animation - expand from card position */
.expand-overlay {
left: var(--start-left);
top: var(--start-top);
width: var(--start-width);
height: var(--start-height);
transform: translate(-50%, -50%);
transform-origin: center center;
overflow: hidden;
container-type: size;
opacity: 0;
transition: opacity 500ms ease-out;
visibility: hidden;
}
.expand-overlay.expanding {
opacity: 1;
visibility: visible;
transition:
left 1s linear,
top 1s linear,
width 1s linear,
height 1s linear,
transform 1s linear;
}
.expand-overlay.expanding,
.expand-overlay.shown {
left: 50%;
top: 50%;
width: 100%;
height: 100%;
transform: translate(-50%, -50%);
opacity: 1;
visibility: visible;
}
/* Scale all elements proportionally to container */
.expand-overlay {
padding: clamp(4px, 3cqh, 32px);
gap: clamp(4px, 1.5cqh, 16px);
}
.expand-overlay .font-kv-question {
font-size: clamp(12px, 8cqh, 96px);
}
.expand-overlay .font-kv-body {
font-size: clamp(8px, 4cqh, 36px);
}
.expand-overlay .grid {
gap: clamp(2px, 1.5cqh, 16px);
}
.expand-overlay .grid span {
font-size: clamp(8px, 3.5cqh, 36px);
}
.expand-overlay .border-8 {
border-width: clamp(2px, 0.8cqh, 8px);
}
.expand-overlay .bg-kv-blue {
padding: clamp(4px, 1.5cqh, 16px);
}
.expand-overlay .px-2 {
padding-left: clamp(2px, 0.8cqw, 8px);
padding-right: clamp(2px, 0.8cqw, 8px);
}
.expand-overlay .py-4 {
padding-top: clamp(4px, 1.5cqh, 16px);
padding-bottom: clamp(4px, 1.5cqh, 16px);
}
/* Final question overlay animation - expand from center */
.expand-overlay-center {
left: 50%;
top: 50%;
width: 10%;
height: 10%;
transform: translate(-50%, -50%);
transform-origin: center center;
overflow: hidden;
container-type: size;
opacity: 0;
visibility: hidden;
}
.expand-overlay-center.waiting {
opacity: 0;
visibility: hidden;
}
.expand-overlay-center.expanding {
opacity: 1;
visibility: visible;
transition:
width 1s linear,
height 1s linear;
width: 100%;
height: 100%;
}
.expand-overlay-center.shown {
left: 50%;
top: 50%;
width: 100%;
height: 100%;
transform: translate(-50%, -50%);
opacity: 1;
visibility: visible;
}
/* Scale elements for center expand */
.expand-overlay-center {
padding: clamp(4px, 3cqh, 32px);
gap: clamp(4px, 1.5cqh, 16px);
}
.expand-overlay-center .font-kv-question {
font-size: clamp(12px, 8cqh, 96px);
}
.expand-overlay-center .font-kv-body {
font-size: clamp(8px, 4cqh, 36px);
}
.expand-overlay-center .grid {
gap: clamp(2px, 1.5cqh, 16px);
}
.expand-overlay-center .grid span {
font-size: clamp(8px, 3.5cqh, 36px);
}
.expand-overlay-center .border-8 {
border-width: clamp(2px, 0.8cqh, 8px);
}
.expand-overlay-center .bg-kv-blue {
padding: clamp(4px, 1.5cqh, 16px);
}
.expand-overlay-center .px-2 {
padding-left: clamp(2px, 0.8cqw, 8px);
padding-right: clamp(2px, 0.8cqw, 8px);
}
.expand-overlay-center .py-4 {
padding-top: clamp(4px, 1.5cqh, 16px);
padding-bottom: clamp(4px, 1.5cqh, 16px);
}
/* Team answering - flash 3 times then stay white */
.team-answering {
animation:
border-flash 150ms ease-in-out 6,
border-stay 0ms 900ms forwards;
}
@keyframes border-flash {
0%,
100% {
border-color: transparent;
}
50% {
border-color: white;
}
}
@keyframes border-stay {
to {
border-color: white;
}
}
/* Category header transition - smooth crossfade between logo and name */
.category-header {
min-height: 3em;
}
.category-content {
position: relative;
display: grid;
place-items: center;
}
.category-logo,
.category-name {
grid-area: 1 / 1;
display: flex;
align-items: center;
justify-content: center;
transition:
opacity 400ms ease-in-out,
transform 400ms ease-in-out;
}
.category-logo {
height: 1.2em;
}
.category-content.show-logo .category-logo {
opacity: 1;
transform: scale(1);
}
.category-content.show-logo .category-name {
opacity: 0;
transform: scale(0.9);
}
.category-content.show-name .category-logo {
opacity: 0;
transform: scale(1.1);
}
.category-content.show-name .category-name {
opacity: 1;
transform: scale(1);
}
</style>

View File

@@ -9,7 +9,7 @@
--color-kv-blue: var(--kv-blue);
--color-kv-yellow: var(--kv-yellow);
--color-kv-green: #009900;
--color-kv-red: #990000;
--color-kv-red: #FF3333;
--color-kv-black: var(--kv-background);
--color-kv-white: var(--kv-text);
/* Additional theme-aware colors */
@@ -70,8 +70,8 @@
@font-face {
font-family: 'ITC Korinna';
src: url('/fonts/ITC Korinna Std Bold.otf') format('opentype');
font-weight: 700;
src: url('/fonts/ITC Korinna Regular.otf') format('opentype');
font-weight: 400 500;
font-style: normal;
font-display: swap;
}
@@ -87,7 +87,7 @@
--kv-text: #FFFFFF;
--kv-background: #000000;
--kv-green: #009900;
--kv-red: #990000;
--kv-red: #FF3333;
--kv-black: #000000;
--kv-white: #FFFFFF;
@@ -112,6 +112,89 @@
--kv-shadow-category: 6px 6px 4px rgba(0, 0, 0, 0.5);
}
/* ============================================
Kuldvillak Typography Classes
============================================ */
/* Headings - Swiss 921 font, uppercase, with shadow */
.kv-h1 {
font-family: var(--kv-font-body);
font-size: 48px;
line-height: 1.2;
text-transform: uppercase;
text-shadow: var(--kv-shadow-text);
}
.kv-h2 {
font-family: var(--kv-font-body);
font-size: 36px;
line-height: 1.2;
text-transform: uppercase;
text-shadow: var(--kv-shadow-text);
}
.kv-h3 {
font-family: var(--kv-font-body);
font-size: 28px;
line-height: 1.2;
text-transform: uppercase;
text-shadow: var(--kv-shadow-text);
}
.kv-h4 {
font-family: var(--kv-font-body);
font-size: 24px;
line-height: 1.2;
text-transform: uppercase;
text-shadow: var(--kv-shadow-text);
}
.kv-h5 {
font-family: var(--kv-font-body);
font-size: 20px;
line-height: 1.2;
text-transform: uppercase;
text-shadow: var(--kv-shadow-text);
}
.kv-p {
font-family: var(--kv-font-body);
font-size: 16px;
line-height: 1.4;
text-transform: uppercase;
}
/* Title variant - for game logo text */
.kv-title {
font-family: var(--kv-font-title);
text-transform: uppercase;
text-shadow: var(--kv-shadow-title);
}
/* Button text - standardized */
.kv-btn-text {
font-family: var(--kv-font-body);
font-size: 24px;
line-height: 1;
text-transform: uppercase;
}
.kv-btn-text-sm {
font-family: var(--kv-font-body);
font-size: 20px;
line-height: 1;
text-transform: uppercase;
}
.kv-btn-text-lg {
font-family: var(--kv-font-body);
font-size: 28px;
line-height: 1;
text-transform: uppercase;
}
/* Label text - for form labels and small UI text */
.kv-label {
font-family: var(--kv-font-body);
font-size: 20px;
line-height: 1.2;
text-transform: uppercase;
text-shadow: var(--kv-shadow-text);
}
/* ============================================
Global Styles
============================================ */
@@ -126,5 +209,6 @@ body {
body {
font-family: var(--kv-font-button);
font-size: 16px;
color: var(--kv-text);
}