feat: add web output format with interactive hover flashcards
Add new web output format that generates a static HTML page with: - Inline SVG abacus representations using existing Typst->SVG pipeline - Hover functionality to reveal numerals on cards - Responsive grid layout that works on desktop and mobile - Print-friendly styles for offline practice - Support for all existing configuration options (colors, shapes, etc.) Usage: python3 src/generate.py --format web --range 1-10 --output cards.html The web format reuses the existing visual generation code, ensuring consistency with PDF/PNG/SVG outputs while providing an interactive learning experience in the browser. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -266,7 +266,7 @@ def main():
|
||||
parser.add_argument('--scale-factor', type=float, default=0.9, help='Manual scale adjustment (0.1 to 1.0, default: 0.9)')
|
||||
|
||||
# Output format options
|
||||
parser.add_argument('--format', '-f', choices=['pdf', 'png', 'svg'], default='pdf', help='Output format (default: pdf)')
|
||||
parser.add_argument('--format', '-f', choices=['pdf', 'png', 'svg', 'web'], default='pdf', help='Output format (default: pdf)')
|
||||
parser.add_argument('--output', '-o', type=str, help='Output path (default: out/flashcards.FORMAT or out/FORMAT)')
|
||||
|
||||
# PDF-specific options
|
||||
@@ -366,12 +366,14 @@ def main():
|
||||
output_path = Path(args.output)
|
||||
elif args.format == 'pdf':
|
||||
output_path = Path('out/flashcards.pdf')
|
||||
elif args.format == 'web':
|
||||
output_path = Path('out/flashcards.html')
|
||||
else:
|
||||
# For PNG/SVG, use directory instead of file
|
||||
output_path = Path(f'out/{args.format}')
|
||||
|
||||
# Create output directory
|
||||
if args.format == 'pdf':
|
||||
if args.format in ['pdf', 'web']:
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
else:
|
||||
output_path.mkdir(parents=True, exist_ok=True)
|
||||
@@ -454,6 +456,23 @@ def main():
|
||||
raise
|
||||
sys.exit(1)
|
||||
|
||||
elif args.format == 'web':
|
||||
# Generate web flashcards (HTML with inline SVG)
|
||||
try:
|
||||
from web_generator import generate_web_flashcards
|
||||
|
||||
result_path = generate_web_flashcards(numbers, final_config, output_path)
|
||||
print(f"\n✓ Generated web flashcards: {result_path}")
|
||||
print(f" Open in browser to view interactive flashcards")
|
||||
|
||||
except FileNotFoundError as e:
|
||||
if 'typst' in str(e):
|
||||
print("Error: typst command not found. Please install Typst first.", file=sys.stderr)
|
||||
print("Visit: https://github.com/typst/typst", file=sys.stderr)
|
||||
else:
|
||||
raise
|
||||
sys.exit(1)
|
||||
|
||||
else:
|
||||
# Generate PNG/SVG (individual cards)
|
||||
try:
|
||||
|
||||
340
src/web_generator.py
Normal file
340
src/web_generator.py
Normal file
@@ -0,0 +1,340 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Web flashcard generator for Soroban abacus cards.
|
||||
Generates static HTML with inline SVG abacus representations using existing Typst->SVG pipeline.
|
||||
"""
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_numeral_color(number, config):
|
||||
"""Get color for numeral based on configuration."""
|
||||
if not config.get('colored_numerals', False):
|
||||
return "#333"
|
||||
|
||||
color_scheme = config.get('color_scheme', 'monochrome')
|
||||
if color_scheme == 'monochrome':
|
||||
return "#333"
|
||||
else:
|
||||
# For colored schemes, use a darker color for visibility
|
||||
return "#222"
|
||||
|
||||
|
||||
def generate_card_svgs(numbers, config):
|
||||
"""Generate SVG content for each flashcard using existing Typst pipeline."""
|
||||
from generate import generate_cards_direct
|
||||
|
||||
# Create temporary directory for SVG generation
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmpdir_path = Path(tmpdir)
|
||||
svg_dir = tmpdir_path / 'svg_cards'
|
||||
|
||||
# Configure for web-optimized SVGs
|
||||
web_config = {
|
||||
**config,
|
||||
'card_width': '4in', # Fixed size for web (Typst needs units)
|
||||
'card_height': '2.5in',
|
||||
'transparent': True, # Transparent background for web
|
||||
'scale_factor': 0.8 # Slightly smaller for web
|
||||
}
|
||||
|
||||
# Generate SVG files using existing pipeline
|
||||
generated_files = generate_cards_direct(
|
||||
numbers, web_config, svg_dir,
|
||||
format='svg', separate_fronts_backs=True
|
||||
)
|
||||
|
||||
# Read SVG contents
|
||||
card_data = {}
|
||||
fronts_dir = svg_dir / 'fronts'
|
||||
|
||||
for i, number in enumerate(numbers):
|
||||
front_file = fronts_dir / f'card_{i:03d}.svg'
|
||||
if front_file.exists():
|
||||
with open(front_file, 'r') as f:
|
||||
svg_content = f.read()
|
||||
# Remove XML declaration and DOCTYPE if present
|
||||
if svg_content.startswith('<?xml'):
|
||||
svg_content = svg_content.split('>', 1)[1]
|
||||
card_data[number] = svg_content.strip()
|
||||
else:
|
||||
# Fallback if SVG generation failed
|
||||
card_data[number] = f'<svg width="300" height="200"><text x="150" y="100" text-anchor="middle" font-size="48">{number}</text></svg>'
|
||||
|
||||
return card_data
|
||||
|
||||
|
||||
def generate_web_flashcards(numbers, config, output_path):
|
||||
"""Generate HTML file with flashcard layout."""
|
||||
|
||||
# Generate SVG content for all cards
|
||||
print(f"Generating SVG content for {len(numbers)} cards...")
|
||||
card_svgs = generate_card_svgs(numbers, config)
|
||||
|
||||
# Generate individual cards HTML
|
||||
cards_html = []
|
||||
for i, number in enumerate(numbers):
|
||||
svg_content = card_svgs.get(number, f'<svg width="300" height="200"><text x="150" y="100" text-anchor="middle" font-size="48">Error</text></svg>')
|
||||
numeral_color = get_numeral_color(number, config)
|
||||
|
||||
card_html = f'''
|
||||
<div class="flashcard" data-number="{number}">
|
||||
<div class="card-number">#{i+1}</div>
|
||||
<div class="abacus-container">
|
||||
{svg_content}
|
||||
</div>
|
||||
<div class="numeral" style="color: {numeral_color};">{number}</div>
|
||||
</div>'''
|
||||
|
||||
cards_html.append(card_html)
|
||||
|
||||
# Configuration descriptions
|
||||
color_schemes = {
|
||||
'monochrome': 'All beads are the same color',
|
||||
'place-value': 'Each place value (ones, tens, hundreds) has a different color',
|
||||
'heaven-earth': 'Heaven beads (5-value) and earth beads (1-value) have different colors',
|
||||
'alternating': 'Columns alternate between two colors'
|
||||
}
|
||||
|
||||
color_scheme_description = color_schemes.get(
|
||||
config.get('color_scheme', 'monochrome'),
|
||||
'Monochrome color scheme'
|
||||
)
|
||||
|
||||
# Format font size for CSS
|
||||
font_size = config.get('font_size', '48pt')
|
||||
if not font_size.endswith(('px', 'pt', 'em', 'rem', '%')):
|
||||
font_size = font_size + 'px' # Add px if no unit specified
|
||||
|
||||
# HTML template
|
||||
html_template = '''<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Soroban Flashcards</title>
|
||||
<style>
|
||||
body {{
|
||||
font-family: {font_family}, sans-serif;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
line-height: 1.6;
|
||||
}}
|
||||
|
||||
.container {{
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}}
|
||||
|
||||
.header {{
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}}
|
||||
|
||||
.header h1 {{
|
||||
color: #333;
|
||||
font-size: 2.5em;
|
||||
margin-bottom: 10px;
|
||||
}}
|
||||
|
||||
.header p {{
|
||||
color: #666;
|
||||
font-size: 1.2em;
|
||||
}}
|
||||
|
||||
.cards-grid {{
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 20px;
|
||||
margin: 20px 0;
|
||||
}}
|
||||
|
||||
.flashcard {{
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
min-height: 220px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}}
|
||||
|
||||
.flashcard:hover {{
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 16px rgba(0,0,0,0.15);
|
||||
}}
|
||||
|
||||
.abacus-container {{
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin: 10px 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}}
|
||||
|
||||
.abacus-container svg {{
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}}
|
||||
|
||||
.numeral {{
|
||||
font-size: {font_size};
|
||||
font-weight: bold;
|
||||
color: {numeral_color};
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: rgba(255,255,255,0.95);
|
||||
padding: 15px 25px;
|
||||
border-radius: 8px;
|
||||
border: 2px solid #ddd;
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
|
||||
z-index: 10;
|
||||
}}
|
||||
|
||||
.flashcard:hover .numeral {{
|
||||
opacity: 1;
|
||||
}}
|
||||
|
||||
.card-number {{
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
font-size: 0.8em;
|
||||
color: #999;
|
||||
background: rgba(255,255,255,0.8);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}}
|
||||
|
||||
.instructions {{
|
||||
text-align: center;
|
||||
margin: 30px 0;
|
||||
padding: 20px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}}
|
||||
|
||||
.instructions h3 {{
|
||||
color: #333;
|
||||
margin-bottom: 10px;
|
||||
}}
|
||||
|
||||
.instructions p {{
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
}}
|
||||
|
||||
.stats {{
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 15px;
|
||||
}}
|
||||
|
||||
.stats div {{
|
||||
background: #f8f9fa;
|
||||
padding: 10px 15px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9em;
|
||||
}}
|
||||
|
||||
@media (max-width: 768px) {{
|
||||
.cards-grid {{
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 15px;
|
||||
}}
|
||||
|
||||
.flashcard {{
|
||||
min-height: 200px;
|
||||
padding: 15px;
|
||||
}}
|
||||
|
||||
.numeral {{
|
||||
font-size: calc({font_size} * 0.8);
|
||||
padding: 10px 20px;
|
||||
}}
|
||||
}}
|
||||
|
||||
@media print {{
|
||||
body {{
|
||||
background-color: white;
|
||||
}}
|
||||
.flashcard {{
|
||||
box-shadow: none;
|
||||
border: 1px solid #ddd;
|
||||
break-inside: avoid;
|
||||
}}
|
||||
.numeral {{
|
||||
opacity: 0.5;
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}}
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>Soroban Flashcards</h1>
|
||||
<p>Hover over the cards to reveal the numbers</p>
|
||||
</div>
|
||||
|
||||
<div class="instructions">
|
||||
<h3>How to use these flashcards:</h3>
|
||||
<p>Look at each abacus representation and try to determine the number before hovering to reveal the answer.
|
||||
The abacus shows numbers using beads: each column represents a place value (ones, tens, hundreds, etc.).
|
||||
In each column, the top bead represents 5 and the bottom beads each represent 1.</p>
|
||||
|
||||
<div class="stats">
|
||||
<div><strong>Cards:</strong> {card_count}</div>
|
||||
<div><strong>Range:</strong> {number_range}</div>
|
||||
<div><strong>Color Scheme:</strong> {color_scheme_description}</div>
|
||||
<div><strong>Bead Shape:</strong> {bead_shape}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cards-grid">
|
||||
{cards_html}
|
||||
</div>
|
||||
|
||||
<div class="instructions">
|
||||
<p><em>Tip: You can print these cards for offline practice. Numbers will be faintly visible in print mode.</em></p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>'''
|
||||
|
||||
# Fill template
|
||||
html_content = html_template.format(
|
||||
font_family=config.get('font_family', 'DejaVu Sans').replace('"', ''),
|
||||
font_size=font_size,
|
||||
numeral_color=get_numeral_color(numbers[0] if numbers else 0, config),
|
||||
cards_html=''.join(cards_html),
|
||||
color_scheme_description=color_scheme_description,
|
||||
bead_shape=config.get('bead_shape', 'diamond').capitalize(),
|
||||
card_count=len(numbers),
|
||||
number_range=f"{min(numbers)} - {max(numbers)}" if numbers else "0"
|
||||
)
|
||||
|
||||
# Write HTML file
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
f.write(html_content)
|
||||
|
||||
print(f"Generated web flashcards: {output_path}")
|
||||
return output_path
|
||||
206
tests/test_web_generation.py
Normal file
206
tests/test_web_generation.py
Normal file
@@ -0,0 +1,206 @@
|
||||
"""Tests for web flashcard generation."""
|
||||
|
||||
import pytest
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
# Import web generator functions
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / 'src'))
|
||||
|
||||
from web_generator import generate_web_flashcards, get_numeral_color, generate_card_svgs
|
||||
|
||||
|
||||
class TestWebGeneration:
|
||||
"""Test web flashcard generation functionality."""
|
||||
|
||||
def test_get_numeral_color_monochrome(self, sample_config):
|
||||
"""Test numeral color for monochrome scheme."""
|
||||
config = {**sample_config, 'color_scheme': 'monochrome', 'colored_numerals': False}
|
||||
color = get_numeral_color(42, config)
|
||||
assert color == "#333"
|
||||
|
||||
# Even with colored numerals, monochrome should return #333
|
||||
config['colored_numerals'] = True
|
||||
color = get_numeral_color(42, config)
|
||||
assert color == "#333"
|
||||
|
||||
def test_get_numeral_color_place_value(self, sample_config):
|
||||
"""Test numeral color for place-value scheme."""
|
||||
config = {**sample_config, 'color_scheme': 'place-value', 'colored_numerals': True}
|
||||
color = get_numeral_color(42, config)
|
||||
assert color == "#222" # Darker color for visibility
|
||||
|
||||
# Without colored numerals, should return default
|
||||
config['colored_numerals'] = False
|
||||
color = get_numeral_color(42, config)
|
||||
assert color == "#333"
|
||||
|
||||
@patch('generate.generate_cards_direct')
|
||||
def test_generate_card_svgs_success(self, mock_generate_cards_direct, sample_config, temp_dir):
|
||||
"""Test successful SVG generation."""
|
||||
# Mock the generated files
|
||||
fronts_dir = temp_dir / 'svg_cards' / 'fronts'
|
||||
fronts_dir.mkdir(parents=True)
|
||||
|
||||
# Create mock SVG files
|
||||
for i, num in enumerate([1, 2, 3]):
|
||||
svg_file = fronts_dir / f'card_{i:03d}.svg'
|
||||
svg_content = f'<svg width="300" height="200"><text>{num}</text></svg>'
|
||||
svg_file.write_text(svg_content)
|
||||
|
||||
# Mock the generate_cards_direct return
|
||||
mock_generate_cards_direct.return_value = [
|
||||
fronts_dir / 'card_000.svg',
|
||||
fronts_dir / 'card_001.svg',
|
||||
fronts_dir / 'card_002.svg',
|
||||
]
|
||||
|
||||
# Mock tempfile to use our temp_dir
|
||||
with patch('tempfile.TemporaryDirectory') as mock_tempdir:
|
||||
mock_tempdir.return_value.__enter__.return_value = str(temp_dir)
|
||||
|
||||
result = generate_card_svgs([1, 2, 3], sample_config)
|
||||
|
||||
assert len(result) == 3
|
||||
assert 1 in result
|
||||
assert 2 in result
|
||||
assert 3 in result
|
||||
assert '<svg' in result[1]
|
||||
assert '<text>1</text>' in result[1]
|
||||
|
||||
@patch('generate.generate_cards_direct')
|
||||
def test_generate_card_svgs_fallback(self, mock_generate_cards_direct, sample_config, temp_dir):
|
||||
"""Test fallback when SVG generation fails."""
|
||||
# Mock generate_cards_direct to return empty list (failure)
|
||||
mock_generate_cards_direct.return_value = []
|
||||
|
||||
with patch('tempfile.TemporaryDirectory') as mock_tempdir:
|
||||
mock_tempdir.return_value.__enter__.return_value = str(temp_dir)
|
||||
|
||||
result = generate_card_svgs([1, 2], sample_config)
|
||||
|
||||
assert len(result) == 2
|
||||
assert '<svg width="300" height="200"' in result[1]
|
||||
assert 'font-size="48"' in result[1] # Fallback SVG
|
||||
|
||||
def test_generate_web_flashcards_structure(self, temp_dir, sample_config):
|
||||
"""Test web flashcards HTML structure."""
|
||||
numbers = [7, 23]
|
||||
output_file = temp_dir / 'test.html'
|
||||
|
||||
# Mock the SVG generation
|
||||
with patch('web_generator.generate_card_svgs') as mock_svg_gen:
|
||||
mock_svg_gen.return_value = {
|
||||
7: '<svg width="300" height="200"><rect></rect></svg>',
|
||||
23: '<svg width="300" height="200"><circle></circle></svg>'
|
||||
}
|
||||
|
||||
result_path = generate_web_flashcards(numbers, sample_config, output_file)
|
||||
|
||||
assert result_path == output_file
|
||||
assert output_file.exists()
|
||||
|
||||
content = output_file.read_text()
|
||||
|
||||
# Check HTML structure
|
||||
assert '<!DOCTYPE html>' in content
|
||||
assert '<html lang="en">' in content
|
||||
assert '<title>Soroban Flashcards</title>' in content
|
||||
assert 'Hover over the cards to reveal the numbers' in content
|
||||
|
||||
# Check cards are present
|
||||
assert 'data-number="7"' in content
|
||||
assert 'data-number="23"' in content
|
||||
|
||||
# Check SVG content is embedded
|
||||
assert '<svg width="300" height="200"><rect></rect></svg>' in content
|
||||
assert '<svg width="300" height="200"><circle></circle></svg>' in content
|
||||
|
||||
# Check CSS classes
|
||||
assert '.flashcard' in content
|
||||
assert '.numeral' in content
|
||||
assert '.abacus-container' in content
|
||||
|
||||
def test_generate_web_flashcards_config_integration(self, temp_dir, sample_config):
|
||||
"""Test that configuration options are properly integrated."""
|
||||
numbers = [42]
|
||||
output_file = temp_dir / 'test.html'
|
||||
|
||||
config = {
|
||||
**sample_config,
|
||||
'color_scheme': 'place-value',
|
||||
'bead_shape': 'circle',
|
||||
'colored_numerals': True,
|
||||
'font_size': '36pt',
|
||||
'font_family': 'Arial'
|
||||
}
|
||||
|
||||
with patch('web_generator.generate_card_svgs') as mock_svg_gen:
|
||||
mock_svg_gen.return_value = {42: '<svg><rect></rect></svg>'}
|
||||
|
||||
generate_web_flashcards(numbers, config, output_file)
|
||||
content = output_file.read_text()
|
||||
|
||||
# Check config values are used
|
||||
assert 'font-family: Arial' in content
|
||||
assert '36pt' in content
|
||||
assert 'place value' in content.lower()
|
||||
assert 'Circle' in content # Bead shape capitalized
|
||||
|
||||
def test_generate_web_flashcards_empty_numbers(self, temp_dir, sample_config):
|
||||
"""Test handling of empty numbers list."""
|
||||
output_file = temp_dir / 'empty.html'
|
||||
|
||||
with patch('web_generator.generate_card_svgs') as mock_svg_gen:
|
||||
mock_svg_gen.return_value = {}
|
||||
|
||||
result_path = generate_web_flashcards([], sample_config, output_file)
|
||||
|
||||
assert result_path == output_file
|
||||
assert output_file.exists()
|
||||
|
||||
content = output_file.read_text()
|
||||
assert '<title>Soroban Flashcards</title>' in content
|
||||
assert 'Cards:</strong> 0' in content
|
||||
assert 'Range:</strong> 0' in content
|
||||
|
||||
def test_web_flashcards_responsive_design(self, temp_dir, sample_config):
|
||||
"""Test that responsive CSS is included."""
|
||||
numbers = [1]
|
||||
output_file = temp_dir / 'responsive.html'
|
||||
|
||||
with patch('web_generator.generate_card_svgs') as mock_svg_gen:
|
||||
mock_svg_gen.return_value = {1: '<svg><rect></rect></svg>'}
|
||||
|
||||
generate_web_flashcards(numbers, sample_config, output_file)
|
||||
content = output_file.read_text()
|
||||
|
||||
# Check mobile responsiveness
|
||||
assert '@media (max-width: 768px)' in content
|
||||
assert 'grid-template-columns: repeat(auto-fill, minmax(280px, 1fr))' in content
|
||||
|
||||
# Check print styles
|
||||
assert '@media print' in content
|
||||
assert 'break-inside: avoid' in content
|
||||
|
||||
def test_web_flashcards_accessibility(self, temp_dir, sample_config):
|
||||
"""Test accessibility features."""
|
||||
numbers = [5]
|
||||
output_file = temp_dir / 'accessible.html'
|
||||
|
||||
with patch('web_generator.generate_card_svgs') as mock_svg_gen:
|
||||
mock_svg_gen.return_value = {5: '<svg><rect></rect></svg>'}
|
||||
|
||||
generate_web_flashcards(numbers, sample_config, output_file)
|
||||
content = output_file.read_text()
|
||||
|
||||
# Check accessibility attributes
|
||||
assert 'lang="en"' in content
|
||||
assert 'charset="UTF-8"' in content
|
||||
assert 'viewport' in content
|
||||
|
||||
# Check that hover states don't rely only on hover
|
||||
assert 'cursor: pointer' in content
|
||||
assert 'transition:' in content
|
||||
Reference in New Issue
Block a user