Jeopardy MVP is ready

master
AlacrisDevs 2 weeks ago
parent facb36a07f
commit 0955d6ca65
  1. 23
      .gitignore
  2. 541
      DOCUMENTATION.md
  3. 21
      LICENSE
  4. 73
      README.md
  5. 27
      messages/en.json
  6. 27
      messages/et.json
  7. 821
      src/lib/components/ColorPicker.svelte
  8. 81
      src/lib/components/ConfirmDialog.svelte
  9. 83
      src/lib/components/Settings.svelte
  10. 2
      src/lib/components/index.ts
  11. 2
      src/lib/components/kuldvillak/ui/KvButtonPrimary.svelte
  12. 2
      src/lib/components/kuldvillak/ui/KvButtonSecondary.svelte
  13. 208
      src/lib/components/kuldvillak/ui/KvEditCard.svelte
  14. 70
      src/lib/components/kuldvillak/ui/KvPlayerCard.svelte
  15. 67
      src/lib/components/kuldvillak/ui/KvProjectorCard.svelte
  16. 3
      src/lib/components/kuldvillak/ui/index.ts
  17. 10
      src/lib/index.ts
  18. 37
      src/lib/stores/audio.svelte.ts
  19. 171
      src/lib/stores/gameSession.svelte.ts
  20. 363
      src/lib/stores/kuldvillak.svelte.ts
  21. 26
      src/lib/stores/theme.svelte.ts
  22. 1
      src/lib/types/kuldvillak.ts
  23. 4
      src/routes/+page.svelte
  24. 11
      src/routes/kuldvillak/+page.svelte
  25. 230
      src/routes/kuldvillak/edit/+page.svelte
  26. 4
      src/routes/kuldvillak/play/+page.svelte
  27. 1166
      src/routes/kuldvillak/play/ModeratorView.svelte
  28. 611
      src/routes/kuldvillak/play/ProjectorView.svelte
  29. 92
      src/routes/layout.css
  30. BIN
      static/fonts/ITC Korinna Regular.otf
  31. BIN
      static/fonts/ITC Korinna Std Bold.otf

23
.gitignore vendored

@ -50,11 +50,34 @@ pnpm-debug.log*
*.sublime-project
*.sublime-workspace
*.code-workspace
.vscode/*
!.vscode/settings.json
!.vscode/extensions.json
# Testing
coverage
.nyc_output
# Security - never commit these
*.pem
*.key
*.cert
*.p12
secrets.*
# Database files
*.db
*.sqlite
*.sqlite3
# AI/Editor state
.windsurfrules
.cursor
.aider*
.continue
# Misc
*.local
.history
*.bak
*.tmp

@ -0,0 +1,541 @@
# Kuldvillak/Jeopardy Game Platform - Technical Documentation
## Table of Contents
- [Overview](#overview)
- [Tech Stack](#tech-stack)
- [Architecture](#architecture)
- [Directory Structure](#directory-structure)
- [Game Flow](#game-flow)
- [State Management](#state-management)
- [Components](#components)
- [Data Types](#data-types)
- [Features](#features)
- [Multi-Window Sync](#multi-window-sync)
---
## Overview
**Ultimate Gaming** is a web-based platform for hosting trivia game shows, currently featuring **Kuldvillak** - a Jeopardy-style quiz game. The platform supports:
- Full game editor with customizable settings
- Dual-screen setup (Moderator + Projector views)
- Real-time cross-tab synchronization
- Theme customization
- Internationalization (Estonian/English)
- Audio management
- Persistent game storage
---
## Tech Stack
| Technology | Version | Purpose |
|------------|---------|---------|
| **SvelteKit** | 2.48.5 | Full-stack framework |
| **Svelte 5** | 5.43.8 | UI with Runes reactivity |
| **TailwindCSS** | 4.1.17 | Utility-first styling |
| **TypeScript** | 5.9.3 | Type safety |
| **Paraglide** | 2.5.0 | i18n (internationalization) |
| **Vite** | 7.2.2 | Build tool |
---
## Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ Ultimate Gaming │
├─────────────────────────────────────────────────────────────┤
│ Routes │
│ ├── / (Home - Game Selection) │
│ ├── /kuldvillak (Game Menu) │
│ ├── /kuldvillak/edit (Game Editor) │
│ └── /kuldvillak/play (Game Play) │
│ ├── ModeratorView (Controls) │
│ └── ProjectorView (Display) │
├─────────────────────────────────────────────────────────────┤
│ State Management (Svelte 5 Runes) │
│ ├── gameSession.svelte.ts (Live game state + BroadcastAPI) │
│ ├── kuldvillak.svelte.ts (Game store - editor) │
│ ├── theme.svelte.ts (Color theming) │
│ ├── audio.svelte.ts (Music/SFX volumes) │
│ └── persistence.ts (localStorage utilities) │
├─────────────────────────────────────────────────────────────┤
│ Components │
│ ├── Shared (Settings, ColorPicker, Toast, etc.) │
│ └── Kuldvillak UI (Buttons, Cards, Logo, etc.) │
└─────────────────────────────────────────────────────────────┘
```
---
## Directory Structure
```
src/
├── lib/
│ ├── assets/ # Static assets (favicon)
│ ├── components/
│ │ ├── kuldvillak/
│ │ │ └── ui/ # Game-specific UI components
│ │ │ ├── KvButtonPrimary.svelte
│ │ │ ├── KvButtonSecondary.svelte
│ │ │ ├── KvEditCard.svelte
│ │ │ ├── KvGameLogo.svelte
│ │ │ ├── KvNumberInput.svelte
│ │ │ ├── KvPlayerCard.svelte
│ │ │ └── KvProjectorCard.svelte
│ │ ├── ColorPicker.svelte
│ │ ├── ConfirmDialog.svelte
│ │ ├── LanguageSwitcher.svelte
│ │ ├── Settings.svelte
│ │ ├── Slider.svelte
│ │ └── Toast.svelte
│ ├── paraglide/ # Generated i18n code
│ ├── stores/
│ │ ├── audio.svelte.ts
│ │ ├── gameSession.svelte.ts
│ │ ├── kuldvillak.svelte.ts
│ │ ├── persistence.ts
│ │ └── theme.svelte.ts
│ ├── types/
│ │ └── kuldvillak.ts # TypeScript interfaces
│ └── index.ts # Library exports
├── routes/
│ ├── kuldvillak/
│ │ ├── edit/
│ │ │ └── +page.svelte # Game editor
│ │ ├── play/
│ │ │ ├── +page.svelte # Route handler
│ │ │ ├── ModeratorView.svelte
│ │ │ └── ProjectorView.svelte
│ │ ├── +layout.svelte
│ │ └── +page.svelte # Game menu
│ ├── +error.svelte
│ ├── +layout.svelte # Root layout
│ ├── +page.svelte # Home page
│ └── layout.css # Global styles + Tailwind
├── app.html
├── app.d.ts
├── hooks.server.ts
└── hooks.ts
```
---
## Game Flow
### 1. Game Creation (Editor)
```
/kuldvillak/edit
├── Configure Settings
│ ├── Number of rounds (1-2)
│ ├── Timer durations
│ ├── Point values (preset or custom)
│ ├── Daily doubles per round
│ └── Enable/disable final round
├── Add Teams (2-6 players)
├── Fill Categories & Questions
│ ├── 6 categories per round
│ ├── 5 questions per category
│ └── Mark daily doubles
├── Final Round Question (optional)
└── Start Game → Opens Moderator + Projector views
```
### 2. Game Phases
```
┌─────────────┐
│ intro │ ← Kuldvillak logo screen
└──────┬──────┘
┌──────▼──────────────┐
│ intro-categories │ ← Category reveal animation
└──────┬──────────────┘
┌──────▼──────┐
│ board │ ← Question selection grid
└──────┬──────┘
┌──────▼──────────────┐
│ daily-double (if) │ ← Wager selection
└──────┬──────────────┘
┌──────▼──────┐
│ question │ ← Display question, teams answer
└──────┬──────┘
├── Correct → Award points
├── Wrong → Deduct points, next team
└── Skip → Reveal answer
└──────► Back to board
│ (After all questions)
┌──────▼────────────────┐
│ final-intro │ ← Final round logo
├───────────────────────┤
│ final-category │ ← Reveal final category
├───────────────────────┤
│ final-question │ ← Teams write answers + wagers
├───────────────────────┤
│ final-scores │ ← Final standings
└───────────────────────┘
┌──────▼──────┐
│ finished │
└─────────────┘
```
### 3. Question Flow
```
Question Selected
├── Is Daily Double?
│ │
│ ├── YES → Select team → Enter wager → Show question
│ └── NO → Show question directly
Display Question
├── Timer starts
├── Team clicks to answer
│ │
│ ├── Timer pauses
│ │
│ ├── Moderator marks Correct/Wrong
│ │ │
│ │ ├── Correct → +points → Show answer → Return to board
│ │ └── Wrong → -points → Continue (others can try)
│ │
│ └── All teams wrong → Auto-reveal answer
└── Timer expires → Auto-reveal answer
```
---
## State Management
### GameSessionStore (`gameSession.svelte.ts`)
The heart of the game - manages live game state with cross-tab synchronization.
**Key Features:**
- Uses `BroadcastChannel` API for real-time sync between Moderator and Projector
- Persists to `localStorage` for session recovery
- Single internal timer managed by moderator view
**State Interface:**
```typescript
interface GameSessionState {
// Game data
name: string;
settings: GameSettings;
teams: Team[];
rounds: Round[];
finalRound: FinalRound | null;
// Game phase
phase: GamePhase;
currentRoundIndex: number;
activeTeamId: string | null;
// Animation state
introCategoryIndex: number;
categoriesIntroduced: boolean;
boardRevealed: boolean;
// Question state
currentQuestion: { roundIndex, categoryIndex, questionIndex } | null;
showAnswer: boolean;
wrongTeamIds: string[];
lastAnsweredTeamId: string | null;
lastAnswerCorrect: boolean | null;
// Daily Double
dailyDoubleWager: number | null;
// Final Round
finalCategoryRevealed: boolean;
finalWagers: Record<string, number>;
finalAnswers: Record<string, string>;
finalRevealed: string[];
// Timer
timerRunning: boolean;
timerSeconds: number;
timerMax: number;
timeoutCountdown: number | null;
revealCountdown: number | null;
skippingQuestion: boolean;
// Tracking
questionsAnswered: number;
currentQuestionNumber: number;
questionResults: QuestionResult[];
}
```
### ThemeStore (`theme.svelte.ts`)
Manages color theming with cross-window sync.
**CSS Variables controlled:**
- `--kv-blue` (primary)
- `--kv-yellow` (secondary)
- `--kv-text`
- `--kv-background`
### AudioStore (`audio.svelte.ts`)
Manages background music and sound effects volumes.
---
## Components
### Shared Components
| Component | Purpose |
|-----------|---------|
| `Settings.svelte` | Modal with audio, language, and color settings |
| `ColorPicker.svelte` | Full HSL color picker with swatches |
| `ConfirmDialog.svelte` | Confirmation modal |
| `Toast.svelte` | Notification messages |
| `Slider.svelte` | Volume control slider |
| `LanguageSwitcher.svelte` | ET/EN language toggle |
### Kuldvillak UI Components
| Component | Purpose |
|-----------|---------|
| `KvButtonPrimary.svelte` | Yellow primary action button |
| `KvButtonSecondary.svelte` | Blue secondary action button |
| `KvEditCard.svelte` | Team card for moderator (score adjust, correct/wrong) |
| `KvPlayerCard.svelte` | Basic player display card |
| `KvProjectorCard.svelte` | Player card for projector view |
| `KvNumberInput.svelte` | Styled number input with +/- buttons |
| `KvGameLogo.svelte` | SVG logo component with variants |
---
## Data Types
### Core Types (`types/kuldvillak.ts`)
```typescript
interface Question {
id: string;
question: string;
answer: string;
points: number;
isDailyDouble: boolean;
isRevealed: boolean;
imageUrl?: string;
}
interface Category {
id: string;
name: string;
questions: Question[];
}
interface Round {
id: string;
name: string;
categories: Category[];
pointMultiplier: number;
}
interface Team {
id: string;
name: string;
score: number;
}
interface GameSettings {
numberOfRounds: 1 | 2;
pointValuePreset: 'round1' | 'round2' | 'custom' | 'multiplier';
pointValues: number[];
basePointValue: number;
categoriesPerRound: number; // Default: 6
questionsPerCategory: number; // Default: 5
dailyDoublesPerRound: number[];
enableFinalRound: boolean;
enableSoundEffects: boolean;
allowNegativeScores: boolean;
maxTeams: number; // Max: 6
defaultTimerSeconds: number;
answerRevealSeconds: number;
}
type GamePhase =
| 'intro' // Logo screen
| 'intro-categories' // Category reveal
| 'lobby'
| 'board' // Question grid
| 'question'
| 'answer'
| 'daily-double'
| 'final-intro'
| 'final-category'
| 'final-wagers'
| 'final-question'
| 'final-reveal'
| 'final-scores'
| 'finished';
```
---
## Features
### 1. Game Editor
- Auto-save to localStorage
- Import/Export JSON game files
- Visual daily double indicators
- Point value presets or custom
- Final round configuration
### 2. Moderator View
- Full game control
- Score adjustments
- Timer control (start/stop/reset)
- Question skip functionality
- Open projector window button
### 3. Projector View
- Full-screen presentation
- Animated category introductions
- Staggered board reveal animation
- Question expand animation
- Team score display during questions
- Final scores display
### 4. Animations (ProjectorView)
- **Category Intro**: 500ms fade-in → 1500ms shown → 500ms push-out
- **Board Reveal**: Staggered 50ms per cell
- **Question Expand**: From grid cell position to fullscreen
### 5. Cross-Tab Sync
- Real-time state sync via BroadcastChannel
- localStorage persistence for recovery
- Single timer owner (moderator)
---
## Multi-Window Sync
```
┌─────────────────┐ BroadcastChannel ┌─────────────────┐
│ Moderator Tab │ ◄─────────────────────────────► │ Projector Tab │
│ │ │ │
│ - Controls │ STATE_UPDATE │ - Display │
│ - Timer owner │ ─────────────────────────────────►│ - Animations │
│ - Score adjust │ │ - Read-only │
└────────┬────────┘ └─────────────────┘
│ localStorage
┌─────────────────┐
│ Session Store │
│ (persistence) │
└─────────────────┘
```
---
## Styling
### Design Tokens (CSS Variables)
```css
:root {
/* Colors */
--kv-blue: #003B9B; /* Primary */
--kv-yellow: #FFAB00; /* Secondary/Accent */
--kv-text: #FFFFFF;
--kv-background: #000000;
--kv-green: #009900; /* Correct */
--kv-red: #FF3333; /* Wrong */
/* Fonts */
--kv-font-title: 'Swiss 921';
--kv-font-body: 'Swiss 921';
--kv-font-price: 'Bebas Neue';
--kv-font-question: 'ITC Korinna';
/* Shadows */
--kv-shadow-text: 6px 6px 4px rgba(0,0,0,0.5);
--kv-shadow-price: 8px 8px 4px rgba(0,0,0,0.5);
--kv-shadow-button: 0 4px 4px rgba(0,0,0,0.25), 8px 8px 4px rgba(0,0,0,0.5);
}
```
### Tailwind Theme Extensions
Custom Tailwind classes prefixed with `kv-`:
- Colors: `bg-kv-blue`, `text-kv-yellow`, etc.
- Fonts: `font-kv-body`, `font-kv-price`, etc.
---
## i18n (Internationalization)
Using **Paraglide** for type-safe translations.
**Supported Languages:**
- Estonian (et) - Primary
- English (en)
**Message Files:**
- `messages/et.json`
- `messages/en.json`
**Usage:**
```svelte
<script>
import * as m from "$lib/paraglide/messages";
</script>
{m.kv_play_daily_double()}
```
---
## Development
```bash
# Install dependencies
npm install
# Start dev server
npm run dev
# Build for production
npm run build
# Type check
npm run check
```
---
## Future Considerations
1. **Database Backend**: Replace localStorage with proper database for persistence
2. **WebSocket**: Real-time sync without BroadcastChannel limitations
3. **Sound Effects**: Implement actual SFX playback
4. **More Game Modes**: "Rooside Sõda" (Family Feud) placeholder exists
5. **User Accounts**: Save games to cloud
6. **Mobile Buzzer**: Player buzzer app for team selection

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

@ -1,38 +1,73 @@
# sv
# Ultimate Gaming 🎮
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
A web-based platform for hosting trivia game shows, featuring **Kuldvillak** (Estonian Jeopardy).
## Creating a project
## Features
If you're seeing this, you've probably already done this step. Congrats!
- ✨ **Game Editor** - Create custom games with categories, questions, and daily doubles
- 🖥 **Dual-Screen Setup** - Moderator controls + Projector display
- 🔄 **Real-time Sync** - Cross-tab synchronization via BroadcastChannel API
- 🎨 **Theme Customization** - Customize colors to match your brand
- 🌍 **Internationalization** - Estonian and English support
- 💾 **Persistent Storage** - Auto-save and load games from localStorage
```sh
# create a new project in the current directory
npx sv create
## Tech Stack
# create a new project in my-app
npx sv create my-app
```
- **SvelteKit 2** + **Svelte 5** (Runes)
- **TailwindCSS 4** for styling
- **TypeScript** for type safety
- **Paraglide** for i18n
## Developing
## Getting Started
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```bash
# Install dependencies
npm install
```sh
# Start development server
npm run dev
# or start the server and open the app in a new browser tab
# Open in browser
npm run dev -- --open
```
## Building
## Usage
1. **Create a Game**: Go to `/kuldvillak/edit` to create a new game
2. **Add Teams**: Configure 2-6 players/teams
3. **Fill Content**: Add categories, questions, and answers
4. **Start Playing**: Click "Start" to launch the game
5. **Open Projector**: Use "Open Projector" button for display screen
## Project Structure
To create a production version of your app:
```
src/
├── lib/
│ ├── components/ # Reusable UI components
│ ├── stores/ # Svelte stores (gameSession, theme, audio)
│ └── types/ # TypeScript interfaces
├── routes/
│ ├── kuldvillak/
│ │ ├── edit/ # Game editor
│ │ └── play/ # Game play views
│ └── +page.svelte # Home page
└── app.html
```
```sh
## Documentation
See [DOCUMENTATION.md](./DOCUMENTATION.md) for detailed technical documentation.
## Building for Production
```bash
npm run build
npm run preview
```
You can preview the production build with `npm run preview`.
## License
MIT License - Feel free to use, modify, and distribute.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
*This is a fan-made project inspired by Jeopardy! and its Estonian counterpart Kuldvillak. Not affiliated with or endorsed by the original shows.*

@ -18,6 +18,7 @@
"kv_error_404": "404",
"kv_error_not_found": "Not Found",
"kv_error_hint": "(pssst... make sure you're on the right page)",
"kv_edit_title": "Game Editor",
"kv_edit_back": "Back",
"kv_edit_game_name": "Game name...",
"kv_edit_save": "Save",
@ -141,5 +142,29 @@
"kv_settings_save_exit": "Save and Exit",
"kv_edit_image_link": "Image Link",
"kv_edit_save_exit": "Save and Exit",
"kv_edit_final_enabled": "Final Round Enabled"
"kv_edit_final_enabled": "Final Round Enabled",
"kv_play_adjust_score": "Adjust score",
"kv_play_click_team_to_answer": "Click a team to select answerer",
"kv_play_final_scores": "Final Scores",
"kv_edit_dd_short": "DD",
"kv_play_timeout_reveal": "Timer ran out, nobody can answer. Revealing answer in {seconds} seconds...",
"kv_play_answer_revealed": "Answer revealed. Returning to board in {seconds} seconds...",
"kv_play_timer_paused": "Timer paused. {name} is answering.",
"kv_play_correct_return": "{name} answered correctly! Returning to board in {seconds} seconds...",
"kv_play_wrong_waiting": "{name} answered incorrectly. Waiting for next player...",
"kv_play_wrong_reveal": "{name} answered incorrectly. Revealing answer in {seconds} seconds...",
"kv_play_skip_reveal": "Skipping question entirely. Revealing answer in {seconds} seconds...",
"kv_play_click_team_to_judge": "Click a team to judge their answer",
"kv_play_judging": "Judging",
"kv_play_enter_wager": "Enter wager",
"kv_play_judged": "Judged",
"kv_play_finish": "Finish",
"kv_color_picker": "Color Picker",
"kv_done": "Done",
"kv_opacity": "Opacity",
"kv_confirm_close_title": "Discard Changes?",
"kv_confirm_close_message": "Are you sure you want to close? Any unsaved changes will be lost.",
"kv_confirm_discard": "Discard",
"kv_confirm_cancel": "Cancel",
"kv_final_round": "Final Round"
}

@ -18,6 +18,7 @@
"kv_error_404": "404",
"kv_error_not_found": "Lehte ei leitud",
"kv_error_hint": "(pssst... vaata, et oled ikka õigel lehel)",
"kv_edit_title": "Mängu redaktor",
"kv_edit_back": "Tagasi",
"kv_edit_game_name": "Mängu nimi...",
"kv_edit_save": "Salvesta",
@ -141,5 +142,29 @@
"kv_settings_save_exit": "Salvesta ja välju",
"kv_edit_image_link": "Pildi link",
"kv_edit_save_exit": "Salvesta ja Välju",
"kv_edit_final_enabled": "Finaalvoor lubatud"
"kv_edit_final_enabled": "Finaalvoor lubatud",
"kv_play_adjust_score": "Muuda skoori",
"kv_play_click_team_to_answer": "Kliki meeskonnal vastajaks",
"kv_play_final_scores": "Lõpptulemused",
"kv_edit_dd_short": "HV",
"kv_play_timeout_reveal": "Aeg sai läbi, keegi ei saa vastata. Vastuse näitamine {seconds} sekundi pärast...",
"kv_play_answer_revealed": "Vastus näidatud. Tagasi mängulaudale {seconds} sekundi pärast...",
"kv_play_timer_paused": "Taimer peatatud. {name} vastab.",
"kv_play_correct_return": "{name} vastas õigesti! Tagasi mängulaudale {seconds} sekundi pärast...",
"kv_play_wrong_waiting": "{name} vastas valesti. Ootame järgmist mängijat...",
"kv_play_wrong_reveal": "{name} vastas valesti. Vastuse näitamine {seconds} sekundi pärast...",
"kv_play_skip_reveal": "Küsimus vahele jäetud. Vastuse näitamine {seconds} sekundi pärast...",
"kv_play_click_team_to_judge": "Kliki meeskonnal vastuse hindamiseks",
"kv_play_judging": "Hindamine",
"kv_play_enter_wager": "Sisesta panus",
"kv_play_judged": "Hinnatud",
"kv_play_finish": "Lõpeta",
"kv_color_picker": "Värvivalija",
"kv_done": "Valmis",
"kv_opacity": "Läbipaistvus",
"kv_confirm_close_title": "Loobuda muudatustest?",
"kv_confirm_close_message": "Kas oled kindel, et soovid sulgeda? Salvestamata muudatused lähevad kaotsi.",
"kv_confirm_discard": "Loobu",
"kv_confirm_cancel": "Tühista",
"kv_final_round": "Kuldvillak"
}

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

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

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

@ -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';

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

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

@ -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>

@ -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>

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

@ -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';

@ -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';

@ -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;
}
}

@ -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();
}
// 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();
}
revealFinalAnswer(teamId: string, correct: boolean) {
// 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();
}

@ -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();

@ -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) {

@ -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'

@ -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]">

@ -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"

@ -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}
/>

@ -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

@ -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;
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
}
}
setTimeout(() => {
boardRevealPhase = "revealed";
gameSession.markBoardRevealed(); // Mark as revealed so it won't animate again
}, delay + 100);
// 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],
];
// 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
},
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
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">
{session.finalRound?.answer}
</div>
{:else}
<div class="text-kv-white kv-shadow-text">
{session.finalRound?.question}
</div>
{/if}
</div>
</div>
</div>
</div>
{/if}
{: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)]"
>
<!-- 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="bg-kv-blue flex-1 flex flex-col items-center justify-center"
class="flex-1 grid gap-4"
style="grid-template-columns: repeat({topRowCount}, 1fr);"
>
<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}
{#each topRow 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="bg-kv-blue flex flex-col items-center justify-center gap-4 p-4"
>
<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 ===
<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>
</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"
>
<!-- Bottom row (if more than 3 players) -->
{#if bottomRowCount > 0}
<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);"
class="flex-1 grid gap-4"
style="grid-template-columns: repeat({bottomRowCount}, 1fr);"
>
{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>
{#each bottomRow as team, i}
<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);"
class="bg-kv-blue flex flex-col items-center justify-center gap-4 p-4"
>
{team.score}
<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>
</div>
{/each}
</div>
{/each}
</div>
{/if}
</div>
{:else if session.phase === "finished"}
<!-- 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>

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

Loading…
Cancel
Save