Compare commits

...

5 Commits

  1. 30
      LICENSE
  2. 15
      README.md
  3. 57
      messages/en.json
  4. 93
      messages/et.json
  5. 14
      package-lock.json
  6. 9
      src/lib/components/ColorPicker.svelte
  7. 21
      src/lib/components/ConfirmDialog.svelte
  8. 84
      src/lib/components/Settings.svelte
  9. 28
      src/lib/components/ToastContainer.svelte
  10. 153
      src/lib/components/editor/EditorHeader.svelte
  11. 284
      src/lib/components/editor/QuestionEditModal.svelte
  12. 118
      src/lib/components/editor/RoundEditor.svelte
  13. 265
      src/lib/components/editor/SettingsPanel.svelte
  14. 53
      src/lib/components/editor/TeamEditor.svelte
  15. 6
      src/lib/components/editor/index.ts
  16. 4
      src/lib/components/index.ts
  17. 101
      src/lib/components/kuldvillak/ui/KvButton.svelte
  18. 48
      src/lib/components/kuldvillak/ui/KvButtonPrimary.svelte
  19. 48
      src/lib/components/kuldvillak/ui/KvButtonSecondary.svelte
  20. 38
      src/lib/components/kuldvillak/ui/KvCheckbox.svelte
  21. 96
      src/lib/components/kuldvillak/ui/KvEditCard.svelte
  22. 2
      src/lib/components/kuldvillak/ui/TutorialModal.svelte
  23. 4
      src/lib/components/kuldvillak/ui/index.ts
  24. 6
      src/lib/index.ts
  25. 8
      src/lib/services/index.ts
  26. 116
      src/lib/services/localStorage.ts
  27. 56
      src/lib/services/storage.ts
  28. 448
      src/lib/stores/editor.svelte.ts
  29. 591
      src/lib/stores/editor.test.ts
  30. 62
      src/lib/stores/gameSession.svelte.ts
  31. 26
      src/lib/stores/gameSession.test.ts
  32. 11
      src/lib/stores/persistence.test.ts
  33. 50
      src/lib/stores/persistence.ts
  34. 127
      src/lib/stores/theme.svelte.ts
  35. 178
      src/lib/stores/theme.test.ts
  36. 60
      src/lib/stores/toast.svelte.ts
  37. 123
      src/lib/types/buzzer.ts
  38. 104
      src/lib/utils/color.ts
  39. 47
      src/lib/utils/focusTrap.ts
  40. 155
      src/lib/utils/validation.ts
  41. 4
      src/routes/+layout.svelte
  42. 24
      src/routes/kuldvillak/+page.svelte
  43. 1131
      src/routes/kuldvillak/edit/+page.svelte
  44. 1146
      src/routes/kuldvillak/edit/+page.svelte.backup
  45. 0
      src/routes/kuldvillak/edit/+page.svelte.new
  46. 13
      src/routes/kuldvillak/play/+page.svelte
  47. 328
      src/routes/kuldvillak/play/ModeratorView.svelte
  48. 2
      src/routes/kuldvillak/play/ProjectorView.svelte
  49. 133
      src/routes/layout.css
  50. 5
      src/test/mocks/app-environment.ts
  51. 4
      vitest.config.ts

@ -1,21 +1,17 @@
MIT License Copyright (c) 2025 Sass
Copyright (c) 2024 PERSONAL USE ONLY
Permission is hereby granted, free of charge, to any person obtaining a copy This source code is provided for personal, non-commercial use only.
of this software and associated documentation files (the "Software"), to deal You may use this code to run the game locally and provide feedback.
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 You may NOT:
copies or substantial portions of the Software. - Redistribute or publish this code
- Create derivative works
- Use this commercially
- Remove or modify this license
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR This license does NOT cover any game assets, sounds, graphics, or other
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, media files, which remain property of their respective copyright holders.
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
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,6 +1,6 @@
# Ultimate Gaming 🎮 # Ultimate Gaming 🎮
A web-based platform for hosting trivia game shows, featuring **Kuldvillak** (Estonian Jeopardy). A web-based platform for hosting trivia game shows, featuring **Kuldvillak** (Estonian Jeopardy) and **Rooside Sõda** (Estonian Family Feud, coming soon).
## Features ## Features
@ -26,9 +26,6 @@ npm install
# Start development server # Start development server
npm run dev npm run dev
# Open in browser
npm run dev -- --open
``` ```
## Usage ## Usage
@ -38,6 +35,7 @@ npm run dev -- --open
3. **Fill Content**: Add categories, questions, and answers 3. **Fill Content**: Add categories, questions, and answers
4. **Start Playing**: Click "Start" to launch the game 4. **Start Playing**: Click "Start" to launch the game
5. **Open Projector**: Use "Open Projector" button for display screen 5. **Open Projector**: Use "Open Projector" button for display screen
6. **Extend your Display**: Keep the moderator view on your screen, move the projector view on the projector and you're good to go!
## Project Structure ## Project Structure
@ -66,8 +64,9 @@ npm run build
npm run preview npm run preview
``` ```
## License **DISCLAIMER**: This is an unofficial fan project for personal entertainment only.
All Jeopardy-related assets, sounds, and graphics are property of Kanal2 and the original Jeopardy copyright holders. This project is not endorsed by or affiliated with the official show.
MIT License - Feel free to use, modify, and distribute.
*This is a fan-made project inspired by Jeopardy! and its Estonian counterpart Kuldvillak. Not affiliated with or endorsed by the original shows.* ## Usage
This game is shared for friends to play locally and provide feedback only.
Please respect the limitations in the LICENSE file.

@ -1,7 +1,7 @@
{ {
"$schema": "https://inlang.com/schema/inlang-message-format", "$schema": "https://inlang.com/schema/inlang-message-format",
"app_title": "Ultimate Gaming", "app_title": "Ultimate Gaming",
"app_version": "v0.1.0", "app_version": "v0.1.1",
"coming_soon": "Coming Soon", "coming_soon": "Coming Soon",
"game_kuldvillak": "Jeopardy", "game_kuldvillak": "Jeopardy",
"game_rooside_soda": "Family Feud", "game_rooside_soda": "Family Feud",
@ -30,27 +30,16 @@
"kv_play_timer": "Time to Answer", "kv_play_timer": "Time to Answer",
"kv_play_timer_reveal": "Answer Reveal", "kv_play_timer_reveal": "Answer Reveal",
"kv_play_seconds": "seconds", "kv_play_seconds": "seconds",
"kv_edit_final_round_toggle": "Final Round",
"kv_edit_final_question": "Final Question",
"kv_edit_edit": "Edit",
"kv_edit_teams": "Points & Teams",
"kv_edit_points": "Points", "kv_edit_points": "Points",
"kv_edit_preset_normal": "Normal", "kv_edit_preset_normal": "Normal",
"kv_edit_preset_double": "Double",
"kv_edit_x_base": "Multiplier",
"kv_edit_custom": "Custom", "kv_edit_custom": "Custom",
"kv_edit_base": "Base",
"kv_edit_negative_scores": "Negative Scores", "kv_edit_negative_scores": "Negative Scores",
"kv_edit_daily_doubles": "Daily Doubles",
"kv_edit_r1": "Jeopardy", "kv_edit_r1": "Jeopardy",
"kv_edit_r2": "Double Jeopardy", "kv_edit_r2": "Double Jeopardy",
"kv_edit_teams_label": "Players", "kv_edit_teams_label": "Players",
"kv_edit_round_1": "Jeopardy",
"kv_edit_round_2": "Double Jeopardy",
"kv_edit_dd_count": "Daily Double", "kv_edit_dd_count": "Daily Double",
"kv_edit_category": "Category", "kv_edit_category": "Category",
"kv_edit_dd": "★", "kv_edit_dd": "★",
"kv_edit_no_category": "(No Category Yet)",
"kv_edit_question": "Question", "kv_edit_question": "Question",
"kv_edit_answer": "Answer", "kv_edit_answer": "Answer",
"kv_edit_daily_double": "Daily Double", "kv_edit_daily_double": "Daily Double",
@ -62,40 +51,17 @@
"kv_toast_game_saved": "Game saved!", "kv_toast_game_saved": "Game saved!",
"kv_toast_game_loaded": "Game loaded!", "kv_toast_game_loaded": "Game loaded!",
"kv_toast_invalid_file": "Invalid game file", "kv_toast_invalid_file": "Invalid game file",
"kv_edit_reset_confirm": "Are you sure you want to reset all fields to default? This will clear all your work.", "kv_edit_edit_round": "Edit Round",
"kv_edit_reset_confirm_title": "Reset Game?",
"kv_edit_reset_confirm_message": "Are you sure you want to reset all fields to default? This will clear all your work.",
"kv_edit_reset_success": "Game reset to defaults", "kv_edit_reset_success": "Game reset to defaults",
"kv_edit_categories_questions": "Categories & Questions",
"kv_edit_daily_doubles_count": "Daily Doubles",
"kv_edit_category_placeholder": "Category name",
"kv_edit_empty": "Empty",
"kv_edit_question_edit": "Edit Question",
"kv_edit_question_placeholder": "Enter the question...",
"kv_edit_answer_placeholder": "Enter the answer...",
"kv_edit_final_round": "Final Round", "kv_edit_final_round": "Final Round",
"kv_edit_final_disabled": "Final Round is disabled in settings.",
"kv_edit_category_name_placeholder": "Enter category name...",
"kv_edit_final_question_placeholder": "Enter the final question...",
"kv_edit_game_settings": "Game Settings",
"kv_edit_num_rounds": "Number of Rounds",
"kv_edit_1_round": "1 Round",
"kv_edit_2_rounds": "2 Rounds",
"kv_edit_point_values": "Point Values",
"kv_edit_standard": "Standard (100-500)",
"kv_edit_double": "Double (200-1000)",
"kv_edit_multiplier": "Multiplier",
"kv_edit_base_value": "Base Value",
"kv_edit_default_timer": "Default Timer (seconds)",
"kv_edit_enable_final": "Enable Final Round",
"kv_edit_allow_negative": "Allow Negative Scores",
"kv_edit_teams_title": "Teams",
"kv_edit_team_name_placeholder": "Team name...",
"kv_edit_add_team": "Add Team", "kv_edit_add_team": "Add Team",
"kv_edit_remove_team": "Remove", "kv_edit_remove_team": "Remove",
"kv_play_loading": "Loading game...", "kv_play_loading": "Loading game...",
"kv_play_loading_hint": "If this takes too long, the game may not have been started.", "kv_play_loading_hint": "If this takes too long, the game may not have been started.",
"kv_play_go_to_editor": "Go to Editor", "kv_play_go_to_editor": "Go to Editor",
"kv_play_round": "Round", "kv_play_round": "Round",
"kv_play_phase": "Phase",
"kv_play_last_answer": "Last", "kv_play_last_answer": "Last",
"kv_play_introduce_categories": "Introduce Categories", "kv_play_introduce_categories": "Introduce Categories",
"kv_play_skip_to_game": "Skip to Game", "kv_play_skip_to_game": "Skip to Game",
@ -104,11 +70,8 @@
"kv_play_daily_double": "Daily Double", "kv_play_daily_double": "Daily Double",
"kv_play_wager": "Wager", "kv_play_wager": "Wager",
"kv_play_confirm": "Confirm", "kv_play_confirm": "Confirm",
"kv_play_question_number": "Question {current}/{total}",
"kv_play_showing_answer": "Showing Answer...",
"kv_play_question_short": "Q", "kv_play_question_short": "Q",
"kv_play_answer_short": "A", "kv_play_answer_short": "A",
"kv_play_answering": "Answering",
"kv_play_correct": "Correct", "kv_play_correct": "Correct",
"kv_play_wrong": "Wrong", "kv_play_wrong": "Wrong",
"kv_play_skip": "Skip / No Answer", "kv_play_skip": "Skip / No Answer",
@ -119,23 +82,21 @@
"kv_play_reveal_category": "Reveal Category", "kv_play_reveal_category": "Reveal Category",
"kv_play_reveal_answer": "Reveal Answer", "kv_play_reveal_answer": "Reveal Answer",
"kv_play_show_scores": "Show Final Scores", "kv_play_show_scores": "Show Final Scores",
"kv_play_scores": "Scores",
"kv_play_adjust_by": "Adjust by",
"kv_play_game_controls": "Game Controls",
"kv_play_next_round": "Next Round", "kv_play_next_round": "Next Round",
"kv_play_go_to_final": "Go to Final Round", "kv_play_go_to_final": "Go to Final Round",
"kv_play_end_game": "End Game", "kv_play_end_game": "End Game",
"kv_play_end_game_confirm": "End the game?", "kv_play_end_game_confirm": "End the game?",
"kv_play_open_projector": "Open Projector", "kv_play_open_projector": "Open Projector",
"kv_play_projector_url": "Projector",
"kv_play_game_over": "Game Over", "kv_play_game_over": "Game Over",
"kv_edit_values": "Values", "kv_edit_values": "Values",
"kv_edit_disabled": "Disabled", "kv_edit_disabled": "Disabled",
"kv_edit_rules": "Jeopardy Rules", "kv_edit_rules": "Jeopardy Rules",
"kv_edit_how_to": "How to Play?", "kv_edit_how_to": "How to Play?",
"kv_settings_colors": "Colors", "kv_settings_colors": "Colors",
"kv_settings_primary": "Primary", "kv_randomize": "Randomize",
"kv_settings_secondary": "Secondary", "kv_randomize_all_colors": "Randomize All Colors",
"kv_settings_primary": "Primary Color",
"kv_settings_secondary": "Secondary Color",
"kv_settings_text_color": "Text", "kv_settings_text_color": "Text",
"kv_settings_background": "Background", "kv_settings_background": "Background",
"kv_settings_reset": "Reset Settings", "kv_settings_reset": "Reset Settings",
@ -158,7 +119,7 @@
"kv_play_judging": "Judging", "kv_play_judging": "Judging",
"kv_play_enter_wager": "Enter wager", "kv_play_enter_wager": "Enter wager",
"kv_play_judged": "Judged", "kv_play_judged": "Judged",
"kv_play_wager_range": "Min: {min}€ Max: {max}€", "kv_play_wager_range": "Min: {min}€ - Max: {max}€",
"kv_play_finish": "Finish", "kv_play_finish": "Finish",
"kv_color_picker": "Color Picker", "kv_color_picker": "Color Picker",
"kv_done": "Done", "kv_done": "Done",

@ -1,7 +1,7 @@
{ {
"$schema": "https://inlang.com/schema/inlang-message-format", "$schema": "https://inlang.com/schema/inlang-message-format",
"app_title": "Sassi Mängukoobas", "app_title": "Sassi Mängukoobas",
"app_version": "v0.1.0", "app_version": "v0.1.1",
"coming_soon": "Tulekul", "coming_soon": "Tulekul",
"game_kuldvillak": "Kuldvillak", "game_kuldvillak": "Kuldvillak",
"game_rooside_soda": "Rooside Sõda", "game_rooside_soda": "Rooside Sõda",
@ -17,85 +17,51 @@
"kv_settings_close": "Välju", "kv_settings_close": "Välju",
"kv_error_404": "404", "kv_error_404": "404",
"kv_error_not_found": "Lehte ei leitud", "kv_error_not_found": "Lehte ei leitud",
"kv_error_hint": "(pssst... oled proovinud arvuti taaskäivitamist?)", "kv_error_hint": "(pssst... oled proovinud taaskäivitamist?)",
"kv_edit_title": "Mängu redaktor", "kv_edit_title": "Muuda mängu",
"kv_edit_back": "Tagasi", "kv_edit_back": "Tagasi",
"kv_edit_game_name": "Mängu nimi...", "kv_edit_game_name": "Mängu nimi...",
"kv_edit_save": "Salvesta", "kv_edit_save": "Salvesta",
"kv_edit_load": "Lae", "kv_edit_load": "Lae",
"kv_edit_reset": "Lähtesta", "kv_edit_reset": "Lähtesta",
"kv_edit_start": "Alusta", "kv_edit_start": "Alusta",
"kv_edit_settings_teams": "Mängu seadistus", "kv_edit_settings_teams": "Mängu seaded",
"kv_edit_rounds": "Voorude arv", "kv_edit_rounds": "Voorude arv",
"kv_play_timer": "Vastamisaeg", "kv_play_timer": "Vastamisaeg",
"kv_play_timer_reveal": "Vastuse näitamine", "kv_play_timer_reveal": "Vastuse näitamine",
"kv_play_seconds": "sekundit", "kv_play_seconds": "sekundit",
"kv_edit_final_round_toggle": "Finaalvoor",
"kv_edit_final_question": "Finaalküsimus",
"kv_edit_edit": "Muuda",
"kv_edit_teams": "Punktid & Tiimid",
"kv_edit_points": "Punktid", "kv_edit_points": "Punktid",
"kv_edit_preset_normal": "Tavaline", "kv_edit_preset_normal": "Tavaline",
"kv_edit_preset_double": "Duubel",
"kv_edit_x_base": "Kordaja",
"kv_edit_custom": "Kohandatud", "kv_edit_custom": "Kohandatud",
"kv_edit_base": "Baas",
"kv_edit_negative_scores": "Negatiivsed punktid", "kv_edit_negative_scores": "Negatiivsed punktid",
"kv_edit_daily_doubles": "Hõbevillak",
"kv_edit_r1": "Villak", "kv_edit_r1": "Villak",
"kv_edit_r2": "Topeltvillak", "kv_edit_r2": "Topeltvillak",
"kv_edit_teams_label": "Mängijad", "kv_edit_teams_label": "Mängijad",
"kv_edit_round_1": "Villak",
"kv_edit_round_2": "Topeltvillak",
"kv_edit_dd_count": "Hõbevillak", "kv_edit_dd_count": "Hõbevillak",
"kv_edit_category": "Kategooria", "kv_edit_category": "Kategooria",
"kv_edit_dd": "★", "kv_edit_dd": "★",
"kv_edit_no_category": "(Kategooria puudub)",
"kv_edit_question": "Küsimus", "kv_edit_question": "Küsimus",
"kv_edit_answer": "Vastus", "kv_edit_answer": "Vastus",
"kv_edit_daily_double": "Hõbevillak", "kv_edit_daily_double": "Hõbevillak",
"kv_edit_starting_game": "Alustan mängu...", "kv_edit_starting_game": "Alustan mängu...",
"kv_edit_opening_projector": "Avan projektori vaate", "kv_edit_opening_projector": "Avan projektori vaate",
"kv_error_min_players": "Vaja on vähemalt 2 mängijat", "kv_error_min_players": "Vaja on vähemalt 2 mängijat",
"kv_error_no_questions": "Voorul {round} pole küsimusi", "kv_error_no_questions": "{round}. voorul pole küsimusi",
"kv_error_no_final": "Finaalvooru küsimus on tühi", "kv_error_no_final": "Finaalvooru küsimus on tühi",
"kv_toast_game_saved": "Mäng salvestatud!", "kv_toast_game_saved": "Mäng salvestatud!",
"kv_toast_game_loaded": "Mäng laetud!", "kv_toast_game_loaded": "Mäng laetud!",
"kv_toast_invalid_file": "Vigane mängufail", "kv_toast_invalid_file": "Vigane mängufail",
"kv_edit_reset_confirm": "Kas oled kindel, et soovid kõik väljad vaikeväärtustele lähtestada? See kustutab kogu sinu töö.", "kv_edit_edit_round": "Muuda vooru",
"kv_edit_reset_confirm_title": "Lähtesta mäng?",
"kv_edit_reset_confirm_message": "Kas oled kindel, et soovid kõik väljad vaikeväärtustele lähtestada? See kustutab kogu sinu töö.",
"kv_edit_reset_success": "Mäng lähtestatud", "kv_edit_reset_success": "Mäng lähtestatud",
"kv_edit_categories_questions": "Kategooriad & Küsimused",
"kv_edit_daily_doubles_count": "Hõbevillakud",
"kv_edit_category_placeholder": "Kategooria nimi",
"kv_edit_empty": "Tühi",
"kv_edit_question_edit": "Muuda küsimust",
"kv_edit_question_placeholder": "Sisesta küsimus...",
"kv_edit_answer_placeholder": "Sisesta vastus...",
"kv_edit_final_round": "Finaalvoor", "kv_edit_final_round": "Finaalvoor",
"kv_edit_final_disabled": "Finaalvoor on seadetes keelatud.",
"kv_edit_category_name_placeholder": "Sisesta kategooria nimi...",
"kv_edit_final_question_placeholder": "Sisesta lõppküsimus...",
"kv_edit_game_settings": "Mängu seaded",
"kv_edit_num_rounds": "Voorude arv",
"kv_edit_1_round": "1 voor",
"kv_edit_2_rounds": "2 vooru",
"kv_edit_point_values": "Punktiväärtused",
"kv_edit_standard": "Tavaline (100-500)",
"kv_edit_double": "Topelt (200-1000)",
"kv_edit_multiplier": "Kordaja",
"kv_edit_base_value": "Baasväärtus",
"kv_edit_default_timer": "Vaikimisi taimer (sekundid)",
"kv_edit_enable_final": "Luba finaalvoor",
"kv_edit_allow_negative": "Luba negatiivsed punktid",
"kv_edit_teams_title": "Tiimid",
"kv_edit_team_name_placeholder": "Tiimi nimi...",
"kv_edit_add_team": "Lisa tiim", "kv_edit_add_team": "Lisa tiim",
"kv_edit_remove_team": "Eemalda", "kv_edit_remove_team": "Eemalda",
"kv_play_loading": "Laen mängu...", "kv_play_loading": "Laen mängu...",
"kv_play_loading_hint": "Kui see võtab liiga kaua, siis mängu pole alustatud.", "kv_play_loading_hint": "Kui see võtab liiga kaua, siis mängu pole alustatud.",
"kv_play_go_to_editor": "Mine redaktorisse", "kv_play_go_to_editor": "Muuda mängu",
"kv_play_round": "Voor", "kv_play_round": "Laen...",
"kv_play_phase": "Hetkeseis",
"kv_play_last_answer": "Viimane", "kv_play_last_answer": "Viimane",
"kv_play_introduce_categories": "Tutvusta kategooriaid", "kv_play_introduce_categories": "Tutvusta kategooriaid",
"kv_play_skip_to_game": "Jäta vahele", "kv_play_skip_to_game": "Jäta vahele",
@ -104,11 +70,8 @@
"kv_play_daily_double": "Hõbevillak", "kv_play_daily_double": "Hõbevillak",
"kv_play_wager": "Panus", "kv_play_wager": "Panus",
"kv_play_confirm": "Kinnita", "kv_play_confirm": "Kinnita",
"kv_play_question_number": "Küsimus {current}/{total}", "kv_play_question_short": "Küsimus",
"kv_play_showing_answer": "Näitan vastust...",
"kv_play_question_short": "K",
"kv_play_answer_short": "Vastus", "kv_play_answer_short": "Vastus",
"kv_play_answering": "Vastab",
"kv_play_correct": "Õige", "kv_play_correct": "Õige",
"kv_play_wrong": "Vale", "kv_play_wrong": "Vale",
"kv_play_skip": "Jäta vahele", "kv_play_skip": "Jäta vahele",
@ -119,21 +82,19 @@
"kv_play_reveal_category": "Näita kategooriat", "kv_play_reveal_category": "Näita kategooriat",
"kv_play_reveal_answer": "Näita vastust", "kv_play_reveal_answer": "Näita vastust",
"kv_play_show_scores": "Näita lõpptulemusi", "kv_play_show_scores": "Näita lõpptulemusi",
"kv_play_scores": "Tulemused",
"kv_play_adjust_by": "Muuda",
"kv_play_game_controls": "Mängu juhtimine",
"kv_play_next_round": "Järgmine voor", "kv_play_next_round": "Järgmine voor",
"kv_play_go_to_final": "Finaalvooru", "kv_play_go_to_final": "Finaalvooru",
"kv_play_end_game": "Lõpeta mäng", "kv_play_end_game": "Lõpeta mäng",
"kv_play_end_game_confirm": "Lõpeta mäng?", "kv_play_end_game_confirm": "Kas soovid mängu lõpetada?",
"kv_play_open_projector": "Ava projektor", "kv_play_open_projector": "Ava projektor",
"kv_play_projector_url": "Projektor",
"kv_play_game_over": "Mäng läbi", "kv_play_game_over": "Mäng läbi",
"kv_edit_values": "Väärtused", "kv_edit_values": "Väärtused",
"kv_edit_disabled": "Keelatud", "kv_edit_disabled": "Lisa",
"kv_edit_rules": "Kuldvillaku reeglid", "kv_edit_rules": "Kuldvillaku reeglid",
"kv_edit_how_to": "Kuidas mängida?", "kv_edit_how_to": "Kuidas mängida?",
"kv_settings_colors": "Värvid", "kv_settings_colors": "Värvid",
"kv_randomize": "Suvaline",
"kv_randomize_all_colors": "Muuda suvaliselt kõiki värve",
"kv_settings_primary": "Primaarne", "kv_settings_primary": "Primaarne",
"kv_settings_secondary": "Sekundaarne", "kv_settings_secondary": "Sekundaarne",
"kv_settings_text_color": "Tekst", "kv_settings_text_color": "Tekst",
@ -141,15 +102,15 @@
"kv_settings_reset": "Lähtesta seaded", "kv_settings_reset": "Lähtesta seaded",
"kv_settings_save_exit": "Salvesta ja välju", "kv_settings_save_exit": "Salvesta ja välju",
"kv_edit_image_link": "Pildi link", "kv_edit_image_link": "Pildi link",
"kv_edit_save_exit": "Salvesta ja Välju", "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_adjust_score": "Muuda skoori",
"kv_play_click_team_to_answer": "Kliki meeskonnal vastajaks", "kv_play_click_team_to_answer": "Kliki meeskonnal vastajaks",
"kv_play_final_scores": "Lõpptulemused", "kv_play_final_scores": "Lõpptulemused",
"kv_edit_dd_short": "HV", "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_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_answer_revealed": "Vastus näidatud. Tagasi mängulauale {seconds} sekundi pärast...",
"kv_play_timer_paused": "Taimer peatatud. {name} vastab.", "kv_play_timer_paused": "Taimer peatatud, {name} vastab.",
"kv_play_correct_return": "{name} vastas õigesti! Tagasi mängulaudale {seconds} sekundi pärast...", "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_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_wrong_reveal": "{name} vastas valesti. Vastuse näitamine {seconds} sekundi pärast...",
@ -158,24 +119,24 @@
"kv_play_judging": "Hindamine", "kv_play_judging": "Hindamine",
"kv_play_enter_wager": "Sisesta panus", "kv_play_enter_wager": "Sisesta panus",
"kv_play_judged": "Hinnatud", "kv_play_judged": "Hinnatud",
"kv_play_wager_range": "Min: {min}€ Max: {max}€", "kv_play_wager_range": "Min: {min}€ - Max: {max}€",
"kv_play_finish": "Lõpeta", "kv_play_finish": "Lõpeta",
"kv_color_picker": "Värvivalija", "kv_color_picker": "Värvivalija",
"kv_done": "Valmis", "kv_done": "Valmis",
"kv_opacity": "Läbipaistvus", "kv_opacity": "Läbipaistvus",
"kv_confirm_close_title": "Loobuda muudatustest?", "kv_confirm_close_title": "Loobu muudatustest?",
"kv_confirm_close_message": "Kas oled kindel, et soovid sulgeda? Salvestamata muudatused lähevad kaotsi.", "kv_confirm_close_message": "Kas oled kindel, et soovid sulgeda? Salvestamata muudatused lähevad kaotsi.",
"kv_confirm_discard": "Loobu", "kv_confirm_discard": "Loobu",
"kv_confirm_cancel": "Tühista", "kv_confirm_cancel": "Mine tagasi",
"kv_final_round": "Kuldvillak", "kv_final_round": "Kuldvillak",
"kv_tutorial_rules_placeholder": "Platvormikoht: Lisa siia reeglite seletus", "kv_tutorial_rules_placeholder": "Lisa siia reeglite seletus",
"kv_tutorial_howto_1": "Tervist! Teeme kerge sissejuhatuse, kuidas sa saad hakata kuldvillakut mängima.\nProovin olla võimalikult detailne, et katta võimalikud küsimused ära, aga loodetavasti on platvormi kasutada piisavalt lihtne, et palju seletust pole vaja.\nSul on võimalik tulla siia tagasi igal ajal.", "kv_tutorial_howto_1": "Tervist! Teeme kerge sissejuhatuse, kuidas sa saad asuda Kuldvillakut mängima.\nProovin olla võimalikult detailne, et katta võimalikud küsimused ära, aga loodetavasti on platvormi kasutamine piisavalt lihtne, et palju seletust pole vaja.\nSul on võimalik tulla siia tagasi ja lugeda üle igal ajal.",
"kv_tutorial_howto_2": "See on sinu mänguloomise ekraan.\nSiin on sul võimalik seada mängu pikkust, punkte, lisareegleid, mängijaid, küsimusi ja palju muud, mis läheb ühe kuldvillaku korraldamisse.", "kv_tutorial_howto_2": "See on sinu mänguloomise ekraan.\nSiin on sul võimalik seada mängu pikkust, punkte, lisareegleid, mängijaid, küsimusi ja palju muud, mis läheb ühe Kuldvillaku korraldamisse.",
"kv_tutorial_howto_3": "Tagasi nool - tagasi kodulehele\nMängu nimi - saad panna enda mängule nime (muudab failinime)\nAva mäng - ava varasemalt tehtud mäng\nSalvesta mäng - salvesta praegune mäng failina arvutisse hiljem kasutamiseks\nreset - lähtesta senine töö\nseaded - kõik vajalikud seaded helist kuni värvideni välja. Mine proovi!\nAlusta - alustab mängu", "kv_tutorial_howto_3": "Tagasi nool - tagasi kodulehele\nMängu nimi - saad panna enda mängule nime (muudab failinime)\nAva mäng - ava varasemalt tehtud mäng\nSalvesta mäng - salvesta praegune mäng failina arvutisse\nLähtesta - lähtesta senine töö\nseaded - kõik vajalikud seaded helist kuni värvideni välja. Mine proovi!\nAlusta - alustab mängu",
"kv_tutorial_howto_4": "voorude arv - kas 1 voor (villak) või 2 vooru (villak + Topeltvillak). 2 vooru on originaalne formaat\nVastamisaeg - määra, kui kaua mängijad vastata saavad sekundites. 5 sekundit on originaalne formaat\nVastuse näitamine - määra, kui kaua mäng näitab vastust ekraanil. 5 sekundit on originaalne formaat.\nFinaalvoor - muuda finaalvooru küsimust. Saad valida, kas finaalvoor tuleb mängu lõpus või ei.\n\nPunktid - tavaline on 10-50, topeltvillakus 20-100. kohandatud laseb sul panna villaku vooru punktid, mis duubelduvad topeltvillakus.\nNegatiivsed punktid - määra, kas mängijate skoorid saavad minna negatiivseks. negatiivsed punktid on sees originaalses formaadis.\nmängijad - lisa mängijad. mängijate arv on 2-6.", "kv_tutorial_howto_4": "voorude arv - kas 1 voor (villak) või 2 vooru (Villak + Topeltvillak). 2 vooru on originaalne formaat\nVastamisaeg - määra, kui kaua mängijad vastata saavad sekundites. 5 sekundit on originaalne formaat\nVastuse näitamine - määra, kui kaua mäng näitab vastust ekraanil. 5 sekundit on originaalne formaat.\nFinaalvoor - muuda finaalvooru küsimust. Saad valida, kas finaalvoor tuleb mängu lõpus või ei.\n\nPunktid - tavaline on 10-50, topeltvillakus 20-100. kohandatud laseb sul panna villaku vooru punktid, mis duubelduvad topeltvillakus.\nNegatiivsed punktid - määra, kas mängijate skoorid saavad minna negatiivseks. negatiivsed punktid on sees originaalses formaadis.\nmängijad - lisa mängijad. mängijate arv on 2-6.",
"kv_tutorial_howto_5": "Igas voorus on 6 kategooriat ja igas kategoorias on 5 küsimust. Kõik kategooriad ja küsimused peavad olema täidetud selleks, et mängu alustada.\nMuutmiseks kliki kategooria või küsimuse ruudu peale.\n\nVillaku voorus on 1 hõbevillak, topeltvillaku voorus 2 hõbevillakut. Iga küsimuse juures on võimalik valida, kas küsimus on hõbevillak või mitte.\nHõbevillaku seletuse leiad kuldvillaku reeglitest.", "kv_tutorial_howto_5": "Igas voorus on 6 kategooriat ja igas kategoorias on 5 küsimust. Kõik kategooriad ja küsimused peavad olema täidetud selleks, et mängu alustada.\nMuutmiseks kliki kategooria või küsimuse ruudu peale.\n\nVillaku voorus on 1 hõbevillak, topeltvillaku voorus 2 hõbevillakut. Iga küsimuse juures on võimalik valida, kas küsimus on hõbevillak või mitte.\nHõbevillaku seletuse leiad kuldvillaku reeglitest.",
"kv_tutorial_howto_6": "Iga küsimuse juures on võimalik näha kategooriat ja mitu punkti saab.\nSelleks, et küsimus oleks täidetud, on vaja panna küsimus ja vastus kirja.\nKüsimuse juurde on võimalik panna ka pilt, mis tähendab seda, et küsimuse näitamisel tuleb ekraanile ainult pilt, mitte küsimus. Mängu läbiviijal on võimalik küsimust oma ekraanilt lugeda.\n\nLisaks on võimalik määrata, kas küsimus on hõbevillak või mitte ja näha, mitu hõbevillakut selles voorus juba on määratud.", "kv_tutorial_howto_6": "Iga küsimuse juures on võimalik näha kategooriat ja mitu punkti saab.\nSelleks, et küsimus oleks täidetud, on vaja panna küsimus ja vastus kirja.\nKüsimuse juurde on võimalik panna ka pilt, mis tähendab seda, et küsimuse näitamisel tuleb ekraanile ainult pilt, mitte küsimus. Mängu läbiviijal on võimalik küsimust oma ekraanilt lugeda.\n\nLisaks on võimalik määrata, kas küsimus on hõbevillak või mitte ja näha, mitu hõbevillakut selles voorus juba on määratud.",
"kv_tutorial_howto_7": "Muusika - muuda muusika helitugevust.\nHeliefektid - muuda heliefektide helitugevust.\nKeel - praegu on toetatud eesti ja inglise keel.\n\nEi meeldi tavaline kuldvillaku sinine-koldne stiil? Võid muuta igat värvi nii palju kui tahad ja kogu mäng jagab sinu stiili! Muudmoodi teada ei saa, kui ei proovi!", "kv_tutorial_howto_7": "Muusika - muuda muusika helitugevust.\nHeliefektid - muuda heliefektide helitugevust.\nKeel - praegu on toetatud eesti ja inglise keel.\n\nEi meeldi tavaline kuldvillaku sinine-kuldne stiil? Võid muuta igat värvi nii palju kui tahad ja kogu mäng jagab sinu stiili! Muudmoodi teada ei saa, kui ei proovi!",
"error_title": "Midagi läks valesti", "error_title": "Midagi läks valesti",
"error_description": "Tekkis ootamatu viga. Palun proovi uuesti.", "error_description": "Tekkis ootamatu viga. Palun proovi uuesti.",
"error_details": "Tehnilised detailid", "error_details": "Tehnilised detailid",

14
package-lock.json generated

@ -6,7 +6,7 @@
"packages": { "packages": {
"": { "": {
"name": "ultimate-gaming", "name": "ultimate-gaming",
"version": "0.1.0", "version": "0.1.1",
"devDependencies": { "devDependencies": {
"@inlang/paraglide-js": "^2.5.0", "@inlang/paraglide-js": "^2.5.0",
"@sveltejs/adapter-auto": "^7.0.0", "@sveltejs/adapter-auto": "^7.0.0",
@ -152,7 +152,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
}, },
@ -199,7 +198,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -1133,7 +1131,6 @@
"integrity": "sha512-oH8tXw7EZnie8FdOWYrF7Yn4IKrqTFHhXvl8YxXxbKwTMcD/5NNCryUSEXRk2ZR4ojnub0P8rNrsVGHXWqIDtA==", "integrity": "sha512-oH8tXw7EZnie8FdOWYrF7Yn4IKrqTFHhXvl8YxXxbKwTMcD/5NNCryUSEXRk2ZR4ojnub0P8rNrsVGHXWqIDtA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@standard-schema/spec": "^1.0.0", "@standard-schema/spec": "^1.0.0",
"@sveltejs/acorn-typescript": "^1.0.5", "@sveltejs/acorn-typescript": "^1.0.5",
@ -1173,7 +1170,6 @@
"integrity": "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==", "integrity": "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0",
"debug": "^4.4.1", "debug": "^4.4.1",
@ -1585,7 +1581,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@ -2156,7 +2151,6 @@
"integrity": "sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA==", "integrity": "sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@acemir/cssom": "^0.9.23", "@acemir/cssom": "^0.9.23",
"@asamuzakjp/dom-selector": "^6.7.4", "@asamuzakjp/dom-selector": "^6.7.4",
@ -2220,7 +2214,6 @@
"integrity": "sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ==", "integrity": "sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
} }
@ -2643,7 +2636,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@ -2833,7 +2825,6 @@
"integrity": "sha512-yyXdW2u3H0H/zxxWoGwJoQlRgaSJLp+Vhktv12iRw2WRDlKqUPT54Fi0K/PkXqrdkcQ98aBazpy0AH4BCBVfoA==", "integrity": "sha512-yyXdW2u3H0H/zxxWoGwJoQlRgaSJLp+Vhktv12iRw2WRDlKqUPT54Fi0K/PkXqrdkcQ98aBazpy0AH4BCBVfoA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@jridgewell/remapping": "^2.3.4", "@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/sourcemap-codec": "^1.5.0",
@ -3030,7 +3021,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@ -3082,7 +3072,6 @@
"integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==", "integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@ -4214,7 +4203,6 @@
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.21.3", "esbuild": "^0.21.3",
"postcss": "^8.4.43", "postcss": "^8.4.43",

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import * as m from "$lib/paraglide/messages"; import * as m from "$lib/paraglide/messages";
import ConfirmDialog from "./ConfirmDialog.svelte"; import ConfirmDialog from "./ConfirmDialog.svelte";
import { KvButton } from "$lib/components/kuldvillak/ui";
interface ColorPickerProps { interface ColorPickerProps {
value: string; value: string;
@ -670,7 +671,6 @@
<input <input
type="text" type="text"
bind:value={hexInput} bind:value={hexInput}
oninput={updateFromHex}
onblur={updateFromHex} onblur={updateFromHex}
onkeydown={(e) => e.key === "Enter" && updateFromHex()} onkeydown={(e) => e.key === "Enter" && updateFromHex()}
class="w-full bg-kv-black border-2 md:border-4 border-black px-2 md:px-3 py-1.5 md:py-2 text-kv-white font-kv-body text-base md:text-lg uppercase" class="w-full bg-kv-black border-2 md:border-4 border-black px-2 md:px-3 py-1.5 md:py-2 text-kv-white font-kv-body text-base md:text-lg uppercase"
@ -799,12 +799,9 @@
</div> </div>
<!-- Done Button --> <!-- Done Button -->
<button <KvButton variant="secondary" onclick={closePicker} size="md">
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()} {m.kv_done()}
</button> </KvButton>
</div> </div>
<!-- Confirmation Dialog --> <!-- Confirmation Dialog -->

@ -1,5 +1,7 @@
<script lang="ts"> <script lang="ts">
import * as m from "$lib/paraglide/messages"; import * as m from "$lib/paraglide/messages";
import { KvButton } from "$lib/components/kuldvillak/ui";
import { trapFocus } from "$lib/utils/focusTrap";
interface ConfirmDialogProps { interface ConfirmDialogProps {
open?: boolean; open?: boolean;
@ -54,6 +56,7 @@
bg-kv-blue border-4 md:border-8 border-kv-black bg-kv-blue border-4 md:border-8 border-kv-black
p-4 md:p-6 w-[90vw] max-w-[380px] p-4 md:p-6 w-[90vw] max-w-[380px]
flex flex-col gap-4 items-center text-center" flex flex-col gap-4 items-center text-center"
use:trapFocus
> >
<h3 <h3
class="text-xl md:text-2xl text-kv-white uppercase font-kv-body kv-shadow-text" class="text-xl md:text-2xl text-kv-white uppercase font-kv-body kv-shadow-text"
@ -66,18 +69,22 @@
{message} {message}
</p> </p>
<div class="flex gap-3 w-full"> <div class="flex gap-3 w-full">
<button <KvButton
variant="primary"
onclick={handleCancel} 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" size="md"
class="flex-1"
> >
<span class="kv-shadow-text">{cancelText}</span> {cancelText}
</button> </KvButton>
<button <KvButton
variant="secondary"
onclick={handleConfirm} 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" size="md"
class="flex-1"
> >
{confirmText} {confirmText}
</button> </KvButton>
</div> </div>
</div> </div>
{/if} {/if}

@ -2,10 +2,8 @@
import Slider from "./Slider.svelte"; import Slider from "./Slider.svelte";
import ColorPicker from "./ColorPicker.svelte"; import ColorPicker from "./ColorPicker.svelte";
import ConfirmDialog from "./ConfirmDialog.svelte"; import ConfirmDialog from "./ConfirmDialog.svelte";
import { import { KvButton } from "$lib/components/kuldvillak/ui";
KvButtonPrimary, import { trapFocus } from "$lib/utils/focusTrap";
KvButtonSecondary,
} from "$lib/components/kuldvillak/ui";
import * as m from "$lib/paraglide/messages"; import * as m from "$lib/paraglide/messages";
import { audioStore } from "$lib/stores/audio.svelte"; import { audioStore } from "$lib/stores/audio.svelte";
import { themeStore } from "$lib/stores/theme.svelte"; import { themeStore } from "$lib/stores/theme.svelte";
@ -102,6 +100,7 @@
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="settings-title" aria-labelledby="settings-title"
use:trapFocus
> >
<!-- Header with Title and Close Button --> <!-- Header with Title and Close Button -->
<div class="flex items-start justify-between w-full"> <div class="flex items-start justify-between w-full">
@ -217,7 +216,7 @@
</div> </div>
<!-- Color Swatches --> <!-- Color Swatches -->
<div class="flex flex-col gap-4 w-full"> <div class="flex flex-col gap-6 w-full">
<!-- Primary Color --> <!-- Primary Color -->
<div class="flex items-center justify-between w-full"> <div class="flex items-center justify-between w-full">
<span <span
@ -225,11 +224,22 @@
> >
{m.kv_settings_primary()} {m.kv_settings_primary()}
</span> </span>
<div class="flex items-center gap-2">
<ColorPicker <ColorPicker
bind:value={themeStore.primary} bind:value={themeStore.primary}
onchange={(c) => (themeStore.primary = c)} onchange={(c) => (themeStore.primary = c)}
/> />
<KvButton
variant="secondary"
onclick={() =>
(themeStore.primary = themeStore.getRandomColor())}
size="sm"
>
{m.kv_randomize()}
</KvButton>
</div> </div>
</div>
<!-- Secondary Color --> <!-- Secondary Color -->
<div class="flex items-center justify-between w-full"> <div class="flex items-center justify-between w-full">
<span <span
@ -237,11 +247,23 @@
> >
{m.kv_settings_secondary()} {m.kv_settings_secondary()}
</span> </span>
<div class="flex items-center gap-2">
<ColorPicker <ColorPicker
bind:value={themeStore.secondary} bind:value={themeStore.secondary}
onchange={(c) => (themeStore.secondary = c)} onchange={(c) => (themeStore.secondary = c)}
/> />
<KvButton
variant="secondary"
onclick={() =>
(themeStore.secondary =
themeStore.getRandomColor())}
size="sm"
>
{m.kv_randomize()}
</KvButton>
</div>
</div> </div>
<!-- Text Color --> <!-- Text Color -->
<div class="flex items-center justify-between w-full"> <div class="flex items-center justify-between w-full">
<span <span
@ -249,11 +271,22 @@
> >
{m.kv_settings_text_color()} {m.kv_settings_text_color()}
</span> </span>
<div class="flex items-center gap-2">
<ColorPicker <ColorPicker
bind:value={themeStore.text} bind:value={themeStore.text}
onchange={(c) => (themeStore.text = c)} onchange={(c) => (themeStore.text = c)}
/> />
<KvButton
variant="secondary"
onclick={() =>
(themeStore.text = themeStore.getRandomColor())}
size="sm"
>
{m.kv_randomize()}
</KvButton>
</div> </div>
</div>
<!-- Background Color --> <!-- Background Color -->
<div class="flex items-center justify-between w-full"> <div class="flex items-center justify-between w-full">
<span <span
@ -261,28 +294,57 @@
> >
{m.kv_settings_background()} {m.kv_settings_background()}
</span> </span>
<div class="flex items-center gap-2">
<ColorPicker <ColorPicker
bind:value={themeStore.background} bind:value={themeStore.background}
onchange={(c) => (themeStore.background = c)} onchange={(c) => (themeStore.background = c)}
/> />
<KvButton
variant="secondary"
onclick={() =>
(themeStore.background =
themeStore.getRandomColor())}
size="sm"
>
{m.kv_randomize()}
</KvButton>
</div>
</div> </div>
</div> </div>
<!-- Randomize All Button -->
<div class="flex justify-center w-full mt-2">
<KvButton
variant="secondary"
onclick={themeStore.randomizeColors}
size="md"
>
{m.kv_randomize_all_colors()}
</KvButton>
</div>
<!-- Buttons Container -->
<div class="flex flex-row gap-4 w-full">
<!-- Reset Settings Button --> <!-- Reset Settings Button -->
<KvButtonPrimary <KvButton
variant="primary"
onclick={handleResetSettings} onclick={handleResetSettings}
class="!text-2xl !py-4 !px-4" size="md"
class="flex-1"
> >
{m.kv_settings_reset()} {m.kv_settings_reset()}
</KvButtonPrimary> </KvButton>
<!-- Save and Exit Button --> <!-- Save and Exit Button -->
<KvButtonSecondary <KvButton
variant="secondary"
onclick={handleSaveAndExit} onclick={handleSaveAndExit}
class="!text-2xl !py-4 !px-4" size="md"
class="flex-1"
> >
{m.kv_settings_save_exit()} {m.kv_settings_save_exit()}
</KvButtonSecondary> </KvButton>
</div>
</div> </div>
<!-- Confirmation Dialog --> <!-- Confirmation Dialog -->

@ -0,0 +1,28 @@
<script lang="ts">
import { toastStore } from "$lib/stores/toast.svelte";
</script>
{#each toastStore.notifications as notification (notification.id)}
<div
class="fixed bottom-8 left-1/2 -translate-x-1/2 z-[200] px-6 py-4 rounded-lg shadow-lg
font-[family-name:var(--kv-font-button)] text-lg uppercase
{notification.type === 'error'
? 'bg-red-600 text-kv-white'
: notification.type === 'success'
? 'bg-green-600 text-kv-white'
: 'bg-blue-600 text-kv-white'}"
role="alert"
style="bottom: calc(2rem + {toastStore.notifications.indexOf(notification) * 4}rem)"
>
<div class="flex items-center gap-3">
<span>{notification.message}</span>
<button
onclick={() => toastStore.dismiss(notification.id)}
class="ml-2 opacity-70 hover:opacity-100"
aria-label="Dismiss"
>
</button>
</div>
</div>
{/each}

@ -0,0 +1,153 @@
<script lang="ts">
import { editorStore } from "$lib/stores/editor.svelte";
import { KvButton } from "$lib/components/kuldvillak/ui";
import * as m from "$lib/paraglide/messages";
interface Props {
gameName: string;
onStart: () => void;
onReset: () => void;
onSettings?: () => void;
onImportSuccess?: () => void;
onImportError?: (error: string) => void;
}
let {
gameName = $bindable(),
onStart,
onReset,
onSettings,
onImportSuccess,
onImportError,
}: Props = $props();
let fileInput: HTMLInputElement;
function handleExport() {
const { blob, filename } = editorStore.exportGame();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
function handleImport(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = JSON.parse(e.target?.result as string);
const result = editorStore.importGame(data);
if (result === true) {
gameName = editorStore.gameName;
onImportSuccess?.();
} else {
onImportError?.(result);
}
} catch {
onImportError?.(m.kv_toast_invalid_file());
}
};
reader.readAsText(file);
(event.target as HTMLInputElement).value = "";
}
// SVG Icons
const BackIcon = `<path d="M29.5334 40L13.5334 24L29.5334 8L33.2668 11.7333L21.0001 24L33.2668 36.2667L29.5334 40Z"/>`;
const LoadIcon = `<path d="M5.41634 33.3332C4.49967 33.3332 3.71495 33.0068 3.06217 32.354C2.4094 31.7012 2.08301 30.9165 2.08301 29.9998V9.99984C2.08301 9.08317 2.4094 8.29845 3.06217 7.64567C3.71495 6.99289 4.49967 6.6665 5.41634 6.6665H15.4163L18.7497 9.99984H32.083C32.9997 9.99984 33.7844 10.3262 34.4372 10.979C35.09 11.6318 35.4163 12.4165 35.4163 13.3332H17.3747L14.0413 9.99984H5.41634V29.9998L9.41634 16.6665H37.9163L33.6247 30.9582C33.4025 31.6804 32.9927 32.2568 32.3955 32.6873C31.7983 33.1179 31.1386 33.3332 30.4163 33.3332H5.41634ZM8.91634 29.9998H30.4163L33.4163 19.9998H11.9163L8.91634 29.9998Z"/>`;
const SaveIcon = `<path d="M35 11.6667V31.6667C35 32.5833 34.6736 33.3681 34.0208 34.0208C33.3681 34.6736 32.5833 35 31.6667 35H8.33333C7.41667 35 6.63194 34.6736 5.97917 34.0208C5.32639 33.3681 5 32.5833 5 31.6667V8.33333C5 7.41667 5.32639 6.63194 5.97917 5.97917C6.63194 5.32639 7.41667 5 8.33333 5H28.3333L35 11.6667ZM31.6667 13.0833L26.9167 8.33333H8.33333V31.6667H31.6667V13.0833ZM20 30C21.3889 30 22.5694 29.5139 23.5417 28.5417C24.5139 27.5694 25 26.3889 25 25C25 23.6111 24.5139 22.4306 23.5417 21.4583C22.5694 20.4861 21.3889 20 20 20C18.6111 20 17.4306 20.4861 16.4583 21.4583C15.4861 22.4306 15 23.6111 15 25C15 26.3889 15.4861 27.5694 16.4583 28.5417C17.4306 29.5139 18.6111 30 20 30ZM10 16.6667H25V10H10V16.6667Z"/>`;
const ResetIcon = `<path d="M20.0837 33.3332C16.3614 33.3332 13.1948 32.0415 10.5837 29.4582C7.97255 26.8748 6.66699 23.7221 6.66699 19.9998V19.7082L4.00033 22.3748L1.66699 20.0415L8.33366 13.3748L15.0003 20.0415L12.667 22.3748L10.0003 19.7082V19.9998C10.0003 22.7776 10.9795 25.1387 12.9378 27.0832C14.8962 29.0276 17.2781 29.9998 20.0837 29.9998C20.8059 29.9998 21.5142 29.9165 22.2087 29.7498C22.9031 29.5832 23.5837 29.3332 24.2503 28.9998L26.7503 31.4998C25.6948 32.111 24.6114 32.5693 23.5003 32.8748C22.3892 33.1804 21.2503 33.3332 20.0837 33.3332ZM31.667 26.6248L25.0003 19.9582L27.3337 17.6248L30.0003 20.2915V19.9998C30.0003 17.2221 29.0212 14.8609 27.0628 12.9165C25.1045 10.9721 22.7225 9.99984 19.917 9.99984C19.1948 9.99984 18.4864 10.0832 17.792 10.2498C17.0975 10.4165 16.417 10.6665 15.7503 10.9998L13.2503 8.49984C14.3059 7.88873 15.3892 7.43039 16.5003 7.12484C17.6114 6.81928 18.7503 6.6665 19.917 6.6665C23.6392 6.6665 26.8059 7.95817 29.417 10.5415C32.0281 13.1248 33.3337 16.2776 33.3337 19.9998V20.2915L36.0003 17.6248L38.3337 19.9582L31.667 26.6248Z"/>`;
const SettingsIcon = `<path d="M12.125 27.5L11.5 23.34Q10.91 23.09 10.25 22.72Q9.59 22.34 9.09 21.94L5.19 23.63L2.5 18.88L5.91 16.28Q5.84 15.97 5.83 15.61Q5.81 15.25 5.81 15Q5.81 14.75 5.83 14.39Q5.84 14.03 5.91 13.72L2.5 11.13L5.19 6.38L9.09 8.06Q9.59 7.66 10.25 7.28Q10.91 6.91 11.5 6.66L12.125 2.5H17.875L18.5 6.66Q19.09 6.91 19.75 7.28Q20.41 7.66 20.91 8.06L24.81 6.38L27.5 11.13L24.09 13.72Q24.16 14.03 24.17 14.39Q24.19 14.75 24.19 15Q24.19 15.25 24.17 15.61Q24.16 15.97 24.09 16.28L27.5 18.88L24.81 23.63L20.91 21.94Q20.41 22.34 19.75 22.72Q19.09 23.09 18.5 23.34L17.875 27.5ZM15 19.06Q16.69 19.06 17.88 17.88Q19.06 16.69 19.06 15Q19.06 13.31 17.88 12.13Q16.69 10.94 15 10.94Q13.31 10.94 12.13 12.13Q10.94 13.31 10.94 15Q10.94 16.69 12.13 17.88Q13.31 19.06 15 19.06Z"/>`;
</script>
<header
class="bg-kv-blue flex flex-wrap items-center gap-4 lg:gap-8 p-2 md:p-4"
>
<!-- Back + Game Name -->
<div class="flex items-center gap-4 flex-1">
<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" fill="currentColor" class="w-full h-full">
{@html BackIcon}
</svg>
</a>
<div class="flex-1 px-4 py-4 bg-kv-black/50">
<input
type="text"
bind:value={gameName}
placeholder={m.kv_edit_game_name()}
class="w-full bg-transparent border-none outline-none font-kv-body text-2xl text-kv-white uppercase kv-shadow-text placeholder:text-kv-white/50"
/>
</div>
</div>
<!-- Action Buttons -->
<div class="flex items-center gap-8">
<KvButton
variant="icon"
size="icon"
onclick={() => fileInput.click()}
ariaLabel={m.kv_edit_load()}
>
<svg viewBox="0 0 40 40" fill="currentColor" class="w-full h-full">
{@html LoadIcon}
</svg>
</KvButton>
<KvButton
variant="icon"
size="icon"
onclick={handleExport}
ariaLabel={m.kv_edit_save()}
>
<svg viewBox="0 0 40 40" fill="currentColor" class="w-full h-full">
{@html SaveIcon}
</svg>
</KvButton>
<KvButton
variant="icon"
size="icon"
onclick={onReset}
ariaLabel={m.kv_edit_reset()}
>
<svg viewBox="0 0 40 40" fill="currentColor" class="w-full h-full">
{@html ResetIcon}
</svg>
</KvButton>
{#if onSettings}
<KvButton
variant="icon"
size="icon"
onclick={onSettings}
ariaLabel={m.kv_settings()}
class="w-8 h-8"
>
<svg
viewBox="0 0 30 30"
fill="currentColor"
class="w-full h-full"
>
{@html SettingsIcon}
</svg>
</KvButton>
{/if}
</div>
<input
type="file"
accept=".json"
class="hidden"
bind:this={fileInput}
onchange={handleImport}
/>
<KvButton variant="secondary" onclick={onStart}>
{m.kv_edit_start()}
</KvButton>
</header>

@ -0,0 +1,284 @@
<script lang="ts">
import { editorStore } from "$lib/stores/editor.svelte";
import { KvButton, KvCheckbox } from "$lib/components/kuldvillak/ui";
import { ConfirmDialog } from "$lib/components";
import { trapFocus } from "$lib/utils/focusTrap";
import * as m from "$lib/paraglide/messages";
interface Props {
roundIndex?: number;
catIndex?: number;
qIndex?: number;
onClose: () => void;
finalRound?: boolean;
}
let {
roundIndex = 0,
catIndex = 0,
qIndex = 0,
onClose,
finalRound = false,
}: Props = $props();
// Get the original question data
let originalQuestion = $derived(
finalRound
? {
question: "",
answer: "",
imageUrl: "",
isDailyDouble: false,
points: 0,
}
: editorStore.getQuestion(roundIndex, catIndex, qIndex),
);
let category = $derived(
finalRound
? null
: editorStore.rounds[roundIndex]?.categories[catIndex],
);
let maxDD = $derived(
finalRound
? 0
: (editorStore.settings.dailyDoublesPerRound[roundIndex] ?? 1),
);
let currentDD = $derived(
finalRound ? 0 : editorStore.countDailyDoubles(roundIndex),
);
// Local editing state (only saved when user clicks save)
let localCategory = $state("");
let localQuestion = $state("");
let localAnswer = $state("");
let localImageUrl = $state("");
let localIsDailyDouble = $state(false);
// Track if there are unsaved changes
let hasChanges = $derived(
finalRound
? localCategory !== editorStore.finalRound.category ||
localQuestion !== editorStore.finalRound.question ||
localAnswer !== editorStore.finalRound.answer
: originalQuestion &&
(localQuestion !== originalQuestion.question ||
localAnswer !== originalQuestion.answer ||
localImageUrl !== (originalQuestion.imageUrl || "") ||
localIsDailyDouble !== originalQuestion.isDailyDouble),
);
// Can toggle DD (either already DD or have room for more)
let canToggleDD = $derived(localIsDailyDouble || currentDD < maxDD);
// Confirmation dialog state
let showConfirmClose = $state(false);
// Initialize local state when question changes
$effect(() => {
if (finalRound) {
localCategory = editorStore.finalRound.category;
localQuestion = editorStore.finalRound.question;
localAnswer = editorStore.finalRound.answer;
} else if (originalQuestion) {
localQuestion = originalQuestion.question;
localAnswer = originalQuestion.answer;
localImageUrl = originalQuestion.imageUrl || "";
localIsDailyDouble = originalQuestion.isDailyDouble;
}
});
function handleSaveAndClose() {
if (finalRound) {
editorStore.updateFinalRound({
category: localCategory,
question: localQuestion,
answer: localAnswer,
});
} else if (originalQuestion) {
editorStore.updateQuestion(roundIndex, catIndex, qIndex, {
question: localQuestion,
answer: localAnswer,
imageUrl: localImageUrl || undefined,
isDailyDouble: localIsDailyDouble,
});
}
onClose();
}
function handleCloseAttempt() {
if (hasChanges) {
showConfirmClose = true;
} else {
onClose();
}
}
function handleDiscardAndClose() {
showConfirmClose = false;
onClose();
}
function handleSaveFromConfirm() {
showConfirmClose = false;
handleSaveAndClose();
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) handleCloseAttempt();
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Escape") handleCloseAttempt();
}
const CloseIcon = `<path d="M6.6188 19.4811L4.5188 17.3811L9.9188 11.9811L4.5188 6.61855L6.6188 4.51855L12.0188 9.91855L17.3813 4.51855L19.4813 6.61855L14.0813 11.9811L19.4813 17.3811L17.3813 19.4811L12.0188 14.0811L6.6188 19.4811Z"/>`;
</script>
{#if originalQuestion || finalRound}
<div
class="fixed inset-0 bg-kv-background/50 flex items-center justify-center z-50 p-2 md:p-8"
onclick={handleBackdropClick}
onkeydown={handleKeydown}
role="dialog"
tabindex="-1"
>
<div
class="bg-kv-blue border-8 md:border-[16px] border-kv-black p-4 md:p-8 w-full max-w-2xl flex flex-col gap-4 md:gap-8 max-h-[95vh] overflow-y-auto"
use:trapFocus
>
<!-- Header -->
<div class="flex items-start justify-between gap-4">
<h3
class="font-kv-body text-xl md:text-[28px] text-kv-white uppercase kv-shadow-text m-0"
>
{#if finalRound}
{m.kv_edit_final_round()}
{:else}
{category?.name || m.kv_edit_category()} - {originalQuestion?.points}
{/if}
</h3>
<button
onclick={handleCloseAttempt}
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"
fill="currentColor"
class="w-full h-full"
>
{@html CloseIcon}
</svg>
</button>
</div>
<!-- Category Input (Final Round only) -->
{#if finalRound}
<div class="flex flex-col gap-2">
<label
for="q-category"
class="font-kv-body text-sm text-kv-white uppercase kv-shadow-text"
>
{m.kv_edit_category()}
</label>
<input
id="q-category"
type="text"
bind:value={localCategory}
placeholder={m.kv_edit_category()}
class="w-full bg-kv-black/50 border-4 border-kv-black px-4 py-2 font-kv-body text-lg text-kv-white outline-none placeholder:text-kv-white/50 uppercase"
/>
</div>
{/if}
<!-- Question Input -->
<div class="flex flex-col gap-2">
<label
for="q-text"
class="font-kv-body text-sm text-kv-white uppercase kv-shadow-text"
>
{m.kv_edit_question()}
</label>
<textarea
id="q-text"
bind:value={localQuestion}
placeholder={m.kv_edit_question()}
rows={4}
class="w-full bg-kv-black/50 border-4 border-kv-black px-4 py-2 font-kv-body text-lg text-kv-white outline-none resize-none placeholder:text-kv-white/50 uppercase"
></textarea>
</div>
<!-- Answer Input -->
<div class="flex flex-col gap-2">
<label
for="q-answer"
class="font-kv-body text-sm text-kv-white uppercase kv-shadow-text"
>
{m.kv_edit_answer()}
</label>
<textarea
id="q-answer"
bind:value={localAnswer}
placeholder={m.kv_edit_answer()}
rows={3}
class="w-full bg-kv-black/50 border-4 border-kv-black px-4 py-2 font-kv-body text-lg text-kv-white outline-none resize-none placeholder:text-kv-white/50 uppercase"
></textarea>
</div>
<!-- Image URL Input (not for final round) -->
{#if !finalRound}
<div class="flex flex-col gap-2">
<label
for="q-image"
class="font-kv-body text-sm text-kv-white uppercase kv-shadow-text"
>
{m.kv_edit_image_link()}
</label>
<input
id="q-image"
type="text"
bind:value={localImageUrl}
placeholder="https://example.com/image.jpg"
class="w-full bg-kv-black/50 border-4 border-kv-black px-4 py-2 font-kv-body text-lg text-kv-white outline-none placeholder:text-kv-white/50 uppercase"
/>
</div>
<!-- Daily Double Toggle -->
<div class="flex items-center gap-4">
<KvCheckbox
checked={localIsDailyDouble}
disabled={!canToggleDD && !localIsDailyDouble}
onclick={() =>
(localIsDailyDouble = !localIsDailyDouble)}
/>
<span
class="font-kv-body text-lg text-kv-white uppercase kv-shadow-text"
>
{m.kv_edit_daily_double()}
</span>
<span class="font-kv-body text-sm text-kv-yellow">
({currentDD}/{maxDD})
</span>
</div>
{/if}
<!-- Save Button (left aligned) -->
<div class="flex justify-start">
<KvButton variant="secondary" onclick={handleSaveAndClose}>
{m.kv_settings_save_exit()}
</KvButton>
</div>
</div>
</div>
{/if}
<!-- Confirmation Dialog for unsaved changes -->
<ConfirmDialog
bind:open={showConfirmClose}
title={m.kv_confirm_close_title()}
message={m.kv_confirm_close_message()}
confirmText={m.kv_confirm_discard()}
cancelText={m.kv_settings_save_exit()}
onconfirm={handleDiscardAndClose}
oncancel={handleSaveFromConfirm}
/>

@ -0,0 +1,118 @@
<script lang="ts">
import { editorStore } from "$lib/stores/editor.svelte";
import * as m from "$lib/paraglide/messages";
interface Props {
activeRoundIndex?: number;
onQuestionClick?: (
roundIndex: number,
catIndex: number,
qIndex: number,
) => void;
}
let { activeRoundIndex = $bindable(0), onQuestionClick }: Props = $props();
// Ensure activeRoundIndex stays in bounds when rounds change
$effect(() => {
if (activeRoundIndex >= editorStore.rounds.length) {
activeRoundIndex = Math.max(0, editorStore.rounds.length - 1);
}
});
// Get round display name
function getRoundName(index: number): string {
if (index === 0) return m.kv_edit_r1();
if (index === 1) return m.kv_edit_r2();
return `Round ${index + 1}`;
}
// Handle question slot click
function handleQuestionClick(catIndex: number, qIndex: number) {
onQuestionClick?.(activeRoundIndex, catIndex, qIndex);
}
</script>
<div class="flex flex-col gap-2 md:gap-4 flex-1">
<!-- Active Round Content -->
{#each editorStore.rounds as round, ri}
{#if ri === activeRoundIndex}
<section class="flex-1 flex flex-col bg-kv-black">
<!-- Round Header -->
<div class="bg-kv-blue p-4">
<h2
class="font-kv-body text-[28px] uppercase kv-shadow-text m-0"
>
<span class="text-kv-white">{getRoundName(ri)}</span>
<span class="text-kv-yellow text-xl ml-2">
({m.kv_edit_dd_count()}
{editorStore.countDailyDoubles(ri)}/{editorStore
.settings.dailyDoublesPerRound[ri] ?? 1})
</span>
</h2>
</div>
<!-- Categories Row -->
<div
class="grid grid-cols-3 md:grid-cols-6 gap-1 md:gap-2 bg-kv-black py-2"
>
{#each round.categories as cat, ci}
<div class="bg-kv-blue py-2">
<input
type="text"
value={cat.name}
oninput={(e) =>
editorStore.updateCategoryName(
ri,
ci,
e.currentTarget.value,
)}
placeholder={m.kv_edit_category()}
class="w-full bg-transparent border-none outline-none font-kv-body text-sm text-kv-white text-center uppercase kv-shadow-text placeholder:text-kv-white/50"
/>
</div>
{/each}
</div>
<!-- Questions Grid -->
<div
class="grid grid-cols-3 md:grid-cols-6 gap-1 md:gap-2 bg-kv-black flex-1"
>
{#each { length: editorStore.settings.questionsPerCategory } as _, qi}
{#each round.categories as cat, ci}
{@const q = cat.questions[qi]}
{#if q}
<button
onclick={() => handleQuestionClick(ci, qi)}
class="bg-kv-blue flex items-center justify-center cursor-pointer border-none transition-opacity relative min-h-[60px] md:min-h-[80px]
{q.question.trim()
? 'opacity-100'
: 'opacity-50'}
{q.isDailyDouble
? 'ring-4 ring-inset ring-kv-yellow'
: ''}"
>
<span
class="font-kv-price text-kv-yellow text-2xl md:text-4xl kv-shadow-price kv-shadow-text"
>
{q.points}
</span>
<!-- Daily Double indicator: black star with yellow box -->
{#if q.isDailyDouble}
<div
class="absolute -top-0 right-1 bg-kv-yellow px-1 py-0.5 flex items-center justify-center"
>
<span class="text-kv-black text-sm"
>★</span
>
</div>
{/if}
</button>
{/if}
{/each}
{/each}
</div>
</section>
{/if}
{/each}
</div>

@ -0,0 +1,265 @@
<script lang="ts">
import { editorStore } from "$lib/stores/editor.svelte";
import { KvCheckbox, KvButton } from "$lib/components/kuldvillak/ui";
import { TeamEditor } from "./index";
import * as m from "$lib/paraglide/messages";
interface Props {
onShowRules?: () => void;
onShowHowTo?: () => void;
onEditFinalRound?: () => void;
activeRoundIndex?: number;
onRoundSelect?: (index: number) => void;
}
let {
onShowRules,
onShowHowTo,
onEditFinalRound,
activeRoundIndex = 0,
onRoundSelect,
}: Props = $props();
function setRoundCount(count: 1 | 2) {
editorStore.setRoundCount(count, m.kv_edit_r2());
}
</script>
<section class="bg-kv-blue p-2 md:p-4 overflow-x-auto">
<!-- Title Row -->
<div class="flex flex-wrap items-center justify-between gap-4 mb-4 md:mb-8">
<h2 class="kv-h3 text-kv-white m-0">{m.kv_edit_settings_teams()}</h2>
<div class="flex flex-wrap gap-2">
{#if onShowRules}
<KvButton variant="secondary" onclick={onShowRules} size="md"
>{m.kv_edit_rules()}</KvButton
>
{/if}
{#if onShowHowTo}
<KvButton variant="secondary" onclick={onShowHowTo} size="md"
>{m.kv_edit_how_to()}</KvButton
>
{/if}
</div>
</div>
<div class="flex flex-wrap gap-4 lg:gap-16 xl:gap-32">
<!-- Left: Core Settings -->
<div class="flex gap-8">
<div class="flex flex-col gap-4">
<div class="h-12 flex items-center">
<span class="kv-label text-kv-white"
>{m.kv_edit_rounds()}</span
>
</div>
<div class="h-12 flex items-center">
<span class="kv-label text-kv-white"
>{m.kv_play_timer()}</span
>
</div>
<div class="h-12 flex items-center">
<span class="kv-label text-kv-white"
>{m.kv_play_timer_reveal()}</span
>
</div>
<div class="h-12 flex items-center">
<span class="kv-label text-kv-white"
>{m.kv_edit_final_round()}</span
>
</div>
{#if editorStore.settings.numberOfRounds === 2 && onRoundSelect}
<div class="h-12 flex items-center">
<span class="kv-label text-kv-white"
>{m.kv_edit_edit_round()}</span
>
</div>
{/if}
</div>
<div class="flex flex-col gap-4">
<!-- Round count -->
<div class="h-12 flex items-center">
<KvButton
variant="toggle"
size="square"
active={editorStore.settings.numberOfRounds === 1}
onclick={() => setRoundCount(1)}>1</KvButton
>
<KvButton
variant="toggle"
size="square"
active={editorStore.settings.numberOfRounds === 2}
onclick={() => setRoundCount(2)}>2</KvButton
>
</div>
<!-- Timer -->
<div class="h-12 flex items-center gap-2">
<div
class="w-12 h-12 border-4 border-kv-black flex items-center justify-center"
>
<input
type="number"
bind:value={
editorStore.settings.defaultTimerSeconds
}
min={1}
max={60}
class="w-full h-full bg-transparent border-none outline-none font-kv-body text-xl text-kv-white text-center uppercase [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
</div>
<span
class="font-kv-body text-xl text-kv-white uppercase kv-shadow-text"
>{m.kv_play_seconds()}</span
>
</div>
<!-- Reveal time -->
<div class="h-12 flex items-center gap-2">
<div
class="w-12 h-12 border-4 border-kv-black flex items-center justify-center"
>
<input
type="number"
bind:value={
editorStore.settings.answerRevealSeconds
}
min={1}
max={60}
class="w-full h-full bg-transparent border-none outline-none font-kv-body text-xl text-kv-white text-center uppercase [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
</div>
<span
class="font-kv-body text-xl text-kv-white uppercase kv-shadow-text"
>{m.kv_play_seconds()}</span
>
</div>
<!-- Final round toggle -->
<div class="h-12 flex items-center gap-2">
<KvCheckbox
checked={editorStore.settings.enableFinalRound}
onclick={() =>
(editorStore.settings.enableFinalRound =
!editorStore.settings.enableFinalRound)}
/>
{#if editorStore.settings.enableFinalRound && onEditFinalRound}
<KvButton
variant="secondary"
size="sm"
onclick={onEditFinalRound}
>
{m.kv_edit_question()}
</KvButton>
{/if}
</div>
<!-- Round selection (only show if 2 rounds) -->
{#if editorStore.settings.numberOfRounds === 2 && onRoundSelect}
<div class="h-12 flex items-center">
<KvButton
variant="toggle"
size="hug"
active={activeRoundIndex === 0}
onclick={() => onRoundSelect(0)}
>
{m.kv_edit_r1()}
</KvButton>
<KvButton
variant="toggle"
size="hug"
active={activeRoundIndex === 1}
onclick={() => onRoundSelect(1)}
>
{m.kv_edit_r2()}
</KvButton>
</div>
{/if}
</div>
</div>
<!-- Middle: Points & Teams -->
<div class="flex gap-8">
<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"
>{m.kv_edit_points()}</span
>
</div>
<div class="h-12 flex items-center">
<span
class="font-kv-body text-xl text-kv-white uppercase kv-shadow-text"
>{m.kv_edit_values()}</span
>
</div>
<div class="h-12 flex items-center">
<span
class="font-kv-body text-xl text-kv-white uppercase kv-shadow-text"
>{m.kv_edit_negative_scores()}</span
>
</div>
<div class="h-12 flex items-center">
<span
class="font-kv-body text-xl text-kv-white uppercase kv-shadow-text"
>{m.kv_edit_teams_label()}</span
>
</div>
</div>
<div class="flex flex-col gap-4">
<!-- Point preset -->
<div class="h-12 flex items-center">
<KvButton
variant="toggle"
size="hug"
active={editorStore.settings.pointValuePreset ===
"round1"}
onclick={() => editorStore.updatePreset("round1")}
>{m.kv_edit_preset_normal()}</KvButton
>
<KvButton
variant="toggle"
size="hug"
active={editorStore.settings.pointValuePreset ===
"custom"}
onclick={() => editorStore.updatePreset("custom")}
>{m.kv_edit_custom()}</KvButton
>
</div>
<!-- Point values -->
<div class="h-12 flex items-center">
{#each editorStore.settings.pointValues as val, i}
<div
class="w-12 h-12 border-4 border-kv-black flex items-center justify-center"
>
{#if editorStore.settings.pointValuePreset === "custom"}
<input
type="number"
bind:value={
editorStore.settings.pointValues[i]
}
onchange={() =>
editorStore.updateQuestionPoints()}
min={1}
max={9999}
class="w-full h-full bg-transparent border-none outline-none font-kv-body text-lg text-kv-white text-center [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
{:else}
<span
class="font-kv-body text-lg text-kv-white/50"
>{val}</span
>
{/if}
</div>
{/each}
</div>
<!-- Negative scores -->
<div class="h-12 flex items-center">
<KvCheckbox
checked={editorStore.settings.allowNegativeScores}
onclick={() =>
(editorStore.settings.allowNegativeScores =
!editorStore.settings.allowNegativeScores)}
/>
</div>
<!-- Teams -->
<TeamEditor />
</div>
</div>
</div>
</section>

@ -0,0 +1,53 @@
<script lang="ts">
import { editorStore } from "$lib/stores/editor.svelte";
import { KvButton } from "$lib/components/kuldvillak/ui";
import * as m from "$lib/paraglide/messages";
// Icons as components for cleaner markup
const RemoveIcon = `<path d="M6.6188 19.4811L4.5188 17.3811L9.9188 11.9811L4.5188 6.61855L6.6188 4.51855L12.0188 9.91855L17.3813 4.51855L19.4813 6.61855L14.0813 11.9811L19.4813 17.3811L17.3813 19.4811L12.0188 14.0811L6.6188 19.4811Z"/>`;
const AddIcon = `<path d="M22.2857 25.7143H12V22.2857H22.2857V12H25.7143V22.2857H36V25.7143H25.7143V36H22.2857V25.7143Z"/>`;
</script>
<div class="flex items-center gap-2 flex-wrap">
{#each editorStore.teams as team (team.id)}
<div class="flex items-center h-12 border-4 border-kv-black px-2">
<input
type="text"
value={team.name}
oninput={(e) =>
editorStore.updateTeamName(team.id, e.currentTarget.value)}
class="bg-transparent border-none outline-none font-kv-body text-xl text-kv-text uppercase min-w-[80px] max-w-[120px] kv-shadow-text"
/>
{#if editorStore.canRemoveTeam}
<KvButton
variant="icon"
size="icon"
onclick={() => editorStore.removeTeam(team.id)}
ariaLabel={m.kv_edit_remove_team()}
class="w-8 h-8"
>
<svg
viewBox="0 0 24 24"
fill="currentColor"
class="w-6 h-6"
>
{@html RemoveIcon}
</svg>
</KvButton>
{/if}
</div>
{/each}
{#if editorStore.canAddTeam}
<KvButton
variant="icon"
size="icon"
onclick={() => editorStore.addTeam()}
ariaLabel={m.kv_edit_add_team()}
>
<svg viewBox="0 0 48 48" fill="currentColor" class="w-8 h-8">
{@html AddIcon}
</svg>
</KvButton>
{/if}
</div>

@ -0,0 +1,6 @@
// Editor Components
export { default as TeamEditor } from './TeamEditor.svelte';
export { default as RoundEditor } from './RoundEditor.svelte';
export { default as EditorHeader } from './EditorHeader.svelte';
export { default as QuestionEditModal } from './QuestionEditModal.svelte';
export { default as SettingsPanel } from './SettingsPanel.svelte';

@ -3,9 +3,13 @@ export { default as Slider } from './Slider.svelte';
export { default as Settings } from './Settings.svelte'; export { default as Settings } from './Settings.svelte';
export { default as LanguageSwitcher } from './LanguageSwitcher.svelte'; export { default as LanguageSwitcher } from './LanguageSwitcher.svelte';
export { default as Toast } from './Toast.svelte'; export { default as Toast } from './Toast.svelte';
export { default as ToastContainer } from './ToastContainer.svelte';
export { default as ConfirmDialog } from './ConfirmDialog.svelte'; export { default as ConfirmDialog } from './ConfirmDialog.svelte';
export { default as ColorPicker } from './ColorPicker.svelte'; export { default as ColorPicker } from './ColorPicker.svelte';
export { default as ErrorBoundary } from './ErrorBoundary.svelte'; export { default as ErrorBoundary } from './ErrorBoundary.svelte';
// Kuldvillak Components // Kuldvillak Components
export * from './kuldvillak'; export * from './kuldvillak';
// Editor Components
export * from './editor';

@ -0,0 +1,101 @@
<script lang="ts">
import type { Snippet } from "svelte";
type Variant =
| "primary"
| "secondary"
| "success"
| "danger"
| "warning"
| "ghost"
| "toggle"
| "icon";
type Size = "sm" | "md" | "lg" | "icon" | "square" | "hug";
interface Props {
variant?: Variant;
size?: Size;
disabled?: boolean;
active?: boolean;
onclick?: () => void;
children: Snippet;
class?: string;
ariaLabel?: string;
type?: "button" | "submit" | "reset";
href?: string;
reload?: boolean;
}
let {
variant = "primary",
size = "md",
disabled = false,
active = false,
onclick,
children,
class: className = "",
ariaLabel,
type = "button",
href,
reload = false,
}: Props = $props();
const baseClasses =
"inline-flex items-center justify-center border-4 border-black box-border cursor-pointer transition-all hover:opacity-80 disabled:opacity-50 disabled:cursor-not-allowed";
const variantClasses: Record<Variant, string> = {
primary: "bg-kv-blue text-kv-white kv-shadow-button",
secondary: "bg-kv-yellow text-black kv-shadow-button",
success: "bg-kv-green text-kv-white",
danger: "bg-kv-red text-kv-white",
warning: "bg-kv-yellow text-black",
ghost: "bg-transparent text-kv-white",
toggle: "bg-transparent",
icon: "bg-transparent border-none p-0",
};
// Use -plain variants (no text shadow on buttons)
const sizeClasses: Record<Size, string> = {
sm: "px-3 py-1 kv-body-sm-plain",
md: "px-4 py-2 kv-body-lg-plain",
lg: "px-6 py-4 kv-h3-plain",
icon: "w-10 h-10",
square: "w-12 h-12 font-kv-body text-xl uppercase",
hug: "px-4 h-12 font-kv-body text-xl uppercase",
};
// For toggle variant, apply active state colors (must be reactive)
let activeClass = $derived(
variant === "toggle" && active ? "text-kv-yellow" : "",
);
let inactiveClass = $derived(
variant === "toggle" && !active ? "text-kv-white" : "",
);
let iconColorClass = $derived(variant === "icon" ? "text-kv-yellow" : "");
let combinedClasses = $derived(
`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${activeClass} ${inactiveClass} ${iconColorClass} ${className}`,
);
</script>
{#if href}
<a
{href}
class={combinedClasses}
aria-label={ariaLabel}
data-sveltekit-reload={reload || undefined}
>
{@render children()}
</a>
{:else}
<button
{type}
class={combinedClasses}
{disabled}
{onclick}
aria-label={ariaLabel}
aria-disabled={disabled}
>
{@render children()}
</button>
{/if}

@ -1,48 +0,0 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
href?: string;
disabled?: boolean;
onclick?: () => void;
children: Snippet;
class?: string;
reload?: boolean;
ariaLabel?: string;
}
let {
href,
disabled = false,
onclick,
children,
class: className = "",
reload = false,
ariaLabel,
}: Props = $props();
const baseClasses =
"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 focus:outline-none focus:ring-2 focus:ring-kv-yellow focus:ring-offset-2 focus:ring-offset-black";
</script>
{#if href && !disabled}
<a
{href}
class="{baseClasses} {className}"
data-sveltekit-reload={reload ? "" : undefined}
aria-label={ariaLabel}
role="button"
>
<span class="kv-shadow-text">{@render children()}</span>
</a>
{:else}
<button
class="{baseClasses} {className}"
{disabled}
{onclick}
aria-label={ariaLabel}
aria-disabled={disabled}
>
<span class="kv-shadow-text">{@render children()}</span>
</button>
{/if}

@ -1,48 +0,0 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
href?: string;
disabled?: boolean;
onclick?: () => void;
children: Snippet;
class?: string;
reload?: boolean;
ariaLabel?: string;
}
let {
href,
disabled = false,
onclick,
children,
class: className = "",
reload = false,
ariaLabel,
}: Props = $props();
const baseClasses =
"inline-flex items-center justify-center px-6 py-4 bg-kv-yellow text-black kv-btn-text cursor-pointer transition-opacity hover:opacity-80 disabled:opacity-50 disabled:cursor-not-allowed kv-shadow-button border-4 border-black box-border focus:outline-none focus:ring-2 focus:ring-kv-blue focus:ring-offset-2 focus:ring-offset-black";
</script>
{#if href && !disabled}
<a
{href}
class="{baseClasses} {className}"
data-sveltekit-reload={reload ? "" : undefined}
aria-label={ariaLabel}
role="button"
>
<span class="kv-shadow-text">{@render children()}</span>
</a>
{:else}
<button
class="{baseClasses} {className}"
{disabled}
{onclick}
aria-label={ariaLabel}
aria-disabled={disabled}
>
<span class="kv-shadow-text">{@render children()}</span>
</button>
{/if}

@ -0,0 +1,38 @@
<script lang="ts">
interface Props {
checked: boolean;
onclick: () => void;
disabled?: boolean;
class?: string;
}
let {
checked,
onclick,
disabled = false,
class: className = "",
}: Props = $props();
</script>
<button
{onclick}
{disabled}
class="w-8 h-8 cursor-pointer p-0 flex items-center justify-center border-4 border-black disabled:opacity-50 disabled:cursor-not-allowed {checked
? 'bg-kv-yellow'
: 'bg-white'} {className}"
>
{#if checked}
<svg width="18" height="15" viewBox="0 0 18 15" fill="none">
<path
d="M6 14.1L0 8.1L2.1 6L6 9.9L15.9 0L18 2.1L6 14.1Z"
fill="black"
/>
</svg>
{:else}
<svg viewBox="0 0 24 24" fill="black" class="w-4 h-4">
<path
d="M6.6188 19.4811L4.5188 17.3811L9.9188 11.9811L4.5188 6.61855L6.6188 4.51855L12.0188 9.91855L17.3813 4.51855L19.4813 6.61855L14.0813 11.9811L19.4813 17.3811L17.3813 19.4811L12.0188 14.0811L6.6188 19.4811Z"
/>
</svg>
{/if}
</button>

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import * as m from "$lib/paraglide/messages"; import * as m from "$lib/paraglide/messages";
import KvNumberInput from "./KvNumberInput.svelte"; import KvNumberInput from "./KvNumberInput.svelte";
import KvButton from "./KvButton.svelte";
interface Props { interface Props {
name: string; name: string;
@ -92,26 +93,20 @@
<!-- Final round judging UI --> <!-- Final round judging UI -->
{#if finalAnswerCorrect === null} {#if finalAnswerCorrect === null}
<div class="flex gap-2"> <div class="flex gap-2">
<button <KvButton
variant="success"
size="md"
onclick={() => (finalAnswerCorrect = true)} 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()} {m.kv_play_correct()}
</span> </KvButton>
</button> <KvButton
<button variant="danger"
size="md"
onclick={() => (finalAnswerCorrect = false)} 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()} {m.kv_play_wrong()}
</span> </KvButton>
</button>
</div> </div>
{:else} {:else}
<!-- Wager input and confirm --> <!-- Wager input and confirm -->
@ -124,88 +119,47 @@
/> />
<span class="font-kv-body text-lg text-kv-white"></span> <span class="font-kv-body text-lg text-kv-white"></span>
</div> </div>
<button <KvButton
variant="secondary"
size="sm"
onclick={() => { onclick={() => {
onFinalJudge?.(finalAnswerCorrect!, finalWagerInput); onFinalJudge?.(finalAnswerCorrect!, finalWagerInput);
finalAnswerCorrect = null; finalAnswerCorrect = null;
finalWagerInput = 0; 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()} {m.kv_play_confirm()}
</button> </KvButton>
{/if} {/if}
{:else if finalMode && !finalJudged && !finalActive} {:else if finalMode && !finalJudged && !finalActive}
<!-- Final mode but not active - show score adjustment buttons --> <!-- Final mode but not active - show score adjustment buttons -->
<div class="flex gap-4"> <div class="flex gap-4">
<button <KvButton variant="success" size="md" onclick={onAdd}>
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} +{scoreAdjustment}
</span> </KvButton>
</button> <KvButton variant="danger" size="md" onclick={onRemove}>
<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} -{scoreAdjustment}
</span> </KvButton>
</button>
</div> </div>
{:else if answering} {:else if answering}
<!-- Correct/Wrong buttons when answering --> <!-- Correct/Wrong buttons when answering -->
<div class="flex gap-4"> <div class="flex gap-4">
<button <KvButton variant="success" size="md" onclick={onCorrect}>
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()} {m.kv_play_correct()}
</span> </KvButton>
</button> <KvButton variant="danger" size="md" onclick={onWrong}>
<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()} {m.kv_play_wrong()}
</span> </KvButton>
</button>
</div> </div>
{:else if !finalMode} {:else if !finalMode}
<!-- Add/Remove score buttons in normal mode --> <!-- Add/Remove score buttons in normal mode -->
<div class="flex gap-4"> <div class="flex gap-4">
<button <KvButton variant="success" size="md" onclick={onAdd}>
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} +{scoreAdjustment}
</span> </KvButton>
</button> <KvButton variant="danger" size="md" onclick={onRemove}>
<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} -{scoreAdjustment}
</span> </KvButton>
</button>
</div> </div>
{/if} {/if}
</div> </div>

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import * as m from "$lib/paraglide/messages"; import * as m from "$lib/paraglide/messages";
import { trapFocus } from "$lib/utils/focusTrap";
export interface TutorialSlide { export interface TutorialSlide {
image: string; image: string;
@ -70,6 +71,7 @@
tabindex="-1" tabindex="-1"
onclick={(e) => e.stopPropagation()} onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}
use:trapFocus
> >
<!-- Header --> <!-- Header -->
<div class="flex items-start justify-between gap-4"> <div class="flex items-start justify-between gap-4">

@ -1,11 +1,11 @@
// Kuldvillak UI Components // Kuldvillak UI Components
// Buttons // Buttons
export { default as KvButtonPrimary } from './KvButtonPrimary.svelte'; export { default as KvButton } from './KvButton.svelte';
export { default as KvButtonSecondary } from './KvButtonSecondary.svelte';
// Form Controls // Form Controls
export { default as KvNumberInput } from './KvNumberInput.svelte'; export { default as KvNumberInput } from './KvNumberInput.svelte';
export { default as KvCheckbox } from './KvCheckbox.svelte';
// Cards // Cards
export { default as KvEditCard } from './KvEditCard.svelte'; export { default as KvEditCard } from './KvEditCard.svelte';

@ -14,6 +14,12 @@ export { themeStore } from './stores/theme.svelte';
// Audio Store // Audio Store
export { audioStore } from './stores/audio.svelte'; export { audioStore } from './stores/audio.svelte';
// Toast Store
export { toastStore } from './stores/toast.svelte';
// Editor Store
export { editorStore } from './stores/editor.svelte';
// Persistence (Save/Load) // Persistence (Save/Load)
export * from './stores/persistence'; export * from './stores/persistence';

@ -0,0 +1,8 @@
// ============================================
// Services Index
// Export storage service and keys
// ============================================
export type { StorageService } from './storage';
export { STORAGE_KEYS } from './storage';
export { localStorageService as storage } from './localStorage';

@ -0,0 +1,116 @@
// ============================================
// LocalStorage Service Implementation
// Browser localStorage backend for StorageService
// ============================================
import { browser } from '$app/environment';
import type { StorageService } from './storage';
/**
* LocalStorage implementation of StorageService.
* Handles JSON serialization/deserialization automatically.
* Safe for SSR - returns null/no-op when not in browser.
*/
class LocalStorageService implements StorageService {
/**
* Get a value from localStorage
* @param key Storage key
* @returns Parsed value or null if not found/invalid
*/
get<T>(key: string): T | null {
if (!browser) return null;
try {
const item = localStorage.getItem(key);
if (item === null) return null;
return JSON.parse(item) as T;
} catch (error) {
console.warn(`[Storage] Failed to parse key "${key}":`, error);
return null;
}
}
/**
* Set a value in localStorage
* @param key Storage key
* @param value Value to store (will be JSON serialized)
*/
set<T>(key: string, value: T): void {
if (!browser) return;
try {
const serialized = JSON.stringify(value);
localStorage.setItem(key, serialized);
} catch (error) {
console.error(`[Storage] Failed to set key "${key}":`, error);
}
}
/**
* Remove a value from localStorage
* @param key Storage key
*/
remove(key: string): void {
if (!browser) return;
localStorage.removeItem(key);
}
/**
* Check if a key exists in localStorage
* @param key Storage key
*/
has(key: string): boolean {
if (!browser) return false;
return localStorage.getItem(key) !== null;
}
/**
* Clear all localStorage data
* Use with caution - affects all stored data
*/
clear(): void {
if (!browser) return;
localStorage.clear();
}
/**
* Get raw string value without JSON parsing
* Useful for checking data format or migration
*/
getRaw(key: string): string | null {
if (!browser) return null;
return localStorage.getItem(key);
}
/**
* Set raw string value without JSON serialization
* Useful for storing pre-serialized data
*/
setRaw(key: string, value: string): void {
if (!browser) return;
localStorage.setItem(key, value);
}
/**
* List all keys matching a prefix
* @param prefix Key prefix to filter by
* @returns Array of matching keys
*/
keys(prefix?: string): string[] {
if (!browser) return [];
const allKeys: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key) {
if (!prefix || key.startsWith(prefix)) {
allKeys.push(key);
}
}
}
return allKeys;
}
}
// Export singleton instance
export const localStorageService = new LocalStorageService();

@ -0,0 +1,56 @@
// ============================================
// Storage Service Interface
// Abstract interface for data persistence
// ============================================
/**
* Abstract storage interface for data persistence.
* Allows swapping between localStorage, IndexedDB, or API backends.
*/
export interface StorageService {
/**
* Get a value from storage
* @param key Storage key
* @returns The stored value or null if not found
*/
get<T>(key: string): T | null;
/**
* Set a value in storage
* @param key Storage key
* @param value Value to store (will be serialized)
*/
set<T>(key: string, value: T): void;
/**
* Remove a value from storage
* @param key Storage key
*/
remove(key: string): void;
/**
* Check if a key exists in storage
* @param key Storage key
*/
has(key: string): boolean;
/**
* Clear all stored data (use with caution)
*/
clear(): void;
}
/**
* Storage keys used throughout the application
* Centralized to prevent typos and enable easy refactoring
*/
export const STORAGE_KEYS = {
EDITOR_AUTOSAVE: 'kuldvillak-editor-autosave',
GAMES_LIST: 'kuldvillak-games',
GAME_PREFIX: 'kuldvillak-game-',
GAME_SESSION: 'kuldvillak-game-session',
THEME: 'kuldvillak-theme',
AUDIO: 'kuldvillak-audio',
} as const;
export type StorageKey = typeof STORAGE_KEYS[keyof typeof STORAGE_KEYS];

@ -0,0 +1,448 @@
// ============================================
// Editor Store - Game Editor State Management
// ============================================
import { goto } from "$app/navigation";
import { gameSession } from "./gameSession.svelte";
import { storage, STORAGE_KEYS } from "$lib/services";
import { validateGameData, type GameData } from "$lib/utils/validation";
import type {
GameSettings,
Team,
Round,
Category,
Question,
FinalRound,
PointValuePreset,
} from "$lib/types/kuldvillak";
import { DEFAULT_SETTINGS } from "$lib/types/kuldvillak";
// ============================================
// Helper Functions (Pure, no state dependency)
// ============================================
function generateId(): string {
return crypto.randomUUID();
}
function createQuestion(points: number): Question {
return {
id: generateId(),
question: "",
answer: "",
points,
isDailyDouble: false,
isRevealed: false,
};
}
function createCategory(settings: GameSettings, multiplier: number = 1): Category {
return {
id: generateId(),
name: "",
questions: settings.pointValues.map((p) => createQuestion(p * multiplier)),
};
}
function createRound(name: string, multiplier: number, settings: GameSettings): Round {
return {
id: generateId(),
name,
categories: Array.from({ length: settings.categoriesPerRound }, () =>
createCategory(settings, multiplier)
),
pointMultiplier: multiplier,
};
}
// ============================================
// Editor Store Class
// ============================================
class EditorStore {
// Core game data
gameName = $state("");
settings = $state<GameSettings>({
...DEFAULT_SETTINGS,
defaultTimerSeconds: 5,
answerRevealSeconds: 5,
});
teams = $state<Team[]>([
{ id: generateId(), name: "Mängija 1", score: 0 },
{ id: generateId(), name: "Mängija 2", score: 0 },
]);
rounds = $state<Round[]>([]);
finalRound = $state<FinalRound>({
category: "",
question: "",
answer: "",
});
// UI state
isStarting = $state(false);
private initialized = false;
constructor() {
// Initialize rounds with default names (will be set properly on load)
this.rounds = [
createRound("Villak", 1, DEFAULT_SETTINGS),
createRound("Topeltvillak", 2, DEFAULT_SETTINGS),
];
}
// ============================================
// Initialization & Persistence
// ============================================
/**
* Load saved data from localStorage
* Call this from onMount in the component
*/
load() {
if (this.initialized) return;
this.initialized = true;
const saved = storage.get<GameData>(STORAGE_KEYS.EDITOR_AUTOSAVE);
if (saved) {
const result = validateGameData(saved);
if (result.success) {
const data = result.data;
this.gameName = data.name || "";
this.settings = {
...DEFAULT_SETTINGS,
...data.settings,
};
this.teams = data.teams;
this.rounds = data.rounds;
this.finalRound = data.finalRound || {
category: "",
question: "",
answer: "",
};
}
}
}
/**
* Save current state to localStorage
*/
save() {
const data: GameData = {
name: this.gameName,
settings: $state.snapshot(this.settings),
teams: $state.snapshot(this.teams),
rounds: $state.snapshot(this.rounds),
finalRound: $state.snapshot(this.finalRound),
};
storage.set(STORAGE_KEYS.EDITOR_AUTOSAVE, data);
}
/**
* Clear saved data from localStorage
*/
clearSaved() {
storage.remove(STORAGE_KEYS.EDITOR_AUTOSAVE);
}
// ============================================
// Validation
// ============================================
/**
* Validate game configuration
* @returns Error message string or null if valid
*/
validateGame(): string | null {
if (this.teams.length < 2) {
return "At least 2 players required";
}
for (let i = 0; i < this.rounds.length; i++) {
const hasQuestions = this.rounds[i].categories.some((cat) =>
cat.questions.some((q) => q.question.trim())
);
if (!hasQuestions) {
return `Round ${i + 1} needs at least one question`;
}
}
if (this.settings.enableFinalRound && !this.finalRound.question.trim()) {
return "Final round question is required";
}
return null;
}
// ============================================
// Game Actions
// ============================================
/**
* Start the game - validates, initializes game session, navigates to play
* @returns Object with success status and optional error message
*/
async startGame(): Promise<{ success: boolean; error?: string }> {
const error = this.validateGame();
if (error) {
return { success: false, error };
}
this.isStarting = true;
try {
gameSession.startGame({
name: this.gameName,
settings: $state.snapshot(this.settings),
teams: $state.snapshot(this.teams),
rounds: $state.snapshot(this.rounds),
finalRound: this.settings.enableFinalRound
? $state.snapshot(this.finalRound)
: null,
});
gameSession.openProjector();
await goto("/kuldvillak/play");
return { success: true };
} catch (err) {
this.isStarting = false;
return { success: false, error: String(err) };
}
}
/**
* Export game to JSON file for download
*/
exportGame(): { blob: Blob; filename: string } {
const game = {
name: this.gameName,
settings: $state.snapshot(this.settings),
teams: $state.snapshot(this.teams),
rounds: $state.snapshot(this.rounds),
finalRound: $state.snapshot(this.finalRound),
};
const blob = new Blob([JSON.stringify(game, null, 2)], {
type: "application/json",
});
const filename = `${this.gameName.replace(/\s+/g, "_") || "game"}.json`;
return { blob, filename };
}
/**
* Import game from JSON data
* @returns true if successful, error message if failed
*/
importGame(data: unknown): true | string {
const result = validateGameData(data);
if (!result.success) {
return result.error;
}
const game = result.data;
this.gameName = game.name || "Loaded Game";
this.settings = {
...DEFAULT_SETTINGS,
...game.settings,
};
this.teams = game.teams;
this.rounds = game.rounds;
this.finalRound = game.finalRound || {
category: "",
question: "",
answer: "",
};
return true;
}
/**
* Reset to default state
*/
reset(round1Name: string, round2Name: string) {
this.settings = {
...DEFAULT_SETTINGS,
defaultTimerSeconds: 5,
answerRevealSeconds: 5,
};
this.teams = [
{ id: generateId(), name: "Mängija 1", score: 0 },
{ id: generateId(), name: "Mängija 2", score: 0 },
];
this.rounds = [
createRound(round1Name, 1, DEFAULT_SETTINGS),
createRound(round2Name, 2, DEFAULT_SETTINGS),
];
this.finalRound = { category: "", question: "", answer: "" };
this.gameName = "";
this.clearSaved();
}
// ============================================
// Team Management
// ============================================
addTeam() {
if (this.teams.length >= 6) return;
this.teams = [
...this.teams,
{ id: generateId(), name: `Mängija ${this.teams.length + 1}`, score: 0 },
];
}
removeTeam(id: string) {
if (this.teams.length <= 2) return;
this.teams = this.teams.filter((t) => t.id !== id);
}
updateTeamName(id: string, name: string) {
const team = this.teams.find((t) => t.id === id);
if (team) {
team.name = name;
}
}
// ============================================
// Round Management
// ============================================
setRoundCount(count: 1 | 2, round2Name: string) {
this.settings.numberOfRounds = count;
if (count === 1 && this.rounds.length > 1) {
this.rounds = [this.rounds[0]];
this.settings.dailyDoublesPerRound = [this.settings.dailyDoublesPerRound[0]];
} else if (count === 2 && this.rounds.length === 1) {
this.rounds = [...this.rounds, createRound(round2Name, 2, this.settings)];
this.settings.dailyDoublesPerRound = [
this.settings.dailyDoublesPerRound[0],
2,
];
}
this.updateQuestionPoints();
}
getPointsForRound(preset: PointValuePreset, roundIndex: number): number[] {
const base = [10, 20, 30, 40, 50];
const multiplier = roundIndex + 1;
if (preset === "round1") return base.map((v) => v * multiplier);
return this.settings.pointValues.map((v) => v * multiplier);
}
updatePreset(preset: PointValuePreset) {
this.settings.pointValuePreset = preset;
if (preset === "round1") {
this.settings.pointValues = [10, 20, 30, 40, 50];
}
this.updateQuestionPoints();
}
updateQuestionPoints() {
this.rounds.forEach((round, ri) => {
const points = this.getPointsForRound(this.settings.pointValuePreset, ri);
round.categories.forEach((cat) =>
cat.questions.forEach((q, i) => {
q.points = points[i] ?? q.points;
})
);
});
this.rounds = [...this.rounds];
}
// ============================================
// Category Management
// ============================================
updateCategoryName(roundIndex: number, catIndex: number, name: string) {
if (this.rounds[roundIndex]?.categories[catIndex]) {
this.rounds[roundIndex].categories[catIndex].name = name;
}
}
// ============================================
// Question Management
// ============================================
getQuestion(roundIndex: number, catIndex: number, qIndex: number): Question | null {
return this.rounds[roundIndex]?.categories[catIndex]?.questions[qIndex] ?? null;
}
updateQuestion(
roundIndex: number,
catIndex: number,
qIndex: number,
updates: Partial<Pick<Question, "question" | "answer" | "imageUrl" | "isDailyDouble">>
) {
const q = this.getQuestion(roundIndex, catIndex, qIndex);
if (q) {
Object.assign(q, updates);
this.rounds = [...this.rounds];
}
}
countDailyDoubles(roundIndex: number): number {
return this.rounds[roundIndex]?.categories.reduce(
(sum, cat) => sum + cat.questions.filter((q) => q.isDailyDouble).length,
0
) ?? 0;
}
toggleDailyDouble(roundIndex: number, catIndex: number, qIndex: number): boolean {
const q = this.getQuestion(roundIndex, catIndex, qIndex);
if (!q) return false;
const maxDD = this.settings.dailyDoublesPerRound[roundIndex] ?? 1;
const currentCount = this.countDailyDoubles(roundIndex);
if (q.isDailyDouble) {
q.isDailyDouble = false;
this.rounds = [...this.rounds];
return true;
} else if (currentCount < maxDD) {
q.isDailyDouble = true;
this.rounds = [...this.rounds];
return true;
}
return false;
}
// ============================================
// Final Round Management
// ============================================
updateFinalRound(updates: Partial<FinalRound>) {
this.finalRound = { ...this.finalRound, ...updates };
}
// ============================================
// Derived State
// ============================================
get canAddTeam(): boolean {
return this.teams.length < 6;
}
get canRemoveTeam(): boolean {
return this.teams.length > 2;
}
get totalQuestions(): number {
return this.rounds.reduce(
(total, round) =>
total + round.categories.reduce(
(catTotal, cat) => catTotal + cat.questions.length,
0
),
0
);
}
get filledQuestions(): number {
return this.rounds.reduce(
(total, round) =>
total + round.categories.reduce(
(catTotal, cat) =>
catTotal + cat.questions.filter((q) => q.question.trim()).length,
0
),
0
);
}
}
export const editorStore = new EditorStore();

@ -0,0 +1,591 @@
/**
* EditorStore Unit Tests
*
* Testing approach: Since EditorStore uses Svelte 5 runes ($state) which require
* Svelte compilation, we test the logic by:
* 1. Extracting and testing pure validation/helper functions
* 2. Mocking localStorage for persistence tests
* 3. Testing the store's public API through a mock implementation
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type { GameSettings, Team, Round, FinalRound, Question, Category } from '$lib/types/kuldvillak';
import { DEFAULT_SETTINGS } from '$lib/types/kuldvillak';
// ============================================
// Test Helpers - Replicate pure functions from store
// ============================================
function generateId(): string {
return crypto.randomUUID();
}
function createQuestion(points: number): Question {
return {
id: generateId(),
question: "",
answer: "",
points,
isDailyDouble: false,
isRevealed: false,
};
}
function createCategory(settings: GameSettings, multiplier: number = 1): Category {
return {
id: generateId(),
name: "",
questions: settings.pointValues.map((p) => createQuestion(p * multiplier)),
};
}
function createRound(name: string, multiplier: number, settings: GameSettings): Round {
return {
id: generateId(),
name,
categories: Array.from({ length: settings.categoriesPerRound }, () =>
createCategory(settings, multiplier)
),
pointMultiplier: multiplier,
};
}
// Validation function extracted for testing
function validateGame(
teams: Team[],
rounds: Round[],
settings: GameSettings,
finalRound: FinalRound
): string | null {
if (teams.length < 2) {
return "At least 2 players required";
}
for (let i = 0; i < rounds.length; i++) {
const hasQuestions = rounds[i].categories.some((cat) =>
cat.questions.some((q) => q.question.trim())
);
if (!hasQuestions) {
return `Round ${i + 1} needs at least one question`;
}
}
if (settings.enableFinalRound && !finalRound.question.trim()) {
return "Final round question is required";
}
return null;
}
// Count daily doubles helper
function countDailyDoubles(rounds: Round[], roundIndex: number): number {
return rounds[roundIndex]?.categories.reduce(
(sum, cat) => sum + cat.questions.filter((q) => q.isDailyDouble).length,
0
) ?? 0;
}
// ============================================
// Mock localStorage
// ============================================
const AUTOSAVE_KEY = "kuldvillak-editor-autosave";
function createMockLocalStorage() {
let store: Record<string, string> = {};
return {
getItem: vi.fn((key: string) => store[key] ?? null),
setItem: vi.fn((key: string, value: string) => { store[key] = value; }),
removeItem: vi.fn((key: string) => { delete store[key]; }),
clear: vi.fn(() => { store = {}; }),
get _store() { return store; },
};
}
// ============================================
// Tests
// ============================================
describe('EditorStore Logic', () => {
let mockLocalStorage: ReturnType<typeof createMockLocalStorage>;
beforeEach(() => {
mockLocalStorage = createMockLocalStorage();
vi.stubGlobal('localStorage', mockLocalStorage);
});
afterEach(() => {
vi.unstubAllGlobals();
});
// ============================================
// Helper Function Tests
// ============================================
describe('createQuestion', () => {
it('should create a question with correct structure', () => {
const q = createQuestion(100);
expect(q.points).toBe(100);
expect(q.question).toBe("");
expect(q.answer).toBe("");
expect(q.isDailyDouble).toBe(false);
expect(q.isRevealed).toBe(false);
expect(q.id).toBeDefined();
});
it('should generate unique IDs', () => {
const q1 = createQuestion(100);
const q2 = createQuestion(100);
expect(q1.id).not.toBe(q2.id);
});
});
describe('createCategory', () => {
it('should create category with correct number of questions', () => {
const cat = createCategory(DEFAULT_SETTINGS, 1);
expect(cat.questions.length).toBe(DEFAULT_SETTINGS.pointValues.length);
});
it('should apply multiplier to point values', () => {
const cat = createCategory(DEFAULT_SETTINGS, 2);
const expectedPoints = DEFAULT_SETTINGS.pointValues.map(p => p * 2);
cat.questions.forEach((q, i) => {
expect(q.points).toBe(expectedPoints[i]);
});
});
});
describe('createRound', () => {
it('should create round with correct structure', () => {
const round = createRound("Test Round", 1, DEFAULT_SETTINGS);
expect(round.name).toBe("Test Round");
expect(round.pointMultiplier).toBe(1);
expect(round.categories.length).toBe(DEFAULT_SETTINGS.categoriesPerRound);
});
it('should apply multiplier to all questions', () => {
const round = createRound("Double", 2, DEFAULT_SETTINGS);
round.categories.forEach(cat => {
cat.questions.forEach((q, i) => {
expect(q.points).toBe(DEFAULT_SETTINGS.pointValues[i] * 2);
});
});
});
});
// ============================================
// Validation Tests
// ============================================
describe('validateGame', () => {
let validTeams: Team[];
let validRounds: Round[];
let validSettings: GameSettings;
let validFinalRound: FinalRound;
beforeEach(() => {
validTeams = [
{ id: '1', name: 'Team 1', score: 0 },
{ id: '2', name: 'Team 2', score: 0 },
];
validRounds = [createRound("Round 1", 1, DEFAULT_SETTINGS)];
// Fill at least one question
validRounds[0].categories[0].questions[0].question = "Test question?";
validSettings = { ...DEFAULT_SETTINGS, enableFinalRound: false };
validFinalRound = { category: "", question: "", answer: "" };
});
it('should pass with valid minimal configuration', () => {
const result = validateGame(validTeams, validRounds, validSettings, validFinalRound);
expect(result).toBeNull();
});
it('should fail with fewer than 2 teams', () => {
const result = validateGame([validTeams[0]], validRounds, validSettings, validFinalRound);
expect(result).toBe("At least 2 players required");
});
it('should fail with 0 teams', () => {
const result = validateGame([], validRounds, validSettings, validFinalRound);
expect(result).toBe("At least 2 players required");
});
it('should fail when round has no questions', () => {
const emptyRounds = [createRound("Empty", 1, DEFAULT_SETTINGS)];
const result = validateGame(validTeams, emptyRounds, validSettings, validFinalRound);
expect(result).toBe("Round 1 needs at least one question");
});
it('should fail when second round has no questions', () => {
const twoRounds = [
validRounds[0],
createRound("Round 2", 2, DEFAULT_SETTINGS), // No questions filled
];
const result = validateGame(validTeams, twoRounds, validSettings, validFinalRound);
expect(result).toBe("Round 2 needs at least one question");
});
it('should fail when final round enabled but no question', () => {
const settingsWithFinal = { ...validSettings, enableFinalRound: true };
const result = validateGame(validTeams, validRounds, settingsWithFinal, validFinalRound);
expect(result).toBe("Final round question is required");
});
it('should pass when final round enabled and has question', () => {
const settingsWithFinal = { ...validSettings, enableFinalRound: true };
const finalWithQuestion = { category: "History", question: "What year?", answer: "1990" };
const result = validateGame(validTeams, validRounds, settingsWithFinal, finalWithQuestion);
expect(result).toBeNull();
});
it('should pass with whitespace-only question as empty', () => {
validRounds[0].categories[0].questions[0].question = " ";
const result = validateGame(validTeams, validRounds, validSettings, validFinalRound);
expect(result).toBe("Round 1 needs at least one question");
});
});
// ============================================
// Team Management Tests
// ============================================
describe('Team Management Logic', () => {
it('canAddTeam should be true when under 6 teams', () => {
const teams: Team[] = [
{ id: '1', name: 'Team 1', score: 0 },
{ id: '2', name: 'Team 2', score: 0 },
];
expect(teams.length < 6).toBe(true);
});
it('canAddTeam should be false when at 6 teams', () => {
const teams: Team[] = Array.from({ length: 6 }, (_, i) => ({
id: String(i),
name: `Team ${i + 1}`,
score: 0,
}));
expect(teams.length < 6).toBe(false);
});
it('canRemoveTeam should be true when over 2 teams', () => {
const teams: Team[] = [
{ id: '1', name: 'Team 1', score: 0 },
{ id: '2', name: 'Team 2', score: 0 },
{ id: '3', name: 'Team 3', score: 0 },
];
expect(teams.length > 2).toBe(true);
});
it('canRemoveTeam should be false when at 2 teams', () => {
const teams: Team[] = [
{ id: '1', name: 'Team 1', score: 0 },
{ id: '2', name: 'Team 2', score: 0 },
];
expect(teams.length > 2).toBe(false);
});
it('addTeam should generate unique ID', () => {
const teams: Team[] = [];
const newTeam = { id: generateId(), name: 'New Team', score: 0 };
teams.push(newTeam);
expect(newTeam.id).toBeDefined();
expect(typeof newTeam.id).toBe('string');
expect(newTeam.id.length).toBeGreaterThan(0);
});
it('removeTeam should filter by ID', () => {
const teams: Team[] = [
{ id: '1', name: 'Team 1', score: 0 },
{ id: '2', name: 'Team 2', score: 0 },
{ id: '3', name: 'Team 3', score: 0 },
];
const filtered = teams.filter(t => t.id !== '2');
expect(filtered.length).toBe(2);
expect(filtered.find(t => t.id === '2')).toBeUndefined();
});
});
// ============================================
// Daily Double Tests
// ============================================
describe('Daily Double Logic', () => {
it('countDailyDoubles should return 0 for round with no DDs', () => {
const rounds = [createRound("Test", 1, DEFAULT_SETTINGS)];
expect(countDailyDoubles(rounds, 0)).toBe(0);
});
it('countDailyDoubles should count correctly', () => {
const rounds = [createRound("Test", 1, DEFAULT_SETTINGS)];
rounds[0].categories[0].questions[0].isDailyDouble = true;
rounds[0].categories[1].questions[2].isDailyDouble = true;
expect(countDailyDoubles(rounds, 0)).toBe(2);
});
it('should respect maxDD limit logic', () => {
const maxDD = 2;
const currentDD = 2;
const canAddMore = currentDD < maxDD;
expect(canAddMore).toBe(false);
});
it('should allow toggle when under limit', () => {
const maxDD = 2;
const currentDD = 1;
const canAddMore = currentDD < maxDD;
expect(canAddMore).toBe(true);
});
});
// ============================================
// Persistence Tests
// ============================================
describe('Persistence Logic', () => {
it('save should serialize data to localStorage', () => {
const data = {
name: "Test Game",
settings: DEFAULT_SETTINGS,
teams: [{ id: '1', name: 'Team 1', score: 0 }],
rounds: [],
finalRound: { category: "", question: "", answer: "" },
};
localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(data));
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
AUTOSAVE_KEY,
expect.any(String)
);
});
it('load should parse valid localStorage data', () => {
const savedData = {
name: "Saved Game",
settings: { ...DEFAULT_SETTINGS, defaultTimerSeconds: 10 },
teams: [
{ id: '1', name: 'Player 1', score: 100 },
{ id: '2', name: 'Player 2', score: 200 },
],
rounds: [],
finalRound: { category: "History", question: "Q?", answer: "A" },
};
mockLocalStorage._store[AUTOSAVE_KEY] = JSON.stringify(savedData);
const loaded = JSON.parse(localStorage.getItem(AUTOSAVE_KEY)!);
expect(loaded.name).toBe("Saved Game");
expect(loaded.settings.defaultTimerSeconds).toBe(10);
expect(loaded.teams.length).toBe(2);
});
it('load should handle invalid JSON gracefully', () => {
mockLocalStorage._store[AUTOSAVE_KEY] = "not valid json {{{";
let error: Error | null = null;
try {
JSON.parse(localStorage.getItem(AUTOSAVE_KEY)!);
} catch (e) {
error = e as Error;
}
expect(error).not.toBeNull();
});
it('load should handle missing data gracefully', () => {
const result = localStorage.getItem(AUTOSAVE_KEY);
expect(result).toBeNull();
});
it('clearSaved should remove from localStorage', () => {
mockLocalStorage._store[AUTOSAVE_KEY] = "some data";
localStorage.removeItem(AUTOSAVE_KEY);
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(AUTOSAVE_KEY);
});
it('should handle legacy teamColors property', () => {
const legacyData = {
name: "Legacy Game",
settings: {
...DEFAULT_SETTINGS,
teamColors: ['red', 'blue'], // Legacy property
},
teams: [],
rounds: [],
finalRound: { category: "", question: "", answer: "" },
};
mockLocalStorage._store[AUTOSAVE_KEY] = JSON.stringify(legacyData);
const loaded = JSON.parse(localStorage.getItem(AUTOSAVE_KEY)!);
// Should be able to destructure and remove teamColors
const { teamColors, ...cleanSettings } = loaded.settings;
expect(teamColors).toEqual(['red', 'blue']);
expect(cleanSettings.teamColors).toBeUndefined();
});
});
// ============================================
// Export/Import Tests
// ============================================
describe('Export/Import Logic', () => {
it('exportGame should create valid JSON structure', () => {
const gameData = {
name: "Export Test",
settings: DEFAULT_SETTINGS,
teams: [
{ id: '1', name: 'Team 1', score: 0 },
{ id: '2', name: 'Team 2', score: 0 },
],
rounds: [createRound("Round 1", 1, DEFAULT_SETTINGS)],
finalRound: { category: "Cat", question: "Q?", answer: "A" },
};
const json = JSON.stringify(gameData, null, 2);
const parsed = JSON.parse(json);
expect(parsed.name).toBe("Export Test");
expect(parsed.teams.length).toBe(2);
expect(parsed.rounds.length).toBe(1);
});
it('exportGame should create Blob with correct type', () => {
const json = JSON.stringify({ test: true });
const blob = new Blob([json], { type: "application/json" });
expect(blob.type).toBe("application/json");
expect(blob.size).toBeGreaterThan(0);
});
it('importGame should validate required fields', () => {
const validData = {
settings: DEFAULT_SETTINGS,
teams: [],
rounds: [],
};
const invalidData1 = { teams: [], rounds: [] }; // Missing settings
const invalidData2 = { settings: DEFAULT_SETTINGS, rounds: [] }; // Missing teams
const invalidData3 = { settings: DEFAULT_SETTINGS, teams: [] }; // Missing rounds
const hasRequired = (data: Record<string, unknown>) =>
!!(data.settings && data.teams && data.rounds);
expect(hasRequired(validData)).toBe(true);
expect(hasRequired(invalidData1)).toBe(false);
expect(hasRequired(invalidData2)).toBe(false);
expect(hasRequired(invalidData3)).toBe(false);
});
it('should generate sanitized filename', () => {
const gameName = "My Test Game 2024";
const filename = `${gameName.replace(/\s+/g, "_") || "game"}.json`;
expect(filename).toBe("My_Test_Game_2024.json");
});
it('should use default filename when name is empty', () => {
const gameName = "";
const filename = `${gameName.replace(/\s+/g, "_") || "game"}.json`;
expect(filename).toBe("game.json");
});
});
// ============================================
// Round Management Tests
// ============================================
describe('Round Management Logic', () => {
it('setRoundCount should reduce to 1 round', () => {
let rounds = [
createRound("Round 1", 1, DEFAULT_SETTINGS),
createRound("Round 2", 2, DEFAULT_SETTINGS),
];
if (rounds.length > 1) {
rounds = [rounds[0]];
}
expect(rounds.length).toBe(1);
});
it('setRoundCount should add second round', () => {
let rounds = [createRound("Round 1", 1, DEFAULT_SETTINGS)];
if (rounds.length === 1) {
rounds = [...rounds, createRound("Round 2", 2, DEFAULT_SETTINGS)];
}
expect(rounds.length).toBe(2);
expect(rounds[1].pointMultiplier).toBe(2);
});
it('getPointsForRound should apply multiplier', () => {
const base = [10, 20, 30, 40, 50];
const round1Points = base.map(v => v * 1);
const round2Points = base.map(v => v * 2);
expect(round1Points).toEqual([10, 20, 30, 40, 50]);
expect(round2Points).toEqual([20, 40, 60, 80, 100]);
});
it('updatePreset should reset to default values', () => {
let settings = { ...DEFAULT_SETTINGS, pointValues: [5, 10, 15, 20, 25] };
if (settings.pointValuePreset === "round1") {
settings.pointValues = [10, 20, 30, 40, 50];
}
// Simulate preset change
settings.pointValuePreset = "round1";
settings.pointValues = [10, 20, 30, 40, 50];
expect(settings.pointValues).toEqual([10, 20, 30, 40, 50]);
});
});
// ============================================
// Derived State Tests
// ============================================
describe('Derived State Logic', () => {
it('totalQuestions should count all questions', () => {
const rounds = [
createRound("R1", 1, DEFAULT_SETTINGS),
createRound("R2", 2, DEFAULT_SETTINGS),
];
const total = rounds.reduce(
(total, round) =>
total + round.categories.reduce(
(catTotal, cat) => catTotal + cat.questions.length,
0
),
0
);
const expectedPerRound = DEFAULT_SETTINGS.categoriesPerRound * DEFAULT_SETTINGS.questionsPerCategory;
expect(total).toBe(expectedPerRound * 2);
});
it('filledQuestions should count only non-empty', () => {
const rounds = [createRound("R1", 1, DEFAULT_SETTINGS)];
rounds[0].categories[0].questions[0].question = "Filled";
rounds[0].categories[0].questions[1].question = "Also filled";
rounds[0].categories[1].questions[0].question = "Third";
const filled = rounds.reduce(
(total, round) =>
total + round.categories.reduce(
(catTotal, cat) =>
catTotal + cat.questions.filter((q) => q.question.trim()).length,
0
),
0
);
expect(filled).toBe(3);
});
});
});

@ -1,4 +1,5 @@
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import { storage, STORAGE_KEYS } from "$lib/services";
import type { Team, Round, FinalRound, GameSettings, GamePhase, QuestionResult } from "$lib/types/kuldvillak"; import type { Team, Round, FinalRound, GameSettings, GamePhase, QuestionResult } from "$lib/types/kuldvillak";
// Game session state that syncs across tabs // Game session state that syncs across tabs
@ -57,7 +58,17 @@ export interface GameSessionState {
} }
const CHANNEL_NAME = "kuldvillak-game-session"; const CHANNEL_NAME = "kuldvillak-game-session";
const STORAGE_KEY = "kuldvillak-game-session";
// Timer constants
const TIMEOUT_BEFORE_ANSWER_SECONDS = 5;
const FINAL_ROUND_TIMER_SECONDS = 30;
// Broadcast message types for cross-tab communication
type BroadcastMessage =
| { type: "STATE_UPDATE"; state: GameSessionState | null }
| { type: "REQUEST_STATE" }
| { type: "TIMER_OWNER_CHECK" }
| { type: "TIMER_OWNER_EXISTS" };
class GameSessionStore { class GameSessionStore {
private channel: BroadcastChannel | null = null; private channel: BroadcastChannel | null = null;
@ -76,7 +87,7 @@ class GameSessionStore {
} else if (event.data.type === "REQUEST_STATE") { } else if (event.data.type === "REQUEST_STATE") {
// Another tab is requesting the current state // Another tab is requesting the current state
if (this.state) { if (this.state) {
this.broadcast("STATE_UPDATE", this.state); this.broadcast({ type: "STATE_UPDATE", state: this.state });
} }
} else if (event.data.type === "TIMER_OWNER_CHECK") { } else if (event.data.type === "TIMER_OWNER_CHECK") {
// Another tab is checking who owns the timer // Another tab is checking who owns the timer
@ -89,15 +100,11 @@ class GameSessionStore {
} }
}; };
// Try to load from localStorage // Try to load from storage
const saved = localStorage.getItem(STORAGE_KEY); const saved = storage.get<GameSessionState>(STORAGE_KEYS.GAME_SESSION);
if (saved) { if (saved) {
try { this.state = saved;
this.state = JSON.parse(saved);
// Timer will be started by moderator view via enableTimerControl() // Timer will be started by moderator view via enableTimerControl()
} catch {
// Invalid data
}
} }
// Request state from other tabs // Request state from other tabs
@ -105,11 +112,15 @@ class GameSessionStore {
} }
} }
private broadcast(type: string, state: GameSessionState) { private broadcast(message: BroadcastMessage) {
if (this.channel) { if (this.channel) {
if (message.type === "STATE_UPDATE" && message.state) {
// Use $state.snapshot to get plain object from Proxy // Use $state.snapshot to get plain object from Proxy
const plainState = $state.snapshot(state); const plainState = $state.snapshot(message.state);
this.channel.postMessage({ type, state: plainState }); this.channel.postMessage({ type: message.type, state: plainState });
} else {
this.channel.postMessage(message);
}
} }
} }
@ -117,8 +128,8 @@ class GameSessionStore {
if (browser && this.state) { if (browser && this.state) {
// Use $state.snapshot to get plain object from Proxy // Use $state.snapshot to get plain object from Proxy
const plainState = $state.snapshot(this.state); const plainState = $state.snapshot(this.state);
localStorage.setItem(STORAGE_KEY, JSON.stringify(plainState)); storage.set(STORAGE_KEYS.GAME_SESSION, plainState);
this.broadcast("STATE_UPDATE", this.state); this.broadcast({ type: "STATE_UPDATE", state: this.state });
} }
} }
@ -216,8 +227,8 @@ class GameSessionStore {
this.stopInternalTimer(); this.stopInternalTimer();
this.state = null; this.state = null;
if (browser) { if (browser) {
localStorage.removeItem(STORAGE_KEY); storage.remove(STORAGE_KEYS.GAME_SESSION);
this.broadcast("STATE_UPDATE", null as any); this.broadcast({ type: "STATE_UPDATE", state: null });
// Keep projectorWindow reference so we can detect if tab is still open // Keep projectorWindow reference so we can detect if tab is still open
} }
} }
@ -336,7 +347,7 @@ class GameSessionStore {
const allTeamsWrong = this.state.teams.every(t => this.state!.wrongTeamIds.includes(t.id)); const allTeamsWrong = this.state.teams.every(t => this.state!.wrongTeamIds.includes(t.id));
if (allTeamsWrong) { if (allTeamsWrong) {
// Everyone wrong - start reveal countdown // Everyone wrong - start reveal countdown
this.state.timeoutCountdown = 5; // 5 seconds before showing answer this.state.timeoutCountdown = TIMEOUT_BEFORE_ANSWER_SECONDS;
this.persist(); this.persist();
} else { } else {
this.persist(); this.persist();
@ -346,6 +357,8 @@ class GameSessionStore {
// Skip question - immediately reveal answer // Skip question - immediately reveal answer
skipQuestion() { skipQuestion() {
if (!this.state || !this.state.currentQuestion) return; if (!this.state || !this.state.currentQuestion) return;
// Don't allow skip if timeout/reveal already in progress
if (this.state.timeoutCountdown !== null || this.state.revealCountdown !== null) return;
// Stop timer if running // Stop timer if running
this.state.timerRunning = false; this.state.timerRunning = false;
@ -479,8 +492,8 @@ class GameSessionStore {
showFinalQuestion() { showFinalQuestion() {
if (!this.state) return; if (!this.state) return;
this.state.phase = "final-question"; this.state.phase = "final-question";
this.state.timerMax = 30; // Set 30 second timer for final round this.state.timerMax = FINAL_ROUND_TIMER_SECONDS;
this.state.timerSeconds = 30; this.state.timerSeconds = FINAL_ROUND_TIMER_SECONDS;
this.state.timerRunning = false; this.state.timerRunning = false;
this.state.activeTeamId = null; this.state.activeTeamId = null;
this.state.finalRevealed = []; this.state.finalRevealed = [];
@ -590,8 +603,8 @@ class GameSessionStore {
if (this.state.phase === "question") { if (this.state.phase === "question") {
// Clear active team (no one can answer now) // Clear active team (no one can answer now)
this.state.activeTeamId = null; this.state.activeTeamId = null;
// Start 5 second countdown to answer reveal // Start countdown to answer reveal
this.state.timeoutCountdown = 5; this.state.timeoutCountdown = TIMEOUT_BEFORE_ANSWER_SECONDS;
this.persist(); this.persist();
} else { } else {
this.persist(); this.persist();
@ -614,6 +627,8 @@ class GameSessionStore {
startTimer() { startTimer() {
if (!this.state) return; if (!this.state) return;
// Don't restart timer if answer is showing or timeout/reveal in progress
if (this.state.showAnswer || this.state.timeoutCountdown !== null || this.state.revealCountdown !== null) return;
this.state.timerRunning = true; this.state.timerRunning = true;
this.state.timerSeconds = this.state.timerMax; this.state.timerSeconds = this.state.timerMax;
this.persist(); this.persist();
@ -627,11 +642,10 @@ class GameSessionStore {
resetTimer() { resetTimer() {
if (!this.state) return; if (!this.state) return;
// Don't reset if answer is showing or timeout/reveal in progress
if (this.state.showAnswer || this.state.timeoutCountdown !== null || this.state.revealCountdown !== null) return;
this.state.timerSeconds = this.state.timerMax; this.state.timerSeconds = this.state.timerMax;
this.state.timerRunning = false; this.state.timerRunning = false;
// Also clear any timeout countdowns
this.state.timeoutCountdown = null;
this.state.revealCountdown = null;
this.persist(); this.persist();
} }

@ -0,0 +1,26 @@
/**
* GameSessionStore Tests
*
* NOTE: Full store testing requires Svelte 5 compilation support in Vitest.
* The GameSessionStore uses Svelte 5 runes ($state) which aren't available
* in plain TypeScript test environments.
*
* To enable full testing, you would need to:
* 1. Add @sveltejs/vite-plugin-svelte to vitest.config.ts
* 2. Configure proper Svelte preprocessing
*
* For now, the store is tested indirectly through:
* - Integration tests via the browser
* - The persistence layer tests (which test save/load functionality)
*
* The constants and types exported from this module are tested in kuldvillak.test.ts
*/
import { describe, it, expect } from 'vitest';
describe('GameSessionStore Constants', () => {
it('should document that full store tests require Svelte compilation', () => {
// This is a placeholder test documenting the limitation
expect(true).toBe(true);
});
});

@ -2,6 +2,15 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
import type { KuldvillakGame } from '$lib/types/kuldvillak'; import type { KuldvillakGame } from '$lib/types/kuldvillak';
import { DEFAULT_SETTINGS, DEFAULT_STATE } from '$lib/types/kuldvillak'; import { DEFAULT_SETTINGS, DEFAULT_STATE } from '$lib/types/kuldvillak';
// Mock toast store before importing persistence
vi.mock('./toast.svelte', () => ({
toastStore: {
error: vi.fn(),
success: vi.fn(),
info: vi.fn(),
},
}));
// Mock localStorage // Mock localStorage
const localStorageMock = (() => { const localStorageMock = (() => {
let store: Record<string, string> = {}; let store: Record<string, string> = {};
@ -156,7 +165,7 @@ describe('Persistence', () => {
const duplicate = duplicateKuldvillakGame(original.id); const duplicate = duplicateKuldvillakGame(original.id);
expect(duplicate?.state.phase).toBe('lobby'); expect(duplicate?.state.phase).toBe('intro');
expect(duplicate?.teams[0].score).toBe(0); expect(duplicate?.teams[0].score).toBe(0);
}); });

@ -1,12 +1,10 @@
// ============================================ // ============================================
// LocalStorage Persistence for Game Data // Game Data Persistence Layer
// ============================================ // ============================================
import type { KuldvillakGame, GameMetadata } from '$lib/types/kuldvillak'; import type { KuldvillakGame, GameMetadata } from '$lib/types/kuldvillak';
import { storage, STORAGE_KEYS } from '$lib/services';
const STORAGE_PREFIX = 'ultimate_gaming'; import { toastStore } from './toast.svelte';
const KULDVILLAK_GAMES_KEY = `${STORAGE_PREFIX}_kuldvillak_games`;
const KULDVILLAK_ACTIVE_KEY = `${STORAGE_PREFIX}_kuldvillak_active`;
// ============================================ // ============================================
// Kuldvillak Save/Load Functions // Kuldvillak Save/Load Functions
@ -16,13 +14,10 @@ const KULDVILLAK_ACTIVE_KEY = `${STORAGE_PREFIX}_kuldvillak_active`;
* Get all saved Kuldvillak games metadata (lightweight list) * Get all saved Kuldvillak games metadata (lightweight list)
*/ */
export function getKuldvillakGamesList(): GameMetadata[] { export function getKuldvillakGamesList(): GameMetadata[] {
if (typeof localStorage === 'undefined') return [];
try { try {
const data = localStorage.getItem(KULDVILLAK_GAMES_KEY); const games = storage.get<KuldvillakGame[]>(STORAGE_KEYS.GAMES_LIST);
if (!data) return []; if (!games) return [];
const games: KuldvillakGame[] = JSON.parse(data);
return games.map((game) => ({ return games.map((game) => ({
id: game.id, id: game.id,
name: game.name, name: game.name,
@ -33,6 +28,7 @@ export function getKuldvillakGamesList(): GameMetadata[] {
})); }));
} catch (e) { } catch (e) {
console.error('Failed to load games list:', e); console.error('Failed to load games list:', e);
toastStore.error('Failed to load games list');
return []; return [];
} }
} }
@ -41,13 +37,11 @@ export function getKuldvillakGamesList(): GameMetadata[] {
* Get all saved Kuldvillak games (full data) * Get all saved Kuldvillak games (full data)
*/ */
export function getAllKuldvillakGames(): KuldvillakGame[] { export function getAllKuldvillakGames(): KuldvillakGame[] {
if (typeof localStorage === 'undefined') return [];
try { try {
const data = localStorage.getItem(KULDVILLAK_GAMES_KEY); return storage.get<KuldvillakGame[]>(STORAGE_KEYS.GAMES_LIST) ?? [];
return data ? JSON.parse(data) : [];
} catch (e) { } catch (e) {
console.error('Failed to load games:', e); console.error('Failed to load games:', e);
toastStore.error('Failed to load games');
return []; return [];
} }
} }
@ -64,8 +58,6 @@ export function loadKuldvillakGame(gameId: string): KuldvillakGame | null {
* Save a Kuldvillak game (creates new or updates existing) * Save a Kuldvillak game (creates new or updates existing)
*/ */
export function saveKuldvillakGame(game: KuldvillakGame): boolean { export function saveKuldvillakGame(game: KuldvillakGame): boolean {
if (typeof localStorage === 'undefined') return false;
try { try {
const games = getAllKuldvillakGames(); const games = getAllKuldvillakGames();
const existingIndex = games.findIndex((g) => g.id === game.id); const existingIndex = games.findIndex((g) => g.id === game.id);
@ -79,10 +71,11 @@ export function saveKuldvillakGame(game: KuldvillakGame): boolean {
games.push(game); games.push(game);
} }
localStorage.setItem(KULDVILLAK_GAMES_KEY, JSON.stringify(games)); storage.set(STORAGE_KEYS.GAMES_LIST, games);
return true; return true;
} catch (e) { } catch (e) {
console.error('Failed to save game:', e); console.error('Failed to save game:', e);
toastStore.error('Failed to save game');
return false; return false;
} }
} }
@ -91,15 +84,14 @@ export function saveKuldvillakGame(game: KuldvillakGame): boolean {
* Delete a Kuldvillak game by ID * Delete a Kuldvillak game by ID
*/ */
export function deleteKuldvillakGame(gameId: string): boolean { export function deleteKuldvillakGame(gameId: string): boolean {
if (typeof localStorage === 'undefined') return false;
try { try {
const games = getAllKuldvillakGames(); const games = getAllKuldvillakGames();
const filtered = games.filter((g) => g.id !== gameId); const filtered = games.filter((g) => g.id !== gameId);
localStorage.setItem(KULDVILLAK_GAMES_KEY, JSON.stringify(filtered)); storage.set(STORAGE_KEYS.GAMES_LIST, filtered);
return true; return true;
} catch (e) { } catch (e) {
console.error('Failed to delete game:', e); console.error('Failed to delete game:', e);
toastStore.error('Failed to delete game');
return false; return false;
} }
} }
@ -152,12 +144,10 @@ export function duplicateKuldvillakGame(gameId: string): KuldvillakGame | null {
* Save reference to currently active game * Save reference to currently active game
*/ */
export function setActiveKuldvillakGame(gameId: string | null): void { export function setActiveKuldvillakGame(gameId: string | null): void {
if (typeof localStorage === 'undefined') return;
if (gameId) { if (gameId) {
localStorage.setItem(KULDVILLAK_ACTIVE_KEY, gameId); storage.set('kuldvillak-active-game', gameId);
} else { } else {
localStorage.removeItem(KULDVILLAK_ACTIVE_KEY); storage.remove('kuldvillak-active-game');
} }
} }
@ -165,8 +155,7 @@ export function setActiveKuldvillakGame(gameId: string | null): void {
* Get the ID of the last active game * Get the ID of the last active game
*/ */
export function getActiveKuldvillakGameId(): string | null { export function getActiveKuldvillakGameId(): string | null {
if (typeof localStorage === 'undefined') return null; return storage.get<string>('kuldvillak-active-game');
return localStorage.getItem(KULDVILLAK_ACTIVE_KEY);
} }
/** /**
@ -223,6 +212,7 @@ export async function importKuldvillakGame(file: File): Promise<KuldvillakGame |
return null; return null;
} catch (e) { } catch (e) {
console.error('Failed to import game:', e); console.error('Failed to import game:', e);
toastStore.error('Failed to import game: Invalid file format');
return null; return null;
} }
} }
@ -233,6 +223,7 @@ export async function importKuldvillakGame(file: File): Promise<KuldvillakGame |
/** /**
* Get approximate storage usage * Get approximate storage usage
* Note: This function still accesses localStorage directly for stats
*/ */
export function getStorageStats(): { used: number; available: number } { export function getStorageStats(): { used: number; available: number } {
if (typeof localStorage === 'undefined') { if (typeof localStorage === 'undefined') {
@ -240,9 +231,10 @@ export function getStorageStats(): { used: number; available: number } {
} }
let used = 0; let used = 0;
for (const key in localStorage) { for (let i = 0; i < localStorage.length; i++) {
if (localStorage.hasOwnProperty(key)) { const key = localStorage.key(i);
used += localStorage.getItem(key)?.length ?? 0; if (key) {
used += (localStorage.getItem(key)?.length ?? 0) * 2; // UTF-16 = 2 bytes per char
} }
} }

@ -1,6 +1,15 @@
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import {
getContrast,
hexToHsl,
generateHslColor,
DEFAULT_THEME,
} from "$lib/utils/color";
import { storage, STORAGE_KEYS } from "$lib/services";
// Re-export for backwards compatibility
export { DEFAULT_THEME };
const THEME_STORAGE_KEY = "kuldvillak-theme";
const THEME_CHANNEL_NAME = "kuldvillak-theme-sync"; const THEME_CHANNEL_NAME = "kuldvillak-theme-sync";
// BroadcastChannel for syncing theme across windows // BroadcastChannel for syncing theme across windows
@ -9,31 +18,16 @@ if (browser) {
channel = new BroadcastChannel(THEME_CHANNEL_NAME); channel = new BroadcastChannel(THEME_CHANNEL_NAME);
} }
// Default theme colors // Load initial values from storage
export const DEFAULT_THEME = {
primary: "#003B9B",
secondary: "#FFAB00",
text: "#FFFFFF",
background: "#000000",
};
// Load initial values from localStorage
function getInitialTheme() { function getInitialTheme() {
if (browser) { const saved = storage.get<typeof DEFAULT_THEME>(STORAGE_KEYS.THEME);
const saved = localStorage.getItem(THEME_STORAGE_KEY);
if (saved) { if (saved) {
try {
const theme = JSON.parse(saved);
return { return {
primary: theme.primary ?? DEFAULT_THEME.primary, primary: saved.primary ?? DEFAULT_THEME.primary,
secondary: theme.secondary ?? DEFAULT_THEME.secondary, secondary: saved.secondary ?? DEFAULT_THEME.secondary,
text: theme.text ?? DEFAULT_THEME.text, text: saved.text ?? DEFAULT_THEME.text,
background: theme.background ?? DEFAULT_THEME.background, background: saved.background ?? DEFAULT_THEME.background,
}; };
} catch {
// Ignore parse errors
}
}
} }
return { ...DEFAULT_THEME }; return { ...DEFAULT_THEME };
} }
@ -46,7 +40,7 @@ let secondary = $state(initialTheme.secondary);
let text = $state(initialTheme.text); let text = $state(initialTheme.text);
let background = $state(initialTheme.background); let background = $state(initialTheme.background);
// Saved values (what's persisted to localStorage) // Saved values (what's persisted to storage)
let savedPrimary = $state(initialTheme.primary); let savedPrimary = $state(initialTheme.primary);
let savedSecondary = $state(initialTheme.secondary); let savedSecondary = $state(initialTheme.secondary);
let savedText = $state(initialTheme.text); let savedText = $state(initialTheme.text);
@ -78,22 +72,20 @@ if (browser && channel) {
}; };
} }
// Save current values to localStorage // Save current values to storage
function save() { function save() {
if (browser) { storage.set(STORAGE_KEYS.THEME, {
localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify({
primary, primary,
secondary, secondary,
text, text,
background background
})); });
// Update saved state // Update saved state
savedPrimary = primary; savedPrimary = primary;
savedSecondary = secondary; savedSecondary = secondary;
savedText = text; savedText = text;
savedBackground = background; savedBackground = background;
} }
}
// Revert to last saved values (for cancel/close without saving) // Revert to last saved values (for cancel/close without saving)
function revert() { function revert() {
@ -113,7 +105,82 @@ function resetToDefaults() {
applyTheme(); applyTheme();
} }
// Reset and save (for full reset) // Returns a simple random hex (utility for single color needs)
function getRandomColor() {
// High saturation for a beautiful single random color
return generateHslColor(null, [70, 100], [40, 60]);
}
// Smart Randomization with WCAG and Vibrancy Enforcement
function randomizeColors() {
let attempts = 0;
const maxAttempts = 50;
while (attempts < maxAttempts) {
attempts++;
// 1. Decide Polarity
const isDarkTheme = Math.random() < 0.5;
// 2. Generate Background (Low Saturation, Extreme Luminance)
const newBg = generateHslColor(
null,
[10, 30], // Low Saturation for a neutral BG
isDarkTheme ? [4, 10] : [95, 99] // Extreme Dark or Light
);
// 3. Generate Text (Pure White or Near Black)
const newText = isDarkTheme ? "#FFFFFF" : "#050505";
// 4. Generate Secondary (The "Pop" Color - 7:1 against BG)
const newSec = generateHslColor(
null,
[75, 100], // High Saturation (Vibrant!)
isDarkTheme ? [60, 85] : [20, 45] // Opposite L to BG
);
// 5. Generate Primary (The "Brand" Color - Mid-Tone Vibrancy)
const newPrim = generateHslColor(
null,
[70, 100], // High Saturation
isDarkTheme ? [30, 60] : [40, 60] // Mid-Tone Luminance for True Color
);
// --- VALIDATION ---
// Check Contrast
const ratio_Prim_Text = getContrast(newPrim, newText);
const ratio_Sec_Bg = getContrast(newSec, newBg);
const ratio_Prim_Sec = getContrast(newPrim, newSec);
// Check Hue Diversity (Ensures Primary and Secondary don't look like the same color)
const primHsl = hexToHsl(newPrim);
const secHsl = hexToHsl(newSec);
// We use Math.min to handle the 360-degree color wheel wrap
const hueDiff = Math.min(Math.abs(primHsl[0] - secHsl[0]), 360 - Math.abs(primHsl[0] - secHsl[0]));
// Require at least 40 degrees difference for visual distinction
const distinctHues = hueDiff >= 40;
if (
ratio_Prim_Text >= 4.5 && // WCAG AA for normal text (Relaxed for vibrancy)
ratio_Sec_Bg >= 7.0 && // WCAG AAA (Ensures Secondary/CTA pops)
ratio_Prim_Sec >= 3.0 && // WCAG for non-text/graphical elements (Ensures distinction)
distinctHues // Ensures visual diversity
) {
primary = newPrim;
secondary = newSec;
text = newText;
background = newBg;
applyTheme();
return;
}
}
// Fallback if 50 attempts fail
console.warn("Could not generate vibrant palette with accessibility. Resetting to defaults.");
resetToDefaults();
}
function reset() { function reset() {
resetToDefaults(); resetToDefaults();
save(); save();
@ -133,4 +200,6 @@ export const themeStore = {
revert, revert,
reset, reset,
resetToDefaults, resetToDefaults,
getRandomColor,
randomizeColors,
}; };

@ -0,0 +1,178 @@
import { describe, it, expect } from 'vitest';
import {
hexToRgb,
getLuminance,
getContrast,
hexToHsl,
hslToHex,
generateHslColor,
DEFAULT_THEME,
} from '$lib/utils/color';
describe('ColorUtils', () => {
describe('hexToRgb', () => {
it('should convert white hex to RGB', () => {
expect(hexToRgb('#FFFFFF')).toEqual([255, 255, 255]);
});
it('should convert black hex to RGB', () => {
expect(hexToRgb('#000000')).toEqual([0, 0, 0]);
});
it('should convert red hex to RGB', () => {
expect(hexToRgb('#FF0000')).toEqual([255, 0, 0]);
});
it('should convert green hex to RGB', () => {
expect(hexToRgb('#00FF00')).toEqual([0, 255, 0]);
});
it('should convert blue hex to RGB', () => {
expect(hexToRgb('#0000FF')).toEqual([0, 0, 255]);
});
it('should handle hex without hash', () => {
expect(hexToRgb('FF0000')).toEqual([255, 0, 0]);
});
});
describe('getLuminance', () => {
it('should return 1 for white', () => {
expect(getLuminance(255, 255, 255)).toBeCloseTo(1, 2);
});
it('should return 0 for black', () => {
expect(getLuminance(0, 0, 0)).toBeCloseTo(0, 2);
});
it('should return ~0.2126 for pure red', () => {
expect(getLuminance(255, 0, 0)).toBeCloseTo(0.2126, 2);
});
it('should return ~0.7152 for pure green', () => {
expect(getLuminance(0, 255, 0)).toBeCloseTo(0.7152, 2);
});
it('should return ~0.0722 for pure blue', () => {
expect(getLuminance(0, 0, 255)).toBeCloseTo(0.0722, 2);
});
});
describe('getContrast', () => {
it('should return 21 for black on white (max contrast)', () => {
const contrast = getContrast('#FFFFFF', '#000000');
expect(contrast).toBeCloseTo(21, 0);
});
it('should return 1 for same colors (no contrast)', () => {
const contrast = getContrast('#FF0000', '#FF0000');
expect(contrast).toBeCloseTo(1, 2);
});
it('should return same value regardless of order', () => {
const contrast1 = getContrast('#FFFFFF', '#003B9B');
const contrast2 = getContrast('#003B9B', '#FFFFFF');
expect(contrast1).toBeCloseTo(contrast2, 2);
});
it('should meet WCAG AA (4.5:1) for default theme text on primary', () => {
const contrast = getContrast(DEFAULT_THEME.text, DEFAULT_THEME.primary);
expect(contrast).toBeGreaterThanOrEqual(4.5);
});
});
describe('hexToHsl', () => {
it('should convert red to HSL', () => {
const [h, s, l] = hexToHsl('#FF0000');
expect(h).toBeCloseTo(0, 0);
expect(s).toBeCloseTo(1, 2);
expect(l).toBeCloseTo(0.5, 2);
});
it('should convert green to HSL', () => {
const [h, s, l] = hexToHsl('#00FF00');
expect(h).toBeCloseTo(120, 0);
expect(s).toBeCloseTo(1, 2);
expect(l).toBeCloseTo(0.5, 2);
});
it('should convert blue to HSL', () => {
const [h, s, l] = hexToHsl('#0000FF');
expect(h).toBeCloseTo(240, 0);
expect(s).toBeCloseTo(1, 2);
expect(l).toBeCloseTo(0.5, 2);
});
it('should convert white to HSL with 0 saturation', () => {
const [h, s, l] = hexToHsl('#FFFFFF');
expect(s).toBeCloseTo(0, 2);
expect(l).toBeCloseTo(1, 2);
});
it('should convert black to HSL with 0 saturation and lightness', () => {
const [h, s, l] = hexToHsl('#000000');
expect(s).toBeCloseTo(0, 2);
expect(l).toBeCloseTo(0, 2);
});
});
describe('hslToHex', () => {
it('should convert red HSL to hex', () => {
expect(hslToHex(0, 100, 50).toUpperCase()).toBe('#FF0000');
});
it('should convert green HSL to hex', () => {
expect(hslToHex(120, 100, 50).toUpperCase()).toBe('#00FF00');
});
it('should convert blue HSL to hex', () => {
expect(hslToHex(240, 100, 50).toUpperCase()).toBe('#0000FF');
});
it('should convert white HSL to hex', () => {
expect(hslToHex(0, 0, 100).toUpperCase()).toBe('#FFFFFF');
});
it('should convert black HSL to hex', () => {
expect(hslToHex(0, 0, 0).toUpperCase()).toBe('#000000');
});
});
describe('generateHslColor', () => {
it('should generate valid hex color', () => {
const color = generateHslColor(null, [50, 100], [30, 70]);
expect(color).toMatch(/^#[0-9A-Fa-f]{6}$/);
});
it('should generate color within specified hue range', () => {
for (let i = 0; i < 10; i++) {
const color = generateHslColor([0, 60], [50, 100], [30, 70]);
const [h] = hexToHsl(color);
expect(h).toBeGreaterThanOrEqual(-5);
expect(h).toBeLessThanOrEqual(65);
}
});
});
});
describe('DEFAULT_THEME', () => {
it('should have all required color properties', () => {
expect(DEFAULT_THEME).toHaveProperty('primary');
expect(DEFAULT_THEME).toHaveProperty('secondary');
expect(DEFAULT_THEME).toHaveProperty('text');
expect(DEFAULT_THEME).toHaveProperty('background');
});
it('should have valid hex colors', () => {
const hexRegex = /^#[0-9A-Fa-f]{6}$/;
expect(DEFAULT_THEME.primary).toMatch(hexRegex);
expect(DEFAULT_THEME.secondary).toMatch(hexRegex);
expect(DEFAULT_THEME.text).toMatch(hexRegex);
expect(DEFAULT_THEME.background).toMatch(hexRegex);
});
it('should have good contrast between text and background', () => {
const contrast = getContrast(DEFAULT_THEME.text, DEFAULT_THEME.background);
expect(contrast).toBeGreaterThanOrEqual(7);
});
});

@ -0,0 +1,60 @@
// Global toast notification store
import { browser } from '$app/environment';
export interface ToastNotification {
id: string;
message: string;
type: 'error' | 'success' | 'info';
duration: number;
}
const DEFAULT_DURATION = 3000;
class ToastStore {
notifications = $state<ToastNotification[]>([]);
private generateId(): string {
return `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
show(message: string, type: ToastNotification['type'] = 'info', duration = DEFAULT_DURATION) {
if (!browser) return;
const notification: ToastNotification = {
id: this.generateId(),
message,
type,
duration,
};
this.notifications.push(notification);
if (duration > 0) {
setTimeout(() => {
this.dismiss(notification.id);
}, duration);
}
}
error(message: string, duration = DEFAULT_DURATION) {
this.show(message, 'error', duration);
}
success(message: string, duration = DEFAULT_DURATION) {
this.show(message, 'success', duration);
}
info(message: string, duration = DEFAULT_DURATION) {
this.show(message, 'info', duration);
}
dismiss(id: string) {
this.notifications = this.notifications.filter(n => n.id !== id);
}
clear() {
this.notifications = [];
}
}
export const toastStore = new ToastStore();

@ -0,0 +1,123 @@
// ============================================
// Buzzer System Type Definitions
// ============================================
import type { GamePhase } from './kuldvillak';
/** Connected player in a buzzer session */
export interface BuzzerPlayer {
id: string;
name: string;
score: number;
connected: boolean;
lockedOut: boolean; // True if player buzzed too early
}
/** Current state of the buzzer */
export type BuzzerState =
| 'disabled' // Question not active, can't buzz
| 'locked' // Player buzzed too early, locked out
| 'ready' // Timer active, can buzz
| 'buzzed'; // Someone has buzzed
/** Buzzer session state */
export interface BuzzerSessionState {
roomCode: string;
players: BuzzerPlayer[];
buzzerState: BuzzerState;
currentAnswerer: string | null; // Player ID who buzzed first
buzzQueue: string[]; // First-come-first-served order
gamePhase: GamePhase;
timerActive: boolean;
// Question data (synced from main game)
currentQuestion: {
text: string;
answer: string;
points: number;
isDailyDouble: boolean;
} | null;
showAnswer: boolean;
// Final round
finalCategory: string | null;
finalWagers: Record<string, number>;
finalAnswers: Record<string, string>;
// Result feedback
lastResult: {
playerId: string;
correct: boolean;
points: number;
} | null;
}
// ============================================
// WebSocket Event Types
// ============================================
/** Events sent from client to server */
export type ClientEvent =
| { type: 'join'; roomCode: string; playerName: string }
| { type: 'buzz'; timestamp: number }
| { type: 'submit-wager'; wager: number }
| { type: 'submit-answer'; answer: string }
| { type: 'disconnect' };
/** Events sent from server to client */
export type ServerEvent =
| { type: 'joined'; playerId: string; player: BuzzerPlayer; players: BuzzerPlayer[] }
| { type: 'game-state'; state: Partial<BuzzerSessionState> }
| { type: 'player-joined'; player: BuzzerPlayer }
| { type: 'player-left'; playerId: string }
| { type: 'buzzer-pressed'; playerId: string; playerName: string }
| { type: 'buzzer-state-change'; state: BuzzerState; currentAnswerer: string | null }
| { type: 'answer-result'; playerId: string; correct: boolean; points: number }
| { type: 'phase-change'; phase: GamePhase }
| { type: 'question-update'; question: BuzzerSessionState['currentQuestion']; showAnswer: boolean }
| { type: 'timer-update'; active: boolean }
| { type: 'score-update'; playerId: string; score: number }
| { type: 'final-category'; category: string }
| { type: 'wager-accepted'; playerId: string }
| { type: 'answer-accepted'; playerId: string }
| { type: 'game-over'; rankings: { playerId: string; name: string; score: number; rank: number }[] }
| { type: 'game-ended' }
| { type: 'error'; message: string };
/** Events sent from moderator to buzzer server */
export type ModeratorEvent =
| { type: 'create-room'; roomCode: string }
| { type: 'start-game' }
| { type: 'phase-change'; phase: GamePhase }
| { type: 'question-selected'; question: BuzzerSessionState['currentQuestion'] }
| { type: 'timer-start' }
| { type: 'timer-stop' }
| { type: 'mark-correct'; playerId: string; points: number }
| { type: 'mark-wrong'; playerId: string; points: number }
| { type: 'show-answer' }
| { type: 'return-to-board' }
| { type: 'final-category'; category: string }
| { type: 'reveal-final-question'; question: string }
| { type: 'final-timer-start' }
| { type: 'game-over'; rankings: { playerId: string; name: string; score: number; rank: number }[] }
| { type: 'end-game' }
| { type: 'kick-player'; playerId: string };
// ============================================
// Room Code Generation
// ============================================
/** Generate a random 4-character room code */
export function generateRoomCode(): string {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ'; // Removed I and O to avoid confusion
let code = '';
for (let i = 0; i < 4; i++) {
code += chars.charAt(Math.floor(Math.random() * chars.length));
}
return code;
}
/** Validate a room code format */
export function isValidRoomCode(code: string): boolean {
return /^[A-Z]{4}$/.test(code.toUpperCase());
}

@ -0,0 +1,104 @@
// ============================================
// WCAG & Color Utilities
// ============================================
/**
* Convert Hex color to RGB array
*/
export function hexToRgb(hex: string): [number, number, number] {
const bigint = parseInt(hex.replace('#', ''), 16);
return [(bigint >> 16) & 255, (bigint >> 8) & 255, bigint & 255];
}
/**
* Calculate Relative Luminance (WCAG 2.0 formula)
*/
export function getLuminance(r: number, g: number, b: number): number {
const a = [r, g, b].map((v) => {
v /= 255;
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
});
return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722;
}
/**
* Calculate Contrast Ratio between two colors
*/
export function getContrast(hex1: string, hex2: string): number {
const rgb1 = hexToRgb(hex1);
const rgb2 = hexToRgb(hex2);
const l1 = getLuminance(rgb1[0], rgb1[1], rgb1[2]);
const l2 = getLuminance(rgb2[0], rgb2[1], rgb2[2]);
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
/**
* Convert Hex to HSL
* Returns [H in degrees, S as 0-1, L as 0-1]
*/
export function hexToHsl(hex: string): [number, number, number] {
const rgb = hexToRgb(hex);
const r = rgb[0] / 255;
const g = rgb[1] / 255;
const b = rgb[2] / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0;
let s = 0;
const l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
case g: h = (b - r) / d + 2; break;
case b: h = (r - g) / d + 4; break;
}
h /= 6;
}
return [h * 360, s, l];
}
/**
* Convert HSL to Hex
* @param h Hue in degrees (0-360)
* @param s Saturation as percentage (0-100)
* @param l Lightness as percentage (0-100)
*/
export function hslToHex(h: number, s: number, l: number): string {
l /= 100;
const a = s * Math.min(l, 1 - l) / 100;
const f = (n: number) => {
const k = (n + h / 30) % 12;
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
return Math.round(255 * color).toString(16).padStart(2, '0');
};
return `#${f(0)}${f(8)}${f(4)}`;
}
/**
* Generate a random color with controlled HSL values
*/
export function generateHslColor(
hueRange: [number, number] | null,
satRange: [number, number],
lumRange: [number, number]
): string {
const h = hueRange
? Math.floor(Math.random() * (hueRange[1] - hueRange[0])) + hueRange[0]
: Math.floor(Math.random() * 360);
const s = Math.floor(Math.random() * (satRange[1] - satRange[0])) + satRange[0];
const l = Math.floor(Math.random() * (lumRange[1] - lumRange[0])) + lumRange[0];
return hslToHex(h, s, l);
}
// Default theme colors
export const DEFAULT_THEME = {
primary: "#003B9B",
secondary: "#FFAB00",
text: "#FFFFFF",
background: "#000000",
};

@ -0,0 +1,47 @@
// Focus trap utility for modals
export function trapFocus(node: HTMLElement) {
const focusableSelectors = [
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'a[href]',
'[tabindex]:not([tabindex="-1"])',
].join(', ');
function getFocusableElements() {
return Array.from(node.querySelectorAll<HTMLElement>(focusableSelectors));
}
function handleKeydown(e: KeyboardEvent) {
if (e.key !== 'Tab') return;
const focusable = getFocusableElements();
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
// Focus first element on mount
const focusable = getFocusableElements();
if (focusable.length > 0) {
focusable[0].focus();
}
node.addEventListener('keydown', handleKeydown);
return {
destroy() {
node.removeEventListener('keydown', handleKeydown);
}
};
}

@ -0,0 +1,155 @@
// ============================================
// JSON Validation Utilities
// Type-safe parsing with structural validation
// ============================================
import type { GameSettings, Team, Round, FinalRound } from '$lib/types/kuldvillak';
/**
* Result type for validation operations
*/
export type ValidationResult<T> =
| { success: true; data: T }
| { success: false; error: string };
/**
* Validate that a value is a non-null object
*/
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/**
* Validate that a value is an array
*/
function isArray(value: unknown): value is unknown[] {
return Array.isArray(value);
}
/**
* Validate that a value is a string
*/
function isString(value: unknown): value is string {
return typeof value === 'string';
}
/**
* Validate that a value is a number
*/
function isNumber(value: unknown): value is number {
return typeof value === 'number' && !isNaN(value);
}
/**
* Game data structure for import/export
*/
export interface GameData {
name?: string;
settings: GameSettings;
teams: Team[];
rounds: Round[];
finalRound?: FinalRound | null;
}
/**
* Validate and parse game data from unknown input
* @param data Unknown data to validate
* @returns Validation result with typed data or error message
*/
export function validateGameData(data: unknown): ValidationResult<GameData> {
if (!isObject(data)) {
return { success: false, error: 'Invalid data format: expected object' };
}
// Validate required fields exist
if (!isObject(data.settings)) {
return { success: false, error: 'Missing or invalid settings' };
}
if (!isArray(data.teams)) {
return { success: false, error: 'Missing or invalid teams array' };
}
if (!isArray(data.rounds)) {
return { success: false, error: 'Missing or invalid rounds array' };
}
// Validate teams structure
for (let i = 0; i < data.teams.length; i++) {
const team = data.teams[i];
if (!isObject(team)) {
return { success: false, error: `Invalid team at index ${i}` };
}
if (!isString(team.id)) {
return { success: false, error: `Team ${i} missing id` };
}
if (!isString(team.name)) {
return { success: false, error: `Team ${i} missing name` };
}
}
// Validate rounds structure
for (let i = 0; i < data.rounds.length; i++) {
const round = data.rounds[i];
if (!isObject(round)) {
return { success: false, error: `Invalid round at index ${i}` };
}
if (!isArray(round.categories)) {
return { success: false, error: `Round ${i} missing categories` };
}
}
// Clean up legacy properties from settings
const settingsObj = data.settings as Record<string, unknown>;
const { teamColors, ...cleanSettings } = settingsObj;
// Build validated game data
const gameData: GameData = {
name: isString(data.name) ? data.name : undefined,
settings: cleanSettings as unknown as GameSettings,
teams: data.teams.map((t) => {
const team = t as Record<string, unknown>;
return {
id: team.id as string,
name: team.name as string,
score: isNumber(team.score) ? team.score : 0,
};
}),
rounds: data.rounds as Round[],
finalRound: isObject(data.finalRound)
? (data.finalRound as unknown as FinalRound)
: null,
};
return { success: true, data: gameData };
}
/**
* Safely parse JSON string with validation
* @param jsonString JSON string to parse
* @returns Validation result with parsed data or error
*/
export function parseJSON<T>(jsonString: string): ValidationResult<T> {
try {
const data = JSON.parse(jsonString) as T;
return { success: true, data };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to parse JSON'
};
}
}
/**
* Parse and validate game data from JSON string
* @param jsonString JSON string containing game data
* @returns Validation result with typed game data or error
*/
export function parseGameData(jsonString: string): ValidationResult<GameData> {
const parseResult = parseJSON<unknown>(jsonString);
if (!parseResult.success) {
return parseResult;
}
return validateGameData(parseResult.data);
}

@ -2,7 +2,7 @@
import "./layout.css"; import "./layout.css";
import favicon from "$lib/assets/favicon.svg"; import favicon from "$lib/assets/favicon.svg";
import { themeStore } from "$lib/stores/theme.svelte"; import { themeStore } from "$lib/stores/theme.svelte";
import { ErrorBoundary } from "$lib/components"; import { ErrorBoundary, ToastContainer } from "$lib/components";
import { onMount } from "svelte"; import { onMount } from "svelte";
let { children } = $props(); let { children } = $props();
@ -20,3 +20,5 @@
<ErrorBoundary> <ErrorBoundary>
{@render children()} {@render children()}
</ErrorBoundary> </ErrorBoundary>
<ToastContainer />

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Settings } from "$lib/components"; import { Settings } from "$lib/components";
import { KvButtonPrimary, KvGameLogo } from "$lib/components/kuldvillak/ui"; import { KvButton, KvGameLogo } from "$lib/components/kuldvillak/ui";
import * as m from "$lib/paraglide/messages"; import * as m from "$lib/paraglide/messages";
let settingsOpen = $state(false); let settingsOpen = $state(false);
@ -52,20 +52,22 @@
<!-- Menu Buttons --> <!-- Menu Buttons -->
<div <div
class="flex flex-col gap-2 p-2 w-56 md:w-64 bg-kv-black border-2 border-kv-black" class="flex flex-col p-2 w-56 md:w-64 bg-kv-black border-2 border-kv-black"
> >
<KvButtonPrimary <KvButton
variant="primary"
href="/kuldvillak/edit" href="/kuldvillak/edit"
class="w-full !text-lg md:!text-2xl !py-3 md:!py-4" class="w-full !text-lg md:!text-2xl !py-3 md:!py-4"
> >
{m.kv_new_game()} {m.kv_new_game()}
</KvButtonPrimary> </KvButton>
<KvButtonPrimary <KvButton
variant="primary"
onclick={() => fileInput.click()} onclick={() => fileInput.click()}
class="w-full !text-lg md:!text-2xl !py-3 md:!py-4" class="w-full !text-lg md:!text-2xl !py-3 md:!py-4"
> >
{m.kv_load_game()} {m.kv_load_game()}
</KvButtonPrimary> </KvButton>
<input <input
type="file" type="file"
accept=".json" accept=".json"
@ -73,19 +75,21 @@
bind:this={fileInput} bind:this={fileInput}
onchange={handleLoadGame} onchange={handleLoadGame}
/> />
<KvButtonPrimary <KvButton
variant="primary"
onclick={openSettings} onclick={openSettings}
class="w-full !text-lg md:!text-2xl !py-3 md:!py-4" class="w-full !text-lg md:!text-2xl !py-3 md:!py-4"
> >
{m.kv_settings()} {m.kv_settings()}
</KvButtonPrimary> </KvButton>
<KvButtonPrimary <KvButton
variant="primary"
href="/" href="/"
reload reload
class="w-full !text-lg md:!text-2xl !py-3 md:!py-4" class="w-full !text-lg md:!text-2xl !py-3 md:!py-4"
> >
{m.kv_exit()} {m.kv_exit()}
</KvButtonPrimary> </KvButton>
</div> </div>
</div> </div>
</div> </div>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

@ -3,10 +3,7 @@
import { gameSession } from "$lib/stores/gameSession.svelte"; import { gameSession } from "$lib/stores/gameSession.svelte";
import ProjectorView from "./ProjectorView.svelte"; import ProjectorView from "./ProjectorView.svelte";
import ModeratorView from "./ModeratorView.svelte"; import ModeratorView from "./ModeratorView.svelte";
import { import { KvButton, KvSpinner } from "$lib/components/kuldvillak/ui";
KvButtonSecondary,
KvSpinner,
} from "$lib/components/kuldvillak/ui";
import * as m from "$lib/paraglide/messages"; import * as m from "$lib/paraglide/messages";
import faviconKuldvillak from "$lib/assets/kuldvillak_favicon.svg"; import faviconKuldvillak from "$lib/assets/kuldvillak_favicon.svg";
@ -40,9 +37,9 @@
{m.kv_play_loading_hint()} {m.kv_play_loading_hint()}
</p> </p>
<a href="/kuldvillak/edit"> <a href="/kuldvillak/edit">
<KvButtonSecondary> <KvButton variant="secondary">
{m.kv_play_go_to_editor()} {m.kv_play_go_to_editor()}
</KvButtonSecondary> </KvButton>
</a> </a>
</div> </div>
</div> </div>
@ -64,9 +61,9 @@
{m.kv_play_loading_hint()} {m.kv_play_loading_hint()}
</p> </p>
<a href="/kuldvillak/edit"> <a href="/kuldvillak/edit">
<KvButtonSecondary> <KvButton variant="secondary">
{m.kv_play_go_to_editor()} {m.kv_play_go_to_editor()}
</KvButtonSecondary> </KvButton>
</a> </a>
</div> </div>
</div> </div>

@ -4,8 +4,7 @@
import { onMount } from "svelte"; import { onMount } from "svelte";
import * as m from "$lib/paraglide/messages"; import * as m from "$lib/paraglide/messages";
import { import {
KvButtonPrimary, KvButton,
KvButtonSecondary,
KvNumberInput, KvNumberInput,
KvEditCard, KvEditCard,
} from "$lib/components/kuldvillak/ui"; } from "$lib/components/kuldvillak/ui";
@ -14,6 +13,31 @@
// Only moderator controls the timer // Only moderator controls the timer
onMount(() => { onMount(() => {
gameSession.enableTimerControl(); gameSession.enableTimerControl();
// Add spacebar event listener for timer control
const handleKeyDown = (e: KeyboardEvent) => {
if (e.code === "Space") {
e.preventDefault(); // Prevent scrolling
if (
["question", "final-question"].includes(
session?.phase || "",
) &&
!session?.showAnswer
) {
if (session?.timerRunning) {
gameSession.stopTimer();
} else {
gameSession.startTimer();
}
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}); });
// Settings modal state // Settings modal state
@ -162,24 +186,22 @@
class="bg-kv-blue flex flex-col md:flex-row items-start md:items-center justify-between p-4 gap-4" class="bg-kv-blue flex flex-col md:flex-row items-start md:items-center justify-between p-4 gap-4"
> >
<!-- Left: Game Info --> <!-- Left: Game Info -->
<div <div class="flex flex-col gap-2 text-kv-white">
class="flex flex-col gap-2 font-kv-body text-kv-white uppercase" <h1 class="kv-h1 m-0">
>
<h1 class="text-2xl md:text-4xl kv-shadow-text m-0">
{session.name} {session.name}
</h1> </h1>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{#if session.phase.startsWith("final")} {#if session.phase.startsWith("final")}
<span class="text-xl md:text-[28px] kv-shadow-text"> <span class="kv-h2">
{m.kv_final_round()} {m.kv_final_round()}
</span> </span>
{:else} {:else}
<span class="text-xl md:text-[28px] kv-shadow-text"> <span class="kv-h2">
{session.currentRoundIndex === 0 {session.currentRoundIndex === 0
? m.kv_edit_r1() ? m.kv_edit_r1()
: m.kv_edit_r2()} : m.kv_edit_r2()}
</span> </span>
<span class="text-base md:text-xl text-kv-yellow"> <span class="kv-body-lg text-kv-yellow">
({m.kv_edit_dd_short()} ({m.kv_edit_dd_short()}
{countRemainingDailyDoubles( {countRemainingDailyDoubles(
session.currentRoundIndex, session.currentRoundIndex,
@ -194,31 +216,34 @@
<div class="flex flex-col gap-4 items-end w-full md:w-auto"> <div class="flex flex-col gap-4 items-end w-full md:w-auto">
<!-- Action Buttons --> <!-- Action Buttons -->
<div class="flex flex-wrap gap-4 items-center justify-end"> <div class="flex flex-wrap gap-4 items-center justify-end">
<KvButtonPrimary onclick={openProjector}> <KvButton variant="primary" onclick={openProjector}>
{m.kv_play_open_projector()} {m.kv_play_open_projector()}
</KvButtonPrimary> </KvButton>
{#if session.phase === "board"} {#if session.phase === "board"}
{#if session.rounds.length > 1 && session.currentRoundIndex === 0} {#if session.rounds.length > 1 && session.currentRoundIndex === 0}
<!-- Two rounds and on first round: show Next Round button --> <!-- Two rounds and on first round: show Next Round button -->
<KvButtonPrimary <KvButton
variant="primary"
onclick={() => gameSession.nextRound()} onclick={() => gameSession.nextRound()}
> >
{m.kv_play_next_round()} {m.kv_play_next_round()}
</KvButtonPrimary> </KvButton>
{:else if session.settings.enableFinalRound && session.finalRound} {:else if session.settings.enableFinalRound && session.finalRound}
<!-- One round OR on second round: show Final Round button --> <!-- One round OR on second round: show Final Round button -->
<KvButtonPrimary <KvButton
variant="primary"
onclick={() => gameSession.goToFinalRound()} onclick={() => gameSession.goToFinalRound()}
> >
{m.kv_play_go_to_final()} {m.kv_play_go_to_final()}
</KvButtonPrimary> </KvButton>
{/if} {/if}
{/if} {/if}
<KvButtonSecondary <KvButton
variant="secondary"
onclick={() => (showEndGameConfirm = true)} onclick={() => (showEndGameConfirm = true)}
> >
{m.kv_play_end_game()} {m.kv_play_end_game()}
</KvButtonSecondary> </KvButton>
</div> </div>
<!-- Last Answer & Score Adjustment --> <!-- Last Answer & Score Adjustment -->
@ -227,9 +252,7 @@
{@const lastCorrectTeam = session.teams.find( {@const lastCorrectTeam = session.teams.find(
(t) => t.id === session.lastCorrectTeamId, (t) => t.id === session.lastCorrectTeamId,
)} )}
<span <span class="kv-body-lg text-kv-white">
class="font-kv-body text-lg md:text-xl text-kv-white uppercase kv-shadow-text"
>
{m.kv_play_last_answer()}: {m.kv_play_last_answer()}:
<span class="text-kv-yellow"> <span class="text-kv-yellow">
{lastCorrectTeam?.name} {lastCorrectTeam?.name}
@ -240,9 +263,7 @@
{/if} {/if}
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<span <span class="kv-body-lg text-kv-white">
class="font-kv-body text-lg md:text-xl text-kv-white uppercase kv-shadow-text"
>
{m.kv_play_adjust_score()}: {m.kv_play_adjust_score()}:
</span> </span>
<KvNumberInput <KvNumberInput
@ -323,67 +344,60 @@
class="bg-kv-blue flex-1 flex flex-col items-center justify-center gap-8 p-8" class="bg-kv-blue flex-1 flex flex-col items-center justify-center gap-8 p-8"
> >
{#if session.phase === "intro"} {#if session.phase === "intro"}
<span <span class="kv-h1 text-kv-yellow">
class="font-kv-body text-4xl md:text-6xl text-kv-yellow uppercase kv-shadow-text"
>
{session.currentRoundIndex === 0 {session.currentRoundIndex === 0
? m.kv_edit_r1() ? m.kv_edit_r1()
: m.kv_edit_r2()} : m.kv_edit_r2()}
</span> </span>
<div class="flex gap-4"> <div class="flex gap-4">
{#if !session.categoriesIntroduced} {#if !session.categoriesIntroduced}
<KvButtonPrimary <KvButton
variant="primary"
onclick={() => onclick={() =>
gameSession.startCategoryIntro()} gameSession.startCategoryIntro()}
> >
{m.kv_play_introduce_categories()} {m.kv_play_introduce_categories()}
</KvButtonPrimary> </KvButton>
{/if} {/if}
<KvButtonSecondary <KvButton
variant="secondary"
onclick={() => gameSession.startBoard()} onclick={() => gameSession.startBoard()}
> >
{session.categoriesIntroduced {session.categoriesIntroduced
? m.kv_play_start_game() ? m.kv_play_start_game()
: m.kv_play_skip_to_game()} : m.kv_play_skip_to_game()}
</KvButtonSecondary> </KvButton>
</div> </div>
{:else if session.phase === "intro-categories"} {:else if session.phase === "intro-categories"}
{#if !introDelayComplete && session.introCategoryIndex === 0} {#if !introDelayComplete && session.introCategoryIndex === 0}
<!-- Initial 3s delay - show round name with countdown --> <!-- Initial 3s delay - show round name with countdown -->
<span <span class="kv-h1 text-kv-yellow">
class="font-kv-body text-4xl md:text-6xl text-kv-yellow uppercase kv-shadow-text"
>
{session.currentRoundIndex === 0 {session.currentRoundIndex === 0
? m.kv_edit_r1() ? m.kv_edit_r1()
: m.kv_edit_r2()} : m.kv_edit_r2()}
</span> </span>
<span <span class="kv-body-lg text-kv-white">
class="font-kv-body text-lg md:text-xl text-kv-white uppercase kv-shadow-text"
>
{m.kv_play_introducing_categories({ {m.kv_play_introducing_categories({
seconds: introCountdown, seconds: introCountdown,
})} })}
</span> </span>
{:else if currentRound?.categories[session.introCategoryIndex]} {:else if currentRound?.categories[session.introCategoryIndex]}
<span <span class="kv-h1 text-kv-white">
class="font-kv-body text-4xl md:text-6xl text-kv-white uppercase kv-shadow-text"
>
{currentRound.categories[ {currentRound.categories[
session.introCategoryIndex session.introCategoryIndex
].name} ].name}
</span> </span>
<span <span class="kv-h2 text-kv-yellow">
class="font-kv-body text-2xl text-kv-yellow uppercase"
>
{session.introCategoryIndex + 1} / {currentRound {session.introCategoryIndex + 1} / {currentRound
.categories.length} .categories.length}
</span> </span>
{:else} {:else}
<KvButtonPrimary <KvButton
variant="primary"
onclick={() => gameSession.startBoard()} onclick={() => gameSession.startBoard()}
> >
{m.kv_play_start_game()} {m.kv_play_start_game()}
</KvButtonPrimary> </KvButton>
{/if} {/if}
{/if} {/if}
</div> </div>
@ -397,9 +411,7 @@
<div <div
class="bg-kv-blue flex items-center justify-center p-2 min-h-[40px]" class="bg-kv-blue flex items-center justify-center p-2 min-h-[40px]"
> >
<span <span class="kv-h3 text-kv-white text-center">
class="font-kv-body text-sm md:text-2xl text-kv-white text-center uppercase kv-shadow-text"
>
{cat.name || "???"} {cat.name || "???"}
</span> </span>
</div> </div>
@ -439,24 +451,22 @@
<div <div
class="bg-kv-blue flex-1 flex flex-col items-center justify-center gap-8 p-8" class="bg-kv-blue flex-1 flex flex-col items-center justify-center gap-8 p-8"
> >
<h2 <h2 class="kv-h1 text-kv-yellow">
class="font-kv-body text-4xl md:text-6xl text-kv-yellow uppercase kv-shadow-text"
>
{m.kv_play_daily_double()}! {m.kv_play_daily_double()}!
</h2> </h2>
<!-- Team Selection --> <!-- Team Selection -->
<div class="flex flex-wrap gap-4 justify-center"> <div class="flex flex-wrap gap-4 justify-center">
{#each session.teams as team} {#each session.teams as team}
<button <KvButton
class="bg-kv-blue border-4 border-black box-border font-kv-body text-xl md:text-2xl px-6 py-3 cursor-pointer uppercase hover:opacity-80 kv-shadow-text kv-shadow-button variant="primary"
{session.activeTeamId === team.id class={session.activeTeamId === team.id
? 'border-kv-yellow text-kv-yellow' ? "border-kv-yellow text-kv-yellow"
: 'text-kv-white'}" : ""}
onclick={() => gameSession.setActiveTeam(team.id)} onclick={() => gameSession.setActiveTeam(team.id)}
> >
{team.name} ({team.score}€) {team.name} ({team.score}€)
</button> </KvButton>
{/each} {/each}
</div> </div>
@ -471,32 +481,29 @@
)} )}
{@const isValidWager = {@const isValidWager =
wagerInput >= 5 && wagerInput <= maxWager} wagerInput >= 5 && wagerInput <= maxWager}
<div <div class="kv-body-lg text-kv-yellow">
class="font-kv-body text-lg text-kv-yellow uppercase kv-shadow-text"
>
{m.kv_play_wager_range({ min: 5, max: maxWager })} {m.kv_play_wager_range({ min: 5, max: maxWager })}
</div> </div>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<span <span class="kv-h3 text-kv-white">
class="font-kv-body text-xl md:text-2xl text-kv-white uppercase kv-shadow-text"
>
{m.kv_play_wager()}: {m.kv_play_wager()}:
</span> </span>
<input <input
type="number" type="number"
class="bg-transparent border-4 border-black box-border font-kv-body text-xl md:text-2xl text-kv-white text-center px-4 py-2 w-32 class="bg-transparent border-4 border-black box-border kv-h3-plain text-kv-white text-center px-4 py-2 w-32
{!isValidWager ? 'border-red-500' : ''}" {!isValidWager ? 'border-red-500' : ''}"
min="5" min="5"
max={maxWager} max={maxWager}
step="5" step="5"
bind:value={wagerInput} bind:value={wagerInput}
/> />
<KvButtonPrimary <KvButton
variant="primary"
onclick={confirmDailyDoubleWager} onclick={confirmDailyDoubleWager}
disabled={!isValidWager} disabled={!isValidWager}
> >
{m.kv_play_confirm()} {m.kv_play_confirm()}
</KvButtonPrimary> </KvButton>
</div> </div>
{/if} {/if}
</div> </div>
@ -512,9 +519,7 @@
(t) => t.id === session.lastAnsweredTeamId, (t) => t.id === session.lastAnsweredTeamId,
)?.name ?? ""} )?.name ?? ""}
<!-- Correct answer - returning to board --> <!-- Correct answer - returning to board -->
<span <span class="kv-body-lg text-kv-green">
class="font-kv-body text-lg md:text-xl text-kv-green uppercase kv-shadow-text"
>
{m.kv_play_correct_return({ {m.kv_play_correct_return({
name: lastTeamName, name: lastTeamName,
seconds: session.revealCountdown, seconds: session.revealCountdown,
@ -522,18 +527,14 @@
</span> </span>
{:else if session.revealCountdown !== null && session.revealCountdown > 0} {:else if session.revealCountdown !== null && session.revealCountdown > 0}
<!-- Answer revealed - returning to board --> <!-- Answer revealed - returning to board -->
<span <span class="kv-body-lg text-kv-yellow">
class="font-kv-body text-lg md:text-xl text-kv-yellow uppercase kv-shadow-text"
>
{m.kv_play_answer_revealed({ {m.kv_play_answer_revealed({
seconds: session.revealCountdown, seconds: session.revealCountdown,
})} })}
</span> </span>
{:else if session.skippingQuestion && session.timeoutCountdown !== null && session.timeoutCountdown > 0} {:else if session.skippingQuestion && session.timeoutCountdown !== null && session.timeoutCountdown > 0}
<!-- Skipping question - revealing answer --> <!-- Skipping question - revealing answer -->
<span <span class="kv-body-lg text-kv-yellow">
class="font-kv-body text-lg md:text-xl text-kv-yellow uppercase kv-shadow-text"
>
{m.kv_play_skip_reveal({ {m.kv_play_skip_reveal({
seconds: session.timeoutCountdown, seconds: session.timeoutCountdown,
})} })}
@ -544,9 +545,7 @@
(t) => t.id === session.lastAnsweredTeamId, (t) => t.id === session.lastAnsweredTeamId,
)?.name ?? ""} )?.name ?? ""}
<!-- All wrong - revealing answer --> <!-- All wrong - revealing answer -->
<span <span class="kv-body-lg text-kv-red">
class="font-kv-body text-lg md:text-xl text-kv-red uppercase kv-shadow-text"
>
{m.kv_play_wrong_reveal({ {m.kv_play_wrong_reveal({
name: lastTeamName, name: lastTeamName,
seconds: session.timeoutCountdown, seconds: session.timeoutCountdown,
@ -554,9 +553,7 @@
</span> </span>
{:else if session.timeoutCountdown !== null && session.timeoutCountdown > 0} {:else if session.timeoutCountdown !== null && session.timeoutCountdown > 0}
<!-- Timer ran out - revealing answer --> <!-- Timer ran out - revealing answer -->
<span <span class="kv-body-lg text-kv-red">
class="font-kv-body text-lg md:text-xl text-kv-red uppercase kv-shadow-text"
>
{m.kv_play_timeout_reveal({ {m.kv_play_timeout_reveal({
seconds: session.timeoutCountdown, seconds: session.timeoutCountdown,
})} })}
@ -567,9 +564,7 @@
(t) => t.id === session.lastAnsweredTeamId, (t) => t.id === session.lastAnsweredTeamId,
)?.name ?? ""} )?.name ?? ""}
<!-- Wrong answer - waiting for next player --> <!-- Wrong answer - waiting for next player -->
<span <span class="kv-body-lg text-kv-red">
class="font-kv-body text-lg md:text-xl text-kv-red uppercase kv-shadow-text"
>
{m.kv_play_wrong_waiting({ name: lastTeamName })} {m.kv_play_wrong_waiting({ name: lastTeamName })}
</span> </span>
{:else if session.activeTeamId && !session.timerRunning && !session.showAnswer} {:else if session.activeTeamId && !session.timerRunning && !session.showAnswer}
@ -577,16 +572,12 @@
session.teams.find((t) => t.id === session.activeTeamId) session.teams.find((t) => t.id === session.activeTeamId)
?.name ?? ""} ?.name ?? ""}
<!-- Timer paused - someone answering --> <!-- Timer paused - someone answering -->
<span <span class="kv-body-lg text-kv-yellow">
class="font-kv-body text-lg md:text-xl text-kv-yellow uppercase kv-shadow-text"
>
{m.kv_play_timer_paused({ name: activeTeamName })} {m.kv_play_timer_paused({ name: activeTeamName })}
</span> </span>
{:else if !session.activeTeamId && !session.showAnswer && (session.timerRunning || session.timerSeconds > 0)} {:else if !session.activeTeamId && !session.showAnswer && (session.timerRunning || session.timerSeconds > 0)}
<!-- Waiting for team selection --> <!-- Waiting for team selection -->
<span <span class="kv-body-lg text-kv-white/70">
class="font-kv-body text-lg text-kv-white/70 uppercase"
>
{m.kv_play_click_team_to_answer()} {m.kv_play_click_team_to_answer()}
</span> </span>
{/if} {/if}
@ -600,65 +591,67 @@
</div> </div>
<!-- Answer Text --> <!-- Answer Text -->
<div <div class="kv-h2 text-kv-yellow text-center">
class="font-kv-body text-2xl md:text-3xl lg:text-4xl text-kv-yellow text-center uppercase kv-shadow-text"
>
{m.kv_play_answer_short()}: {questionData.question.answer} {m.kv_play_answer_short()}: {questionData.question.answer}
</div> </div>
<!-- Timer Controls --> <!-- Timer Controls -->
<div class="flex flex-col items-center gap-4"> <div class="flex flex-col items-center gap-4">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<span <span class="kv-body-lg text-kv-white">
class="font-kv-body text-lg md:text-xl text-kv-white uppercase kv-shadow-text"
>
{m.kv_play_timer()} {m.kv_play_timer()}
</span> </span>
<div <div
class="border-4 border-black box-border flex items-center justify-center h-12 px-4 min-w-[60px]" class="border-4 border-black box-border flex items-center justify-center h-12 px-4 min-w-[60px]"
> >
<span <span
class="font-kv-body text-lg md:text-xl text-kv-white text-center kv-shadow-text" class="kv-body-lg-plain text-kv-white text-center"
> >
{session.timerSeconds} {session.timerSeconds}
</span> </span>
</div> </div>
<span <span class="kv-body-lg text-kv-white">
class="font-kv-body text-lg md:text-xl text-kv-white uppercase kv-shadow-text"
>
{m.kv_play_seconds()} {m.kv_play_seconds()}
</span> </span>
</div> </div>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
{#if session.timerRunning} {#if session.timerRunning}
<button <KvButton
class="bg-kv-red border-4 border-black box-border font-kv-body text-xl md:text-2xl text-kv-white uppercase px-4 py-2 cursor-pointer hover:opacity-80 kv-shadow-text" variant="danger"
onclick={() => gameSession.stopTimer()} onclick={() => gameSession.stopTimer()}
> >
{m.kv_play_stop()} {m.kv_play_stop()}
</button> </KvButton>
{:else} {:else}
<button <KvButton
class="bg-kv-green border-4 border-black box-border font-kv-body text-xl md:text-2xl text-kv-white uppercase px-4 py-2 cursor-pointer hover:opacity-80 kv-shadow-text" variant="success"
onclick={() => gameSession.startTimer()} onclick={() => gameSession.startTimer()}
disabled={session.showAnswer ||
session.timeoutCountdown !== null ||
session.revealCountdown !== null}
> >
{m.kv_play_start()} {m.kv_play_start()}
</button> </KvButton>
{/if} {/if}
<button <KvButton
class="bg-transparent border-4 border-black box-border font-kv-body text-xl md:text-2xl text-kv-white uppercase px-4 py-2 cursor-pointer hover:opacity-80 kv-shadow-text" variant="ghost"
onclick={() => gameSession.resetTimer()} onclick={() => gameSession.resetTimer()}
disabled={session.showAnswer ||
session.timeoutCountdown !== null ||
session.revealCountdown !== null}
> >
{m.kv_play_reset()} {m.kv_play_reset()}
</button> </KvButton>
<button <KvButton
class="bg-kv-yellow border-4 border-black box-border font-kv-body text-xl md:text-2xl text-black uppercase px-4 py-2 cursor-pointer hover:opacity-80" variant="warning"
onclick={skipQuestion} onclick={skipQuestion}
disabled={session.showAnswer} disabled={session.showAnswer ||
session.timeoutCountdown !== null ||
session.revealCountdown !== null}
> >
{m.kv_play_skip()} {m.kv_play_skip()}
</button> </KvButton>
</div> </div>
</div> </div>
</div> </div>
@ -668,34 +661,33 @@
class="bg-kv-blue flex-1 flex flex-col items-center justify-center gap-8 p-8" class="bg-kv-blue flex-1 flex flex-col items-center justify-center gap-8 p-8"
> >
{#if session.finalCategoryRevealed || session.phase === "final-category"} {#if session.finalCategoryRevealed || session.phase === "final-category"}
<!-- Category revealed - show prominently like first round --> <!-- Category revealed - show prominently -->
<span <span class="kv-h1 text-kv-white">
class="font-kv-body text-4xl md:text-6xl text-kv-white uppercase kv-shadow-text"
>
{session.finalRound?.category} {session.finalRound?.category}
</span> </span>
<span <span class="kv-h2 text-kv-yellow">
class="font-kv-body text-2xl text-kv-yellow uppercase"
>
{m.kv_play_final_round()} {m.kv_play_final_round()}
</span> </span>
<KvButtonPrimary {#if session.finalCategoryRevealed}
<!-- Only show question button after reveal completes -->
<KvButton
variant="primary"
onclick={() => gameSession.showFinalQuestion()} onclick={() => gameSession.showFinalQuestion()}
> >
{m.kv_play_question_short()} {m.kv_play_question_short()}
</KvButtonPrimary> </KvButton>
{/if}
{:else} {:else}
<!-- Before reveal - show Final Round title --> <!-- Before reveal - show Final Round title -->
<span <span class="kv-h1 text-kv-yellow">
class="font-kv-title text-5xl md:text-7xl text-kv-yellow uppercase kv-shadow-text"
>
{m.kv_play_final_round()} {m.kv_play_final_round()}
</span> </span>
<KvButtonPrimary <KvButton
variant="primary"
onclick={() => gameSession.startFinalCategoryReveal()} onclick={() => gameSession.startFinalCategoryReveal()}
> >
{m.kv_play_reveal_category()} {m.kv_play_reveal_category()}
</KvButtonPrimary> </KvButton>
{/if} {/if}
</div> </div>
{:else if session.phase === "final-question"} {:else if session.phase === "final-question"}
@ -708,15 +700,11 @@
> >
<!-- Status Message --> <!-- Status Message -->
{#if activeTeam} {#if activeTeam}
<span <span class="kv-body-lg text-kv-yellow">
class="font-kv-body text-lg md:text-xl text-kv-yellow uppercase kv-shadow-text"
>
{m.kv_play_judging()}: {activeTeam.name} {m.kv_play_judging()}: {activeTeam.name}
</span> </span>
{:else} {:else}
<span <span class="kv-body-lg text-kv-white/70">
class="font-kv-body text-lg text-kv-white/70 uppercase"
>
{m.kv_play_click_team_to_judge()} {m.kv_play_click_team_to_judge()}
</span> </span>
{/if} {/if}
@ -730,68 +718,63 @@
</div> </div>
<!-- Answer Text --> <!-- Answer Text -->
<div <div class="kv-h2 text-kv-yellow text-center">
class="font-kv-body text-2xl md:text-3xl lg:text-4xl text-kv-yellow text-center uppercase kv-shadow-text"
>
{m.kv_play_answer_short()}: {session.finalRound?.answer} {m.kv_play_answer_short()}: {session.finalRound?.answer}
</div> </div>
<!-- Timer Controls --> <!-- Timer Controls -->
<div class="flex flex-col items-center gap-4"> <div class="flex flex-col items-center gap-4">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<span <span class="kv-body-lg text-kv-white">
class="font-kv-body text-lg md:text-xl text-kv-white uppercase kv-shadow-text"
>
{m.kv_play_timer()} {m.kv_play_timer()}
</span> </span>
<div <div
class="border-4 border-black box-border flex items-center justify-center h-12 px-4 min-w-[60px]" class="border-4 border-black box-border flex items-center justify-center h-12 px-4 min-w-[60px]"
> >
<span <span
class="font-kv-body text-lg md:text-xl text-kv-white text-center kv-shadow-text" class="kv-body-lg-plain text-kv-white text-center"
> >
{session.timerSeconds} {session.timerSeconds}
</span> </span>
</div> </div>
<span <span class="kv-body-lg text-kv-white">
class="font-kv-body text-lg md:text-xl text-kv-white uppercase kv-shadow-text"
>
{m.kv_play_seconds()} {m.kv_play_seconds()}
</span> </span>
</div> </div>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
{#if session.timerRunning} {#if session.timerRunning}
<button <KvButton
class="bg-kv-red border-4 border-black box-border font-kv-body text-xl md:text-2xl text-kv-white uppercase px-4 py-2 cursor-pointer hover:opacity-80 kv-shadow-text" variant="danger"
onclick={() => gameSession.stopTimer()} onclick={() => gameSession.stopTimer()}
> >
{m.kv_play_stop()} {m.kv_play_stop()}
</button> </KvButton>
{:else} {:else}
<button <KvButton
class="bg-kv-green border-4 border-black box-border font-kv-body text-xl md:text-2xl text-kv-white uppercase px-4 py-2 cursor-pointer hover:opacity-80 kv-shadow-text" variant="success"
onclick={() => gameSession.startTimer()} onclick={() => gameSession.startTimer()}
> >
{m.kv_play_start()} {m.kv_play_start()}
</button> </KvButton>
{/if} {/if}
<button <KvButton
class="bg-transparent border-4 border-black box-border font-kv-body text-xl md:text-2xl text-kv-white uppercase px-4 py-2 cursor-pointer hover:opacity-80 kv-shadow-text" variant="ghost"
onclick={() => gameSession.resetTimer()} onclick={() => gameSession.resetTimer()}
> >
{m.kv_play_reset()} {m.kv_play_reset()}
</button> </KvButton>
</div> </div>
</div> </div>
<!-- After all judged: Show Scores button (answer auto-reveals) --> <!-- After all judged: Show Scores button (answer auto-reveals) -->
{#if session.finalRevealed.length === session.teams.length} {#if session.finalRevealed.length === session.teams.length}
<KvButtonPrimary <KvButton
variant="primary"
onclick={() => gameSession.showFinalScores()} onclick={() => gameSession.showFinalScores()}
> >
{m.kv_play_show_scores()} {m.kv_play_show_scores()}
</KvButtonPrimary> </KvButton>
{/if} {/if}
</div> </div>
{:else if session.phase === "final-scores"} {:else if session.phase === "final-scores"}
@ -813,21 +796,16 @@
class="bg-kv-blue flex flex-col items-center justify-center gap-2 p-2" class="bg-kv-blue flex flex-col items-center justify-center gap-2 p-2"
> >
<span <span
class="font-kv-body text-2xl md:text-4xl uppercase kv-shadow-text {team.score < class="kv-h1 {team.score < 0
0
? 'text-kv-red' ? 'text-kv-red'
: 'text-kv-white'}" : 'text-kv-white'}"
> >
{team.score} {team.score}
</span> </span>
<span <span class="kv-h3 text-kv-white">
class="font-kv-body text-lg md:text-2xl text-kv-white uppercase kv-shadow-text"
>
{team.name} {team.name}
</span> </span>
<span <span class="kv-h3 text-kv-white">
class="font-kv-body text-lg md:text-2xl text-kv-white uppercase kv-shadow-text"
>
#{i + 1} #{i + 1}
</span> </span>
</div> </div>
@ -844,21 +822,16 @@
class="bg-kv-blue flex flex-col items-center justify-center gap-2 p-2" class="bg-kv-blue flex flex-col items-center justify-center gap-2 p-2"
> >
<span <span
class="font-kv-body text-2xl md:text-4xl uppercase kv-shadow-text {team.score < class="kv-h1 {team.score < 0
0
? 'text-kv-red' ? 'text-kv-red'
: 'text-kv-white'}" : 'text-kv-white'}"
> >
{team.score} {team.score}
</span> </span>
<span <span class="kv-h3 text-kv-white">
class="font-kv-body text-lg md:text-2xl text-kv-white uppercase kv-shadow-text"
>
{team.name} {team.name}
</span> </span>
<span <span class="kv-h3 text-kv-white">
class="font-kv-body text-lg md:text-2xl text-kv-white uppercase kv-shadow-text"
>
#{i + topRowCount + 1} #{i + topRowCount + 1}
</span> </span>
</div> </div>
@ -866,11 +839,12 @@
</div> </div>
{/if} {/if}
<div class="flex justify-center p-4 bg-kv-blue"> <div class="flex justify-center p-4 bg-kv-blue">
<KvButtonPrimary <KvButton
variant="primary"
onclick={() => (showEndGameConfirm = true)} onclick={() => (showEndGameConfirm = true)}
> >
{m.kv_play_end_game()} {m.kv_play_end_game()}
</KvButtonPrimary> </KvButton>
</div> </div>
</div> </div>
{:else if session.phase === "finished"} {:else if session.phase === "finished"}
@ -878,14 +852,16 @@
<div <div
class="bg-kv-blue flex-1 flex flex-col items-center justify-center gap-8 p-8" class="bg-kv-blue flex-1 flex flex-col items-center justify-center gap-8 p-8"
> >
<span <span class="kv-h1 text-kv-yellow">
class="font-kv-title text-5xl md:text-7xl text-kv-yellow uppercase kv-shadow-text"
>
{m.kv_play_game_over()}! {m.kv_play_game_over()}!
</span> </span>
<KvButtonPrimary onclick={finishGame} disabled={isFinishing}> <KvButton
variant="primary"
onclick={finishGame}
disabled={isFinishing}
>
{m.kv_play_finish()} {m.kv_play_finish()}
</KvButtonPrimary> </KvButton>
</div> </div>
{/if} {/if}
</div> </div>

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

@ -9,7 +9,7 @@
--color-kv-blue: var(--kv-blue); --color-kv-blue: var(--kv-blue);
--color-kv-yellow: var(--kv-yellow); --color-kv-yellow: var(--kv-yellow);
--color-kv-green: #009900; --color-kv-green: #009900;
--color-kv-red: #FF3333; --color-kv-red: #CC0000;
--color-kv-black: var(--kv-background); --color-kv-black: var(--kv-background);
--color-kv-white: var(--kv-text); --color-kv-white: var(--kv-text);
/* Additional theme-aware colors */ /* Additional theme-aware colors */
@ -87,7 +87,7 @@
--kv-text: #FFFFFF; --kv-text: #FFFFFF;
--kv-background: #000000; --kv-background: #000000;
--kv-green: #009900; --kv-green: #009900;
--kv-red: #FF3333; --kv-red: #CC0000;
--kv-black: #000000; --kv-black: #000000;
--kv-white: #FFFFFF; --kv-white: #FFFFFF;
@ -195,6 +195,135 @@
text-shadow: var(--kv-shadow-text); text-shadow: var(--kv-shadow-text);
} }
/* ============================================
Kuldvillak Semantic Typography Scale
6-tier system: 3 headings + 3 body sizes
All text includes shadow by default
============================================ */
/* --- HEADINGS (with shadow) --- */
/* H1: Main Page Titles - 2.5rem/40px */
.kv-h1 {
font-family: var(--kv-font-body);
font-size: 2rem;
line-height: 1.1;
text-transform: uppercase;
text-shadow: var(--kv-shadow-text);
}
@media (min-width: 768px) {
.kv-h1 {
font-size: 2.5rem;
}
}
/* H2: Section Headers / Big Prompts - 1.875rem/30px */
.kv-h2 {
font-family: var(--kv-font-body);
font-size: 1.5rem;
line-height: 1.2;
text-transform: uppercase;
text-shadow: var(--kv-shadow-text);
}
@media (min-width: 768px) {
.kv-h2 {
font-size: 1.875rem;
}
}
/* H3: Card/Modal Titles - 1.5rem/24px */
.kv-h3 {
font-family: var(--kv-font-body);
font-size: 1.25rem;
line-height: 1.3;
text-transform: uppercase;
text-shadow: var(--kv-shadow-text);
}
@media (min-width: 768px) {
.kv-h3 {
font-size: 1.5rem;
}
}
/* --- BODY TEXT (with shadow) --- */
/* Body Large: Timer, Important Labels - 1.125rem/18px */
.kv-body-lg {
font-family: var(--kv-font-body);
font-size: 1rem;
line-height: 1.4;
text-transform: uppercase;
text-shadow: var(--kv-shadow-text);
}
@media (min-width: 768px) {
.kv-body-lg {
font-size: 1.125rem;
}
}
/* Body: Standard text - 1rem/16px */
.kv-body {
font-family: var(--kv-font-body);
font-size: 0.875rem;
line-height: 1.4;
text-transform: uppercase;
text-shadow: var(--kv-shadow-text);
}
@media (min-width: 768px) {
.kv-body {
font-size: 1rem;
}
}
/* Body Small: Captions, footers - 0.875rem/14px */
.kv-body-sm {
font-family: var(--kv-font-body);
font-size: 0.75rem;
line-height: 1.4;
text-transform: uppercase;
text-shadow: var(--kv-shadow-text);
}
@media (min-width: 768px) {
.kv-body-sm {
font-size: 0.875rem;
}
}
/* --- NO-SHADOW VARIANTS (for buttons, inputs) --- */
.kv-h1-plain,
.kv-h2-plain,
.kv-h3-plain,
.kv-body-lg-plain,
.kv-body-plain,
.kv-body-sm-plain {
font-family: var(--kv-font-body);
text-transform: uppercase;
text-shadow: none;
}
.kv-h1-plain { font-size: 2rem; line-height: 1.1; }
.kv-h2-plain { font-size: 1.5rem; line-height: 1.2; }
.kv-h3-plain { font-size: 1.25rem; line-height: 1.3; }
.kv-body-lg-plain { font-size: 1rem; line-height: 1.4; }
.kv-body-plain { font-size: 0.875rem; line-height: 1.4; }
.kv-body-sm-plain { font-size: 0.75rem; line-height: 1.4; }
@media (min-width: 768px) {
.kv-h1-plain { font-size: 2.5rem; }
.kv-h2-plain { font-size: 1.875rem; }
.kv-h3-plain { font-size: 1.5rem; }
.kv-body-lg-plain { font-size: 1.125rem; }
.kv-body-plain { font-size: 1rem; }
.kv-body-sm-plain { font-size: 0.875rem; }
}
/* ============================================ /* ============================================
Global Styles Global Styles
============================================ */ ============================================ */

@ -0,0 +1,5 @@
// Mock for $app/environment used in tests
export const browser = true;
export const building = false;
export const dev = true;
export const version = 'test';

@ -6,8 +6,8 @@ export default defineConfig({
environment: 'jsdom', environment: 'jsdom',
globals: true, globals: true,
alias: { alias: {
'$lib': '/src/lib', '$lib': new URL('./src/lib', import.meta.url).pathname,
'$app': '/src/app', '$app/environment': new URL('./src/test/mocks/app-environment.ts', import.meta.url).pathname,
}, },
}, },
}); });

Loading…
Cancel
Save