Compare commits

..

No commits in common. 'a1fc0c596a985285319212bc0f6e37d6c5c0910a' and '8ec3a2b10cecbc82d89c2215222d4cc9768eae4f' have entirely different histories.

  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. 1135
      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,17 +1,21 @@
Copyright (c) 2025 Sass
MIT License
PERSONAL USE ONLY
Copyright (c) 2024
This source code is provided for personal, non-commercial use only.
You may use this code to run the game locally and provide feedback.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
You may NOT:
- Redistribute or publish this code
- Create derivative works
- Use this commercially
- Remove or modify this license
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
This license does NOT cover any game assets, sounds, graphics, or other
media files, which remain property of their respective copyright holders.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

@ -1,6 +1,6 @@
# Ultimate Gaming 🎮
A web-based platform for hosting trivia game shows, featuring **Kuldvillak** (Estonian Jeopardy) and **Rooside Sõda** (Estonian Family Feud, coming soon).
A web-based platform for hosting trivia game shows, featuring **Kuldvillak** (Estonian Jeopardy).
## Features
@ -26,6 +26,9 @@ npm install
# Start development server
npm run dev
# Open in browser
npm run dev -- --open
```
## Usage
@ -35,7 +38,6 @@ npm run dev
3. **Fill Content**: Add categories, questions, and answers
4. **Start Playing**: Click "Start" to launch the game
5. **Open Projector**: Use "Open Projector" button for display screen
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
@ -64,9 +66,8 @@ npm run build
npm run preview
```
**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.
## License
## Usage
This game is shared for friends to play locally and provide feedback only.
Please respect the limitations in the LICENSE file.
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.*

@ -1,7 +1,7 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"app_title": "Ultimate Gaming",
"app_version": "v0.1.1",
"app_version": "v0.1.0",
"coming_soon": "Coming Soon",
"game_kuldvillak": "Jeopardy",
"game_rooside_soda": "Family Feud",
@ -30,16 +30,27 @@
"kv_play_timer": "Time to Answer",
"kv_play_timer_reveal": "Answer Reveal",
"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_preset_normal": "Normal",
"kv_edit_preset_double": "Double",
"kv_edit_x_base": "Multiplier",
"kv_edit_custom": "Custom",
"kv_edit_base": "Base",
"kv_edit_negative_scores": "Negative Scores",
"kv_edit_daily_doubles": "Daily Doubles",
"kv_edit_r1": "Jeopardy",
"kv_edit_r2": "Double Jeopardy",
"kv_edit_teams_label": "Players",
"kv_edit_round_1": "Jeopardy",
"kv_edit_round_2": "Double Jeopardy",
"kv_edit_dd_count": "Daily Double",
"kv_edit_category": "Category",
"kv_edit_dd": "★",
"kv_edit_no_category": "(No Category Yet)",
"kv_edit_question": "Question",
"kv_edit_answer": "Answer",
"kv_edit_daily_double": "Daily Double",
@ -51,17 +62,40 @@
"kv_toast_game_saved": "Game saved!",
"kv_toast_game_loaded": "Game loaded!",
"kv_toast_invalid_file": "Invalid game file",
"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_confirm": "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_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_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_remove_team": "Remove",
"kv_play_loading": "Loading game...",
"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_round": "Round",
"kv_play_phase": "Phase",
"kv_play_last_answer": "Last",
"kv_play_introduce_categories": "Introduce Categories",
"kv_play_skip_to_game": "Skip to Game",
@ -70,8 +104,11 @@
"kv_play_daily_double": "Daily Double",
"kv_play_wager": "Wager",
"kv_play_confirm": "Confirm",
"kv_play_question_number": "Question {current}/{total}",
"kv_play_showing_answer": "Showing Answer...",
"kv_play_question_short": "Q",
"kv_play_answer_short": "A",
"kv_play_answering": "Answering",
"kv_play_correct": "Correct",
"kv_play_wrong": "Wrong",
"kv_play_skip": "Skip / No Answer",
@ -82,21 +119,23 @@
"kv_play_reveal_category": "Reveal Category",
"kv_play_reveal_answer": "Reveal Answer",
"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_go_to_final": "Go to Final Round",
"kv_play_end_game": "End Game",
"kv_play_end_game_confirm": "End the game?",
"kv_play_open_projector": "Open Projector",
"kv_play_projector_url": "Projector",
"kv_play_game_over": "Game Over",
"kv_edit_values": "Values",
"kv_edit_disabled": "Disabled",
"kv_edit_rules": "Jeopardy Rules",
"kv_edit_how_to": "How to Play?",
"kv_settings_colors": "Colors",
"kv_randomize": "Randomize",
"kv_randomize_all_colors": "Randomize All Colors",
"kv_settings_primary": "Primary Color",
"kv_settings_secondary": "Secondary Color",
"kv_settings_primary": "Primary",
"kv_settings_secondary": "Secondary",
"kv_settings_text_color": "Text",
"kv_settings_background": "Background",
"kv_settings_reset": "Reset Settings",
@ -119,7 +158,7 @@
"kv_play_judging": "Judging",
"kv_play_enter_wager": "Enter wager",
"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_color_picker": "Color Picker",
"kv_done": "Done",

@ -1,7 +1,7 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"app_title": "Sassi Mängukoobas",
"app_version": "v0.1.1",
"app_version": "v0.1.0",
"coming_soon": "Tulekul",
"game_kuldvillak": "Kuldvillak",
"game_rooside_soda": "Rooside Sõda",
@ -17,51 +17,85 @@
"kv_settings_close": "Välju",
"kv_error_404": "404",
"kv_error_not_found": "Lehte ei leitud",
"kv_error_hint": "(pssst... oled proovinud taaskäivitamist?)",
"kv_edit_title": "Muuda mängu",
"kv_error_hint": "(pssst... oled proovinud arvuti taaskäivitamist?)",
"kv_edit_title": "Mängu redaktor",
"kv_edit_back": "Tagasi",
"kv_edit_game_name": "Mängu nimi...",
"kv_edit_save": "Salvesta",
"kv_edit_load": "Lae",
"kv_edit_reset": "Lähtesta",
"kv_edit_start": "Alusta",
"kv_edit_settings_teams": "Mängu seaded",
"kv_edit_settings_teams": "Mängu seadistus",
"kv_edit_rounds": "Voorude arv",
"kv_play_timer": "Vastamisaeg",
"kv_play_timer_reveal": "Vastuse näitamine",
"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_preset_normal": "Tavaline",
"kv_edit_preset_double": "Duubel",
"kv_edit_x_base": "Kordaja",
"kv_edit_custom": "Kohandatud",
"kv_edit_base": "Baas",
"kv_edit_negative_scores": "Negatiivsed punktid",
"kv_edit_daily_doubles": "Hõbevillak",
"kv_edit_r1": "Villak",
"kv_edit_r2": "Topeltvillak",
"kv_edit_teams_label": "Mängijad",
"kv_edit_round_1": "Villak",
"kv_edit_round_2": "Topeltvillak",
"kv_edit_dd_count": "Hõbevillak",
"kv_edit_category": "Kategooria",
"kv_edit_dd": "★",
"kv_edit_no_category": "(Kategooria puudub)",
"kv_edit_question": "Küsimus",
"kv_edit_answer": "Vastus",
"kv_edit_daily_double": "Hõbevillak",
"kv_edit_starting_game": "Alustan mängu...",
"kv_edit_opening_projector": "Avan projektori vaate",
"kv_error_min_players": "Vaja on vähemalt 2 mängijat",
"kv_error_no_questions": "{round}. voorul pole küsimusi",
"kv_error_no_questions": "Voorul {round} pole küsimusi",
"kv_error_no_final": "Finaalvooru küsimus on tühi",
"kv_toast_game_saved": "Mäng salvestatud!",
"kv_toast_game_loaded": "Mäng laetud!",
"kv_toast_invalid_file": "Vigane mängufail",
"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_confirm": "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_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_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_remove_team": "Eemalda",
"kv_play_loading": "Laen mängu...",
"kv_play_loading_hint": "Kui see võtab liiga kaua, siis mängu pole alustatud.",
"kv_play_go_to_editor": "Muuda mängu",
"kv_play_round": "Laen...",
"kv_play_go_to_editor": "Mine redaktorisse",
"kv_play_round": "Voor",
"kv_play_phase": "Hetkeseis",
"kv_play_last_answer": "Viimane",
"kv_play_introduce_categories": "Tutvusta kategooriaid",
"kv_play_skip_to_game": "Jäta vahele",
@ -70,8 +104,11 @@
"kv_play_daily_double": "Hõbevillak",
"kv_play_wager": "Panus",
"kv_play_confirm": "Kinnita",
"kv_play_question_short": "Küsimus",
"kv_play_question_number": "Küsimus {current}/{total}",
"kv_play_showing_answer": "Näitan vastust...",
"kv_play_question_short": "K",
"kv_play_answer_short": "Vastus",
"kv_play_answering": "Vastab",
"kv_play_correct": "Õige",
"kv_play_wrong": "Vale",
"kv_play_skip": "Jäta vahele",
@ -82,19 +119,21 @@
"kv_play_reveal_category": "Näita kategooriat",
"kv_play_reveal_answer": "Näita vastust",
"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_go_to_final": "Finaalvooru",
"kv_play_end_game": "Lõpeta mäng",
"kv_play_end_game_confirm": "Kas soovid mängu lõpetada?",
"kv_play_end_game_confirm": "Lõpeta mäng?",
"kv_play_open_projector": "Ava projektor",
"kv_play_projector_url": "Projektor",
"kv_play_game_over": "Mäng läbi",
"kv_edit_values": "Väärtused",
"kv_edit_disabled": "Lisa",
"kv_edit_disabled": "Keelatud",
"kv_edit_rules": "Kuldvillaku reeglid",
"kv_edit_how_to": "Kuidas mängida?",
"kv_settings_colors": "Värvid",
"kv_randomize": "Suvaline",
"kv_randomize_all_colors": "Muuda suvaliselt kõiki värve",
"kv_settings_primary": "Primaarne",
"kv_settings_secondary": "Sekundaarne",
"kv_settings_text_color": "Tekst",
@ -102,15 +141,15 @@
"kv_settings_reset": "Lähtesta seaded",
"kv_settings_save_exit": "Salvesta ja välju",
"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_play_adjust_score": "Muuda skoori",
"kv_play_click_team_to_answer": "Kliki meeskonnal vastajaks",
"kv_play_final_scores": "Lõpptulemused",
"kv_edit_dd_short": "HV",
"kv_play_timeout_reveal": "Aeg sai läbi, keegi ei saa vastata. Vastuse näitamine {seconds} sekundi pärast...",
"kv_play_answer_revealed": "Vastus näidatud. Tagasi mängulauale {seconds} sekundi pärast...",
"kv_play_timer_paused": "Taimer peatatud, {name} vastab.",
"kv_play_answer_revealed": "Vastus näidatud. Tagasi mängulaudale {seconds} sekundi pärast...",
"kv_play_timer_paused": "Taimer peatatud. {name} vastab.",
"kv_play_correct_return": "{name} vastas õigesti! Tagasi mängulaudale {seconds} sekundi pärast...",
"kv_play_wrong_waiting": "{name} vastas valesti. Ootame järgmist mängijat...",
"kv_play_wrong_reveal": "{name} vastas valesti. Vastuse näitamine {seconds} sekundi pärast...",
@ -119,24 +158,24 @@
"kv_play_judging": "Hindamine",
"kv_play_enter_wager": "Sisesta panus",
"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_color_picker": "Värvivalija",
"kv_done": "Valmis",
"kv_opacity": "Läbipaistvus",
"kv_confirm_close_title": "Loobu muudatustest?",
"kv_confirm_close_title": "Loobuda muudatustest?",
"kv_confirm_close_message": "Kas oled kindel, et soovid sulgeda? Salvestamata muudatused lähevad kaotsi.",
"kv_confirm_discard": "Loobu",
"kv_confirm_cancel": "Mine tagasi",
"kv_confirm_cancel": "Tühista",
"kv_final_round": "Kuldvillak",
"kv_tutorial_rules_placeholder": "Lisa siia reeglite seletus",
"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_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_rules_placeholder": "Platvormikoht: 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_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_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_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-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!",
"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!",
"error_title": "Midagi läks valesti",
"error_description": "Tekkis ootamatu viga. Palun proovi uuesti.",
"error_details": "Tehnilised detailid",

14
package-lock.json generated

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

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

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

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

@ -1,28 +0,0 @@
<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}

@ -1,153 +0,0 @@
<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>

@ -1,284 +0,0 @@
<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}
/>

@ -1,118 +0,0 @@
<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>

@ -1,265 +0,0 @@
<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>

@ -1,53 +0,0 @@
<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>

@ -1,6 +0,0 @@
// 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,13 +3,9 @@ export { default as Slider } from './Slider.svelte';
export { default as Settings } from './Settings.svelte';
export { default as LanguageSwitcher } from './LanguageSwitcher.svelte';
export { default as Toast } from './Toast.svelte';
export { default as ToastContainer } from './ToastContainer.svelte';
export { default as ConfirmDialog } from './ConfirmDialog.svelte';
export { default as ColorPicker } from './ColorPicker.svelte';
export { default as ErrorBoundary } from './ErrorBoundary.svelte';
// Kuldvillak Components
export * from './kuldvillak';
// Editor Components
export * from './editor';

@ -1,101 +0,0 @@
<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}

@ -0,0 +1,48 @@
<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}

@ -0,0 +1,48 @@
<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}

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

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

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

@ -14,12 +14,6 @@ export { themeStore } from './stores/theme.svelte';
// Audio Store
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)
export * from './stores/persistence';

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

@ -1,116 +0,0 @@
// ============================================
// 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();

@ -1,56 +0,0 @@
// ============================================
// 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];

@ -1,448 +0,0 @@
// ============================================
// 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();

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

@ -1,26 +0,0 @@
/**
* 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,15 +2,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
import type { KuldvillakGame } 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
const localStorageMock = (() => {
let store: Record<string, string> = {};
@ -165,7 +156,7 @@ describe('Persistence', () => {
const duplicate = duplicateKuldvillakGame(original.id);
expect(duplicate?.state.phase).toBe('intro');
expect(duplicate?.state.phase).toBe('lobby');
expect(duplicate?.teams[0].score).toBe(0);
});

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

@ -1,15 +1,6 @@
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";
// BroadcastChannel for syncing theme across windows
@ -18,16 +9,31 @@ if (browser) {
channel = new BroadcastChannel(THEME_CHANNEL_NAME);
}
// Load initial values from storage
// Default theme colors
export const DEFAULT_THEME = {
primary: "#003B9B",
secondary: "#FFAB00",
text: "#FFFFFF",
background: "#000000",
};
// Load initial values from localStorage
function getInitialTheme() {
const saved = storage.get<typeof DEFAULT_THEME>(STORAGE_KEYS.THEME);
if (browser) {
const saved = localStorage.getItem(THEME_STORAGE_KEY);
if (saved) {
try {
const theme = JSON.parse(saved);
return {
primary: saved.primary ?? DEFAULT_THEME.primary,
secondary: saved.secondary ?? DEFAULT_THEME.secondary,
text: saved.text ?? DEFAULT_THEME.text,
background: saved.background ?? DEFAULT_THEME.background,
primary: theme.primary ?? DEFAULT_THEME.primary,
secondary: theme.secondary ?? DEFAULT_THEME.secondary,
text: theme.text ?? DEFAULT_THEME.text,
background: theme.background ?? DEFAULT_THEME.background,
};
} catch {
// Ignore parse errors
}
}
}
return { ...DEFAULT_THEME };
}
@ -40,7 +46,7 @@ let secondary = $state(initialTheme.secondary);
let text = $state(initialTheme.text);
let background = $state(initialTheme.background);
// Saved values (what's persisted to storage)
// Saved values (what's persisted to localStorage)
let savedPrimary = $state(initialTheme.primary);
let savedSecondary = $state(initialTheme.secondary);
let savedText = $state(initialTheme.text);
@ -72,19 +78,21 @@ if (browser && channel) {
};
}
// Save current values to storage
// Save current values to localStorage
function save() {
storage.set(STORAGE_KEYS.THEME, {
if (browser) {
localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify({
primary,
secondary,
text,
background
});
}));
// Update saved state
savedPrimary = primary;
savedSecondary = secondary;
savedText = text;
savedBackground = background;
}
}
// Revert to last saved values (for cancel/close without saving)
@ -105,82 +113,7 @@ function resetToDefaults() {
applyTheme();
}
// 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();
}
// Reset and save (for full reset)
function reset() {
resetToDefaults();
save();
@ -200,6 +133,4 @@ export const themeStore = {
revert,
reset,
resetToDefaults,
getRandomColor,
randomizeColors,
};

@ -1,178 +0,0 @@
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);
});
});

@ -1,60 +0,0 @@
// 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();

@ -1,123 +0,0 @@
// ============================================
// 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());
}

@ -1,104 +0,0 @@
// ============================================
// 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",
};

@ -1,47 +0,0 @@
// 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);
}
};
}

@ -1,155 +0,0 @@
// ============================================
// 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 favicon from "$lib/assets/favicon.svg";
import { themeStore } from "$lib/stores/theme.svelte";
import { ErrorBoundary, ToastContainer } from "$lib/components";
import { ErrorBoundary } from "$lib/components";
import { onMount } from "svelte";
let { children } = $props();
@ -20,5 +20,3 @@
<ErrorBoundary>
{@render children()}
</ErrorBoundary>
<ToastContainer />

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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

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

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

@ -9,7 +9,7 @@
--color-kv-blue: var(--kv-blue);
--color-kv-yellow: var(--kv-yellow);
--color-kv-green: #009900;
--color-kv-red: #CC0000;
--color-kv-red: #FF3333;
--color-kv-black: var(--kv-background);
--color-kv-white: var(--kv-text);
/* Additional theme-aware colors */
@ -87,7 +87,7 @@
--kv-text: #FFFFFF;
--kv-background: #000000;
--kv-green: #009900;
--kv-red: #CC0000;
--kv-red: #FF3333;
--kv-black: #000000;
--kv-white: #FFFFFF;
@ -195,135 +195,6 @@
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
============================================ */

@ -1,5 +0,0 @@
// 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',
globals: true,
alias: {
'$lib': new URL('./src/lib', import.meta.url).pathname,
'$app/environment': new URL('./src/test/mocks/app-environment.ts', import.meta.url).pathname,
'$lib': '/src/lib',
'$app': '/src/app',
},
},
});

Loading…
Cancel
Save