feat: US-008 - Frontend - Create habit modal with all options

This commit is contained in:
Echo
2026-02-10 16:36:52 +00:00
parent b99133de79
commit 60bf92a610
2 changed files with 848 additions and 3 deletions

View File

@@ -249,6 +249,279 @@
.priority-low {
background: var(--success);
}
/* Modal overlay */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 1000;
align-items: center;
justify-content: center;
padding: var(--space-4);
}
.modal-overlay.active {
display: flex;
}
.modal {
background: var(--bg-surface);
border-radius: var(--radius-lg);
max-width: 600px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
box-shadow: var(--shadow-lg);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-4);
border-bottom: 1px solid var(--border);
}
.modal-title {
font-size: var(--text-lg);
font-weight: 600;
color: var(--text-primary);
}
.modal-close {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: var(--space-1);
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
transition: all var(--transition-base);
}
.modal-close:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.modal-close svg {
width: 20px;
height: 20px;
}
.modal-body {
padding: var(--space-4);
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: var(--space-2);
padding: var(--space-4);
border-top: 1px solid var(--border);
}
/* Form fields */
.form-field {
margin-bottom: var(--space-4);
}
.form-label {
display: block;
font-size: var(--text-sm);
font-weight: 500;
color: var(--text-primary);
margin-bottom: var(--space-1);
}
.form-label.required::after {
content: '*';
color: var(--error);
margin-left: var(--space-1);
}
.form-input,
.form-select,
.form-textarea {
width: 100%;
padding: var(--space-2);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg-base);
color: var(--text-primary);
font-size: var(--text-sm);
transition: all var(--transition-base);
}
.form-input:focus,
.form-select:focus,
.form-textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-muted);
}
.form-textarea {
min-height: 80px;
resize: vertical;
font-family: inherit;
}
/* Color picker */
.color-picker-swatches {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: var(--space-2);
margin-bottom: var(--space-2);
}
.color-swatch {
width: 100%;
aspect-ratio: 1;
border: 2px solid transparent;
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-base);
}
.color-swatch:hover {
transform: scale(1.1);
}
.color-swatch.selected {
border-color: var(--text-primary);
box-shadow: 0 0 0 2px var(--bg-surface), 0 0 0 4px var(--accent);
}
/* Icon picker */
.icon-picker-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: var(--space-2);
max-height: 250px;
overflow-y: auto;
padding: var(--space-2);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg-base);
}
.icon-option {
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid transparent;
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-base);
background: var(--bg-surface);
}
.icon-option:hover {
background: var(--bg-hover);
transform: scale(1.1);
}
.icon-option.selected {
border-color: var(--accent);
background: var(--accent-muted);
}
.icon-option svg {
width: 20px;
height: 20px;
color: var(--text-primary);
}
/* Frequency params */
.frequency-params {
margin-top: var(--space-2);
}
.day-checkboxes {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: var(--space-2);
}
.day-checkbox-label {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-1);
padding: var(--space-2);
border: 1px solid var(--border);
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-base);
font-size: var(--text-xs);
}
.day-checkbox-label:has(input:checked) {
background: var(--accent-muted);
border-color: var(--accent);
}
.day-checkbox-label input {
margin: 0;
}
/* Toast notification */
.toast {
position: fixed;
bottom: var(--space-4);
right: var(--space-4);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
background: var(--bg-surface);
border: 1px solid var(--border);
box-shadow: var(--shadow-lg);
z-index: 2000;
display: flex;
align-items: center;
gap: var(--space-2);
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.toast.success {
border-color: var(--success);
}
.toast.error {
border-color: var(--error);
}
.toast svg {
width: 20px;
height: 20px;
}
.toast.success svg {
color: var(--success);
}
.toast.error svg {
color: var(--error);
}
</style>
</head>
<body>
@@ -301,6 +574,91 @@
</div>
</main>
<!-- Add/Edit Habit Modal -->
<div id="habitModal" class="modal-overlay">
<div class="modal">
<div class="modal-header">
<h2 class="modal-title">Add Habit</h2>
<button class="modal-close" onclick="closeHabitModal()">
<i data-lucide="x"></i>
</button>
</div>
<div class="modal-body">
<form id="habitForm">
<!-- Name -->
<div class="form-field">
<label class="form-label required" for="habitName">Name</label>
<input type="text" id="habitName" class="form-input" maxlength="100" required>
</div>
<!-- Category -->
<div class="form-field">
<label class="form-label" for="habitCategory">Category</label>
<select id="habitCategory" class="form-select">
<option value="work">Work</option>
<option value="health">Health</option>
<option value="growth">Growth</option>
<option value="personal">Personal</option>
</select>
</div>
<!-- Color -->
<div class="form-field">
<label class="form-label">Color</label>
<div class="color-picker-swatches" id="colorSwatches"></div>
<input type="text" id="customColor" class="form-input" placeholder="#RRGGBB" pattern="^#[0-9A-Fa-f]{6}$">
</div>
<!-- Icon -->
<div class="form-field">
<label class="form-label">Icon</label>
<div class="icon-picker-grid" id="iconPicker"></div>
</div>
<!-- Priority -->
<div class="form-field">
<label class="form-label" for="habitPriority">Priority (1-100)</label>
<input type="number" id="habitPriority" class="form-input" min="1" max="100" value="50">
</div>
<!-- Notes -->
<div class="form-field">
<label class="form-label" for="habitNotes">Notes</label>
<textarea id="habitNotes" class="form-textarea"></textarea>
</div>
<!-- Frequency Type -->
<div class="form-field">
<label class="form-label" for="frequencyType">Frequency</label>
<select id="frequencyType" class="form-select" onchange="updateFrequencyParams()">
<option value="daily">Daily</option>
<option value="specific_days">Specific Days</option>
<option value="x_per_week">X times per week</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
<option value="custom">Custom</option>
</select>
</div>
<!-- Frequency Params (conditional) -->
<div id="frequencyParams" class="frequency-params"></div>
<!-- Reminder Time -->
<div class="form-field">
<label class="form-label" for="reminderTime">Reminder Time (optional)</label>
<input type="time" id="reminderTime" class="form-input">
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn" onclick="closeHabitModal()">Cancel</button>
<button type="submit" class="btn btn-primary" id="submitHabitBtn" onclick="submitHabitForm(event)">
<span id="submitBtnText">Create Habit</span>
</button>
</div>
</div>
</div>
<script>
// Theme management
function initTheme() {
@@ -471,9 +829,283 @@
return 'low';
}
// Show add habit modal (placeholder - full modal in next stories)
// Modal state
let selectedColor = '#3B82F6';
let selectedIcon = 'dumbbell';
// Preset colors
const presetColors = [
'#EF4444', '#F97316', '#F59E0B', '#10B981',
'#3B82F6', '#8B5CF6', '#EC4899', '#6B7280'
];
// Common icons
const commonIcons = [
'dumbbell', 'moon', 'book', 'brain', 'heart', 'flame',
'star', 'target', 'trophy', 'coffee', 'music', 'camera',
'zap', 'sun', 'droplet', 'leaf', 'feather', 'pencil',
'smile', 'watch', 'footprints', 'activity', 'battery', 'headphones',
'utensils', 'apple', 'pizza', 'glass-water', 'pill', 'stethoscope',
'briefcase', 'laptop', 'smartphone', 'mail', 'calendar', 'clock'
];
// Show add habit modal
function showAddHabitModal() {
alert('Add Habit modal - coming in next story!');
const modal = document.getElementById('habitModal');
const form = document.getElementById('habitForm');
// Reset form
form.reset();
selectedColor = '#3B82F6';
selectedIcon = 'dumbbell';
// Initialize color picker
initColorPicker();
// Initialize icon picker
initIconPicker();
// Update frequency params for initial selection
updateFrequencyParams();
// Show modal
modal.classList.add('active');
lucide.createIcons();
}
// Close habit modal
function closeHabitModal() {
const modal = document.getElementById('habitModal');
modal.classList.remove('active');
}
// Close modal when clicking outside
document.addEventListener('click', (e) => {
const modal = document.getElementById('habitModal');
if (e.target === modal) {
closeHabitModal();
}
});
// Initialize color picker
function initColorPicker() {
const swatchesContainer = document.getElementById('colorSwatches');
swatchesContainer.innerHTML = presetColors.map(color =>
`<div class="color-swatch ${color === selectedColor ? 'selected' : ''}"
style="background-color: ${color}"
onclick="selectColor('${color}')"></div>`
).join('');
// Handle custom color input
const customColorInput = document.getElementById('customColor');
customColorInput.addEventListener('input', (e) => {
const value = e.target.value;
if (/^#[0-9A-Fa-f]{6}$/.test(value)) {
selectColor(value);
}
});
}
// Select color
function selectColor(color) {
selectedColor = color;
// Update swatches
const swatches = document.querySelectorAll('.color-swatch');
swatches.forEach(swatch => {
if (swatch.style.backgroundColor === color || rgbToHex(swatch.style.backgroundColor) === color) {
swatch.classList.add('selected');
} else {
swatch.classList.remove('selected');
}
});
// Update custom color input if not a preset
if (!presetColors.includes(color)) {
document.getElementById('customColor').value = color;
}
}
// Convert RGB to Hex
function rgbToHex(rgb) {
const match = rgb.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/);
if (!match) return rgb;
return '#' + [1, 2, 3].map(i => {
const hex = parseInt(match[i]).toString(16);
return hex.length === 1 ? '0' + hex : hex;
}).join('').toUpperCase();
}
// Initialize icon picker
function initIconPicker() {
const iconPickerContainer = document.getElementById('iconPicker');
iconPickerContainer.innerHTML = commonIcons.map(icon =>
`<div class="icon-option ${icon === selectedIcon ? 'selected' : ''}"
onclick="selectIcon('${icon}')">
<i data-lucide="${icon}"></i>
</div>`
).join('');
lucide.createIcons();
}
// Select icon
function selectIcon(icon) {
selectedIcon = icon;
// Update icon options
const iconOptions = document.querySelectorAll('.icon-option');
iconOptions.forEach((option, index) => {
if (commonIcons[index] === icon) {
option.classList.add('selected');
} else {
option.classList.remove('selected');
}
});
}
// Update frequency params based on selected type
function updateFrequencyParams() {
const frequencyType = document.getElementById('frequencyType').value;
const paramsContainer = document.getElementById('frequencyParams');
let html = '';
if (frequencyType === 'specific_days') {
html = `
<label class="form-label">Select Days</label>
<div class="day-checkboxes">
${['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map((day, index) => `
<label class="day-checkbox-label">
<input type="checkbox" name="day" value="${index}" checked>
<span>${day}</span>
</label>
`).join('')}
</div>
`;
} else if (frequencyType === 'x_per_week') {
html = `
<label class="form-label" for="xPerWeek">Times per week</label>
<input type="number" id="xPerWeek" class="form-input" min="1" max="7" value="3">
`;
} else if (frequencyType === 'custom') {
html = `
<label class="form-label" for="customInterval">Interval (days)</label>
<input type="number" id="customInterval" class="form-input" min="1" value="7">
`;
}
paramsContainer.innerHTML = html;
}
// Submit habit form
async function submitHabitForm(event) {
event.preventDefault();
const submitBtn = document.getElementById('submitHabitBtn');
const submitBtnText = document.getElementById('submitBtnText');
// Get form values
const name = document.getElementById('habitName').value.trim();
const category = document.getElementById('habitCategory').value;
const priority = parseInt(document.getElementById('habitPriority').value);
const notes = document.getElementById('habitNotes').value.trim();
const frequencyType = document.getElementById('frequencyType').value;
const reminderTime = document.getElementById('reminderTime').value;
// Validate name
if (!name) {
showToast('Please enter a habit name', 'error');
return;
}
if (name.length > 100) {
showToast('Habit name must be 100 characters or less', 'error');
return;
}
// Validate color
if (!/^#[0-9A-Fa-f]{6}$/.test(selectedColor)) {
showToast('Invalid color format', 'error');
return;
}
// Build frequency params
let frequencyParams = {};
if (frequencyType === 'specific_days') {
const checkedDays = Array.from(document.querySelectorAll('input[name="day"]:checked'))
.map(cb => parseInt(cb.value));
frequencyParams = { days: checkedDays };
} else if (frequencyType === 'x_per_week') {
const xPerWeek = parseInt(document.getElementById('xPerWeek').value);
frequencyParams = { count: xPerWeek };
} else if (frequencyType === 'custom') {
const interval = parseInt(document.getElementById('customInterval').value);
frequencyParams = { interval };
}
// Build habit object
const habitData = {
name,
category,
color: selectedColor,
icon: selectedIcon,
priority,
notes: notes || undefined,
frequency: {
type: frequencyType,
...frequencyParams
},
reminderTime: reminderTime || undefined
};
// Show loading state
submitBtn.disabled = true;
submitBtnText.textContent = 'Creating...';
try {
const response = await fetch('/echo/api/habits', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(habitData)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || `HTTP ${response.status}`);
}
// Success
showToast('Habit created successfully!', 'success');
closeHabitModal();
await loadHabits();
} catch (error) {
console.error('Failed to create habit:', error);
showToast('Failed to create habit: ' + error.message, 'error');
} finally {
submitBtn.disabled = false;
submitBtnText.textContent = 'Create Habit';
}
}
// Show toast notification
function showToast(message, type = 'success') {
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.innerHTML = `
<i data-lucide="${type === 'success' ? 'check-circle' : 'alert-circle'}"></i>
<span>${escapeHtml(message)}</span>
`;
document.body.appendChild(toast);
lucide.createIcons();
setTimeout(() => {
toast.remove();
}, 3000);
}
// Show edit habit modal (placeholder)

View File

@@ -2,6 +2,7 @@
Test suite for Habits frontend page structure and navigation
Story US-006: Frontend - Page structure, layout, and navigation link
Story US-007: Frontend - Habit card component
Story US-008: Frontend - Create habit modal with all options
"""
import sys
@@ -308,6 +309,205 @@ def test_typecheck_us007():
print("✓ Test 21: Typecheck passes (all functions defined)")
def test_modal_opens_on_add_habit_click():
"""Test 22: Modal opens when clicking 'Add Habit' button"""
habits_path = Path(__file__).parent.parent / 'habits.html'
content = habits_path.read_text()
assert 'showAddHabitModal()' in content, "Add Habit button should call showAddHabitModal()"
assert 'function showAddHabitModal(' in content, "showAddHabitModal function should be defined"
assert 'modal-overlay' in content or 'habitModal' in content, "Should have modal overlay element"
print("✓ Test 22: Modal opens on Add Habit button click")
def test_modal_closes_on_x_and_outside_click():
"""Test 23: Modal closes on X button or clicking outside"""
habits_path = Path(__file__).parent.parent / 'habits.html'
content = habits_path.read_text()
assert 'closeHabitModal()' in content, "Should have closeHabitModal function"
assert 'modal-close' in content or 'onclick="closeHabitModal()"' in content, \
"X button should call closeHabitModal()"
# Check for click outside handler
assert 'e.target === modal' in content or 'event.target' in content, \
"Should handle clicking outside modal"
print("✓ Test 23: Modal closes on X button and clicking outside")
def test_modal_has_all_form_fields():
"""Test 24: Form has all required fields"""
habits_path = Path(__file__).parent.parent / 'habits.html'
content = habits_path.read_text()
# Required fields
assert 'habitName' in content or 'name' in content.lower(), "Form should have name field"
assert 'habitCategory' in content or 'category' in content.lower(), "Form should have category field"
assert 'habitPriority' in content or 'priority' in content.lower(), "Form should have priority field"
assert 'habitNotes' in content or 'notes' in content.lower(), "Form should have notes field"
assert 'frequencyType' in content or 'frequency' in content.lower(), "Form should have frequency field"
assert 'reminderTime' in content or 'reminder' in content.lower(), "Form should have reminder time field"
print("✓ Test 24: Form has all required fields")
def test_color_picker_presets_and_custom():
"""Test 25: Color picker shows preset swatches and custom hex input"""
habits_path = Path(__file__).parent.parent / 'habits.html'
content = habits_path.read_text()
assert 'color-picker' in content or 'colorSwatches' in content or 'color-swatch' in content, \
"Should have color picker"
assert 'customColor' in content or 'custom' in content.lower(), \
"Should have custom color input"
assert '#RRGGBB' in content or 'pattern=' in content, \
"Custom color should have hex pattern"
assert 'presetColors' in content or '#3B82F6' in content or '#EF4444' in content, \
"Should have preset colors"
print("✓ Test 25: Color picker with presets and custom hex")
def test_icon_picker_grid():
"""Test 26: Icon picker shows grid of common Lucide icons"""
habits_path = Path(__file__).parent.parent / 'habits.html'
content = habits_path.read_text()
assert 'icon-picker' in content or 'iconPicker' in content, \
"Should have icon picker"
assert 'icon-option' in content or 'commonIcons' in content, \
"Should have icon options"
assert 'selectIcon' in content, "Should have selectIcon function"
# Check for common icons
icon_count = sum([1 for icon in ['dumbbell', 'moon', 'book', 'brain', 'heart']
if icon in content])
assert icon_count >= 3, "Should have at least 3 common icons"
print("✓ Test 26: Icon picker with grid of Lucide icons")
def test_frequency_params_conditional():
"""Test 27: Frequency params display conditionally based on type"""
habits_path = Path(__file__).parent.parent / 'habits.html'
content = habits_path.read_text()
assert 'updateFrequencyParams' in content, "Should have updateFrequencyParams function"
assert 'frequencyParams' in content, "Should have frequency params container"
assert 'specific_days' in content, "Should handle specific_days frequency"
assert 'x_per_week' in content, "Should handle x_per_week frequency"
assert 'custom' in content.lower(), "Should handle custom frequency"
# Check for conditional rendering (day checkboxes for specific_days)
assert 'day-checkbox' in content or "['Mon', 'Tue'" in content or 'Mon' in content, \
"Should have day checkboxes for specific_days"
print("✓ Test 27: Frequency params display conditionally")
def test_client_side_validation():
"""Test 28: Client-side validation prevents submit without name"""
habits_path = Path(__file__).parent.parent / 'habits.html'
content = habits_path.read_text()
assert 'required' in content, "Name field should be required"
assert 'trim()' in content, "Should trim input values"
# Check for validation in submit function
submit_func = content[content.find('function submitHabitForm'):]
assert 'if (!name)' in submit_func or 'name.length' in submit_func, \
"Should validate name is not empty"
assert 'showToast' in submit_func and 'error' in submit_func, \
"Should show error toast for validation failures"
print("✓ Test 28: Client-side validation checks name required")
def test_submit_posts_to_api():
"""Test 29: Submit sends POST /echo/api/habits and refreshes list"""
habits_path = Path(__file__).parent.parent / 'habits.html'
content = habits_path.read_text()
assert 'submitHabitForm' in content, "Should have submitHabitForm function"
submit_func = content[content.find('function submitHabitForm'):]
assert "fetch('/echo/api/habits'" in submit_func or 'fetch("/echo/api/habits"' in submit_func, \
"Should POST to /echo/api/habits"
assert "'POST'" in submit_func or '"POST"' in submit_func, \
"Should use POST method"
assert 'JSON.stringify' in submit_func, "Should send JSON body"
assert 'loadHabits()' in submit_func, "Should refresh habit list on success"
print("✓ Test 29: Submit POSTs to API and refreshes list")
def test_loading_state_on_submit():
"""Test 30: Loading state shown on submit button during API call"""
habits_path = Path(__file__).parent.parent / 'habits.html'
content = habits_path.read_text()
submit_func = content[content.find('function submitHabitForm'):]
assert 'disabled = true' in submit_func or '.disabled' in submit_func, \
"Submit button should be disabled during API call"
assert 'Creating' in submit_func or 'loading' in submit_func.lower(), \
"Should show loading text"
assert 'disabled = false' in submit_func, \
"Submit button should be re-enabled after API call"
print("✓ Test 30: Loading state on submit button")
def test_toast_notifications():
"""Test 31: Toast notification shown for success and error"""
habits_path = Path(__file__).parent.parent / 'habits.html'
content = habits_path.read_text()
assert 'showToast' in content, "Should have showToast function"
assert 'toast' in content, "Should have toast styling"
toast_func = content[content.find('function showToast'):]
assert 'success' in toast_func and 'error' in toast_func, \
"Toast should handle both success and error types"
assert 'check-circle' in toast_func or 'alert-circle' in toast_func, \
"Toast should show appropriate icons"
assert 'setTimeout' in toast_func or 'remove()' in toast_func, \
"Toast should auto-dismiss"
print("✓ Test 31: Toast notifications for success and error")
def test_modal_no_console_errors():
"""Test 32: No obvious console error sources in modal code"""
habits_path = Path(__file__).parent.parent / 'habits.html'
content = habits_path.read_text()
# Check that modal functions exist
assert 'function showAddHabitModal(' in content, "showAddHabitModal should be defined"
assert 'function closeHabitModal(' in content, "closeHabitModal should be defined"
assert 'function submitHabitForm(' in content, "submitHabitForm should be defined"
assert 'function updateFrequencyParams(' in content, "updateFrequencyParams should be defined"
# Check for proper error handling
submit_func = content[content.find('function submitHabitForm'):]
assert 'try' in submit_func and 'catch' in submit_func, \
"Submit function should have try-catch error handling"
print("✓ Test 32: No obvious console error sources")
def test_typecheck_us008():
"""Test 33: Typecheck passes for US-008 (all modal functions defined)"""
habits_path = Path(__file__).parent.parent / 'habits.html'
content = habits_path.read_text()
# Check all new functions are defined
required_functions = [
'showAddHabitModal',
'closeHabitModal',
'initColorPicker',
'selectColor',
'initIconPicker',
'selectIcon',
'updateFrequencyParams',
'submitHabitForm',
'showToast'
]
for func in required_functions:
assert f'function {func}(' in content or f'const {func} =' in content, \
f"{func} function should be defined"
print("✓ Test 33: Typecheck passes (all modal functions defined)")
def run_all_tests():
"""Run all tests in sequence"""
tests = [
@@ -334,9 +534,22 @@ def run_all_tests():
test_card_lucide_createicons,
test_card_common_css_variables,
test_typecheck_us007,
# US-008 tests
test_modal_opens_on_add_habit_click,
test_modal_closes_on_x_and_outside_click,
test_modal_has_all_form_fields,
test_color_picker_presets_and_custom,
test_icon_picker_grid,
test_frequency_params_conditional,
test_client_side_validation,
test_submit_posts_to_api,
test_loading_state_on_submit,
test_toast_notifications,
test_modal_no_console_errors,
test_typecheck_us008,
]
print(f"\nRunning {len(tests)} frontend tests for US-006 and US-007...\n")
print(f"\nRunning {len(tests)} frontend tests for US-006, US-007, and US-008...\n")
failed = []
for test in tests: