feat: implement modal dialogs with fullscreen support for challenges

Major UX improvement replacing inline challenge sections with professional modal dialogs:

**Modal System:**
- Clean challenge buttons with attractive call-to-action design
- Professional modal dialogs with smooth animations (fadeIn, slideIn)
- Full Fullscreen API support with cross-browser compatibility
- ESC key, outside click, and close button dismiss modals
- Mobile-responsive design with optimized sizing

**Quiz Integration:**
- Fixed critical bug where quiz ran behind modal (invisible)
- Moved all quiz elements (game, input, results) inside modal
- Updated JavaScript to work with modal structure
- Maintained all existing functionality and scoring systems

**Technical Implementation:**
- ModalManager class handles all modal functionality
- Cross-browser fullscreen support with fallbacks
- Responsive CSS Grid layout for challenge buttons
- Import compatibility fixes for both direct and module execution

**Benefits:**
- Cleaner main page (no cluttered challenge controls)
- Immersive fullscreen experience for distraction-free challenges
- Professional modern UI with smooth transitions
- Better focus and attention on individual challenges

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-09-10 06:48:34 -05:00
parent a1fd4c84d3
commit 9b6cabb111

View File

@@ -23,7 +23,11 @@ def get_numeral_color(number, config):
def generate_card_svgs(numbers, config):
"""Generate SVG content for each flashcard using existing Typst pipeline."""
from .generate import generate_cards_direct
try:
from .generate import generate_cards_direct
except ImportError:
# Fallback for when running tests directly
from generate import generate_cards_direct
# Create temporary directory for SVG generation
with tempfile.TemporaryDirectory() as tmpdir:
@@ -657,6 +661,29 @@ def generate_web_flashcards(numbers, config, output_path):
flex-wrap: wrap;
justify-content: center;
}}
.challenge-buttons {{
grid-template-columns: 1fr;
gap: 15px;
margin: 20px 0;
}}
.modal-content {{
width: 95%;
max-height: 95vh;
}}
.modal-header {{
padding: 15px 20px;
}}
.modal-body {{
padding: 20px;
}}
.modal-header h2 {{
font-size: 18px;
}}
}}
/* Card Sorting Styling */
@@ -1084,6 +1111,163 @@ def generate_web_flashcards(numbers, config, output_path):
animation: success-pulse 0.6s ease-in-out;
}}
/* Challenge Buttons */
.challenge-buttons {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin: 30px 0;
max-width: 800px;
margin-left: auto;
margin-right: auto;
}}
.challenge-btn {{
background: linear-gradient(135deg, #4a90e2, #357abd);
color: white;
border: none;
border-radius: 16px;
padding: 30px;
cursor: pointer;
transition: all 0.3s ease;
text-align: left;
box-shadow: 0 6px 20px rgba(74, 144, 226, 0.3);
}}
.challenge-btn:hover {{
transform: translateY(-3px);
box-shadow: 0 8px 25px rgba(74, 144, 226, 0.4);
}}
.challenge-btn h3 {{
margin: 0 0 10px 0;
font-size: 20px;
font-weight: bold;
}}
.challenge-btn p {{
margin: 0;
opacity: 0.9;
font-size: 14px;
line-height: 1.4;
}}
.sorting-btn {{
background: linear-gradient(135deg, #2c5f76, #1e4a61);
box-shadow: 0 6px 20px rgba(44, 95, 118, 0.3);
}}
.sorting-btn:hover {{
box-shadow: 0 8px 25px rgba(44, 95, 118, 0.4);
}}
/* Modal Styling */
.modal {{
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
animation: fadeIn 0.3s ease-out;
}}
.modal.show {{
display: flex;
align-items: center;
justify-content: center;
}}
.modal-content {{
background: white;
border-radius: 12px;
width: 90%;
max-width: 900px;
max-height: 90vh;
display: flex;
flex-direction: column;
animation: slideIn 0.3s ease-out;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
}}
.modal-header {{
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 30px;
border-bottom: 1px solid #eee;
background: linear-gradient(135deg, #f8f9fa, #e9ecef);
border-radius: 12px 12px 0 0;
}}
.modal-header h2 {{
margin: 0;
color: #333;
}}
.modal-controls {{
display: flex;
gap: 10px;
}}
.fullscreen-btn, .close-btn {{
background: none;
border: none;
font-size: 24px;
cursor: pointer;
padding: 5px 10px;
border-radius: 6px;
transition: background 0.2s ease;
}}
.fullscreen-btn:hover, .close-btn:hover {{
background: rgba(0, 0, 0, 0.1);
}}
.close-btn {{
color: #666;
}}
.close-btn:hover {{
color: #333;
}}
.modal-body {{
padding: 30px;
flex: 1;
overflow-y: auto;
}}
/* Fullscreen modal styling */
.modal.fullscreen {{
background: rgba(0, 0, 0, 0.95);
}}
.modal.fullscreen .modal-content {{
width: 100%;
height: 100%;
max-width: none;
max-height: none;
border-radius: 0;
}}
.modal.fullscreen .modal-header {{
border-radius: 0;
}}
/* Animations */
@keyframes fadeIn {{
from {{ opacity: 0; }}
to {{ opacity: 1; }}
}}
@keyframes slideIn {{
from {{ transform: scale(0.9) translateY(-20px); opacity: 0; }}
to {{ transform: scale(1) translateY(0); opacity: 1; }}
}}
@media print {{
body {{
background-color: white;
@@ -1126,10 +1310,30 @@ def generate_web_flashcards(numbers, config, output_path):
</div>
</div>
<!-- Speed Memory Quiz Section -->
<div class="quiz-section">
<h2>Speed Memory Quiz</h2>
<p>Test your soroban reading skills! Cards will be shown briefly, then you'll enter the numbers you remember.</p>
<!-- Challenge Buttons -->
<div class="challenge-buttons">
<button id="open-quiz-modal" class="challenge-btn quiz-btn">
<h3>Speed Memory Quiz</h3>
<p>Test your soroban reading skills with timed card displays</p>
</button>
<button id="open-sorting-modal" class="challenge-btn sorting-btn">
<h3>Card Sorting Challenge</h3>
<p>Arrange cards in order using only the abacus representations</p>
</button>
</div>
<!-- Quiz Modal -->
<div id="quiz-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Speed Memory Quiz</h2>
<div class="modal-controls">
<button id="quiz-fullscreen-btn" class="fullscreen-btn" title="Toggle Fullscreen">⛶</button>
<button id="close-quiz-modal" class="close-btn">&times;</button>
</div>
</div>
<div class="modal-body">
<p>Test your soroban reading skills! Cards will be shown briefly, then you'll enter the numbers you remember.</p>
<div class="quiz-controls">
<div class="control-group">
@@ -1153,12 +1357,75 @@ def generate_web_flashcards(numbers, config, output_path):
<button id="start-quiz" class="quiz-start-btn">Start Quiz</button>
</div>
<!-- Quiz Game Area (hidden initially) -->
<div id="quiz-game" class="quiz-game" style="display: none;">
<div class="quiz-progress">
<div class="progress-bar">
<div class="progress-fill"></div>
</div>
<span class="progress-text">Card <span id="current-card">1</span> of <span id="total-cards">10</span></span>
</div>
<div class="quiz-display">
<div id="quiz-card" class="quiz-flashcard">
<!-- Card content will be inserted here -->
</div>
<div id="quiz-countdown" class="countdown">Get Ready...</div>
</div>
</div>
<!-- Quiz Input Phase (hidden initially) -->
<div id="quiz-input" class="quiz-input" style="display: none;">
<h3>Enter the Numbers You Remember</h3>
<p>Type the numbers you saw, separated by commas or spaces:</p>
<div class="input-container">
<textarea id="answer-input" placeholder="e.g., 23, 45, 67 or 23 45 67"></textarea>
<button id="submit-answers">Submit Answers</button>
</div>
</div>
<!-- Quiz Results (hidden initially) -->
<div id="quiz-results" class="quiz-results" style="display: none;">
<h3>Quiz Results</h3>
<div class="score-display">
<div class="score-circle">
<span id="score-percentage">0%</span>
</div>
<div class="score-details">
<p><strong>Score:</strong> <span id="score-correct">0</span> / <span id="score-total">0</span> correct</p>
<p><strong>Time per card:</strong> <span id="result-timing">2.0s</span></p>
</div>
</div>
<div class="results-breakdown">
<h4>Detailed Results:</h4>
<div id="results-list">
<!-- Results will be inserted here -->
</div>
</div>
<div class="quiz-actions">
<button id="retry-quiz">Try Again</button>
<button id="back-to-cards">Back to Cards</button>
</div>
</div>
</div>
</div>
</div>
<!-- Card Sorting Challenge Section -->
<div class="sorting-section">
<h2>Card Sorting Challenge</h2>
<p>Drag and drop the cards to arrange them in ascending order (smallest to largest). No numerals shown - rely on reading the abacus!</p>
<!-- Sorting Modal -->
<div id="sorting-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Card Sorting Challenge</h2>
<div class="modal-controls">
<button id="sorting-fullscreen-btn" class="fullscreen-btn" title="Toggle Fullscreen">⛶</button>
<button id="close-sorting-modal" class="close-btn">&times;</button>
</div>
</div>
<div class="modal-body">
<p>Click cards and positions to arrange them in ascending order (smallest to largest). No numerals shown - rely on reading the abacus!</p>
<div class="sorting-controls">
<div class="control-group">
@@ -1201,58 +1468,7 @@ def generate_web_flashcards(numbers, config, output_path):
<!-- Feedback will be shown here -->
</div>
</div>
</div>
<!-- Quiz Game Area (hidden initially) -->
<div id="quiz-game" class="quiz-game" style="display: none;">
<div class="quiz-progress">
<div class="progress-bar">
<div class="progress-fill"></div>
</div>
<span class="progress-text">Card <span id="current-card">1</span> of <span id="total-cards">10</span></span>
</div>
<div class="quiz-display">
<div id="quiz-card" class="quiz-flashcard">
<!-- Card content will be inserted here -->
</div>
<div id="quiz-countdown" class="countdown">Get Ready...</div>
</div>
</div>
<!-- Quiz Input Phase (hidden initially) -->
<div id="quiz-input" class="quiz-input" style="display: none;">
<h3>Enter the Numbers You Remember</h3>
<p>Type the numbers you saw, separated by commas or spaces:</p>
<div class="input-container">
<textarea id="answer-input" placeholder="e.g., 23, 45, 67 or 23 45 67"></textarea>
<button id="submit-answers">Submit Answers</button>
</div>
</div>
<!-- Quiz Results (hidden initially) -->
<div id="quiz-results" class="quiz-results" style="display: none;">
<h3>Quiz Results</h3>
<div class="score-display">
<div class="score-circle">
<span id="score-percentage">0%</span>
</div>
<div class="score-details">
<p><strong>Score:</strong> <span id="score-correct">0</span> / <span id="score-total">0</span> correct</p>
<p><strong>Time per card:</strong> <span id="result-timing">2.0s</span></p>
</div>
</div>
<div class="results-breakdown">
<h4>Detailed Results:</h4>
<div id="results-list">
<!-- Results will be inserted here -->
</div>
</div>
<div class="quiz-actions">
<button id="retry-quiz">Try Again</button>
<button id="back-to-cards">Back to Cards</button>
</div>
</div>
@@ -1266,6 +1482,145 @@ def generate_web_flashcards(numbers, config, output_path):
</div>
<script>
// Modal Manager - Handles modal dialogs and fullscreen functionality
class ModalManager {{
constructor() {{
this.isFullscreen = false;
this.bindEvents();
}}
bindEvents() {{
// Challenge button events
document.getElementById('open-quiz-modal').addEventListener('click', () => {{
this.openModal('quiz-modal');
}});
document.getElementById('open-sorting-modal').addEventListener('click', () => {{
this.openModal('sorting-modal');
}});
// Close button events
document.getElementById('close-quiz-modal').addEventListener('click', () => {{
this.closeModal('quiz-modal');
}});
document.getElementById('close-sorting-modal').addEventListener('click', () => {{
this.closeModal('sorting-modal');
}});
// Fullscreen button events
document.getElementById('quiz-fullscreen-btn').addEventListener('click', () => {{
this.toggleFullscreen('quiz-modal');
}});
document.getElementById('sorting-fullscreen-btn').addEventListener('click', () => {{
this.toggleFullscreen('sorting-modal');
}});
// Close modal when clicking outside
document.addEventListener('click', (e) => {{
if (e.target.classList.contains('modal') && e.target.classList.contains('show')) {{
this.closeModal(e.target.id);
}}
}});
// ESC key to close modal
document.addEventListener('keydown', (e) => {{
if (e.key === 'Escape') {{
const openModal = document.querySelector('.modal.show');
if (openModal) {{
this.closeModal(openModal.id);
}}
}}
}});
// Fullscreen change events
document.addEventListener('fullscreenchange', () => {{
this.handleFullscreenChange();
}});
document.addEventListener('webkitfullscreenchange', () => {{
this.handleFullscreenChange();
}});
document.addEventListener('mozfullscreenchange', () => {{
this.handleFullscreenChange();
}});
document.addEventListener('MSFullscreenChange', () => {{
this.handleFullscreenChange();
}});
}}
openModal(modalId) {{
const modal = document.getElementById(modalId);
modal.classList.add('show');
document.body.style.overflow = 'hidden';
}}
closeModal(modalId) {{
const modal = document.getElementById(modalId);
if (this.isFullscreen) {{
this.exitFullscreen();
}}
modal.classList.remove('show', 'fullscreen');
document.body.style.overflow = '';
}}
async toggleFullscreen(modalId) {{
const modal = document.getElementById(modalId);
if (!this.isFullscreen) {{
try {{
if (modal.requestFullscreen) {{
await modal.requestFullscreen();
}} else if (modal.webkitRequestFullscreen) {{
await modal.webkitRequestFullscreen();
}} else if (modal.mozRequestFullScreen) {{
await modal.mozRequestFullScreen();
}} else if (modal.msRequestFullscreen) {{
await modal.msRequestFullscreen();
}}
modal.classList.add('fullscreen');
}} catch (error) {{
console.warn('Fullscreen not supported or failed:', error);
// Fallback to CSS fullscreen
modal.classList.add('fullscreen');
}}
}} else {{
this.exitFullscreen();
}}
}}
exitFullscreen() {{
if (document.exitFullscreen) {{
document.exitFullscreen();
}} else if (document.webkitExitFullscreen) {{
document.webkitExitFullscreen();
}} else if (document.mozCancelFullScreen) {{
document.mozCancelFullScreen();
}} else if (document.msExitFullscreen) {{
document.msExitFullscreen();
}}
}}
handleFullscreenChange() {{
const isFullscreen = !!(document.fullscreenElement ||
document.webkitFullscreenElement ||
document.mozFullScreenElement ||
document.msFullscreenElement);
this.isFullscreen = isFullscreen;
if (!isFullscreen) {{
// Remove fullscreen class from all modals when exiting fullscreen
document.querySelectorAll('.modal').forEach(modal => {{
modal.classList.remove('fullscreen');
}});
}}
}}
}}
// Quiz functionality - No dependencies, pure JavaScript
class SorobanQuiz {{
constructor() {{
@@ -1330,21 +1685,18 @@ def generate_web_flashcards(numbers, config, output_path):
}});
}}
startQuiz() {{
async startQuiz() {{
// Select random cards
this.quizCards = this.getRandomCards(this.selectedCount);
this.correctAnswers = this.quizCards.map(card => card.number);
this.currentCardIndex = 0;
// Hide other sections, show quiz
this.hideAllSections();
// Show quiz game section within modal
this.hideQuizSections();
document.getElementById('quiz-game').style.display = 'block';
document.getElementById('total-cards').textContent = this.quizCards.length;
// Scroll to quiz area
document.getElementById('quiz-game').scrollIntoView({{ behavior: 'smooth' }});
// Start the card sequence
// Start with the first card
this.showNextCard();
}}
@@ -1423,12 +1775,9 @@ def generate_web_flashcards(numbers, config, output_path):
document.querySelector('.progress-fill').style.width = '100%';
// Hide quiz game, show input
document.getElementById('quiz-game').style.display = 'none';
this.hideQuizSections();
document.getElementById('quiz-input').style.display = 'block';
document.getElementById('answer-input').focus();
// Scroll to input area
document.getElementById('quiz-input').scrollIntoView({{ behavior: 'smooth' }});
}}
submitAnswers() {{
@@ -1460,11 +1809,8 @@ def generate_web_flashcards(numbers, config, output_path):
this.showDetailedResults(correct);
// Hide input, show results
document.getElementById('quiz-input').style.display = 'none';
this.hideQuizSections();
document.getElementById('quiz-results').style.display = 'block';
// Scroll to results
document.getElementById('quiz-results').scrollIntoView({{ behavior: 'smooth' }});
}}
calculateScore() {{
@@ -1522,21 +1868,14 @@ def generate_web_flashcards(numbers, config, output_path):
// Clear input
document.getElementById('answer-input').value = '';
// Hide all quiz sections, show main cards
this.hideAllSections();
document.getElementById('cards-grid').style.display = 'grid';
document.querySelector('.quiz-section').style.display = 'block';
// Scroll back to top
document.querySelector('.header').scrollIntoView({{ behavior: 'smooth' }});
// Reset to initial quiz state (hide all sections, show controls)
this.hideQuizSections();
}}
hideAllSections() {{
hideQuizSections() {{
document.getElementById('quiz-game').style.display = 'none';
document.getElementById('quiz-input').style.display = 'none';
document.getElementById('quiz-results').style.display = 'none';
document.getElementById('cards-grid').style.display = 'none';
document.querySelector('.quiz-section').style.display = 'none';
}}
delay(ms) {{
@@ -2388,6 +2727,7 @@ def generate_web_flashcards(numbers, config, output_path):
// Initialize quiz and sorting when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {{
new ModalManager();
new SorobanQuiz();
new SortingChallenge();
}});