diff --git a/dashboard/habits.html b/dashboard/habits.html
index ed833a6..8e8b8b1 100644
--- a/dashboard/habits.html
+++ b/dashboard/habits.html
@@ -626,7 +626,84 @@
box-shadow: 0 0 0 2px var(--bg-surface), 0 0 0 4px var(--accent);
}
- /* Icon picker */
+ /* Icon picker dropdown */
+ .icon-picker-dropdown {
+ position: relative;
+ }
+
+ .icon-picker-trigger {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: var(--space-2) var(--space-3);
+ background: var(--bg-surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ cursor: pointer;
+ transition: all var(--transition-base);
+ min-height: 44px;
+ }
+
+ .icon-picker-trigger:hover {
+ background: var(--bg-hover);
+ border-color: var(--accent);
+ }
+
+ .icon-picker-trigger svg {
+ width: 20px;
+ height: 20px;
+ color: var(--text-primary);
+ }
+
+ .icon-picker-trigger span {
+ flex: 1;
+ text-align: left;
+ color: var(--text-primary);
+ }
+
+ .trigger-chevron {
+ transition: transform var(--transition-base);
+ }
+
+ .icon-picker-trigger.open .trigger-chevron {
+ transform: rotate(180deg);
+ }
+
+ .icon-picker-content {
+ position: absolute;
+ top: calc(100% + var(--space-1));
+ left: 0;
+ right: 0;
+ background: var(--bg-surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+ z-index: 100;
+ display: none;
+ max-height: 300px;
+ overflow: hidden;
+ }
+
+ .icon-picker-content.visible {
+ display: block;
+ }
+
+ .icon-search-input {
+ width: 100%;
+ padding: var(--space-2);
+ border: none;
+ border-bottom: 1px solid var(--border);
+ background: var(--bg-base);
+ color: var(--text-primary);
+ font-size: var(--text-sm);
+ outline: none;
+ }
+
+ .icon-search-input:focus {
+ border-bottom-color: var(--accent);
+ }
+
.icon-picker-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
@@ -634,9 +711,6 @@
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 {
@@ -649,6 +723,7 @@
cursor: pointer;
transition: all var(--transition-base);
background: var(--bg-surface);
+ min-height: 44px;
}
.icon-option:hover {
@@ -1125,7 +1200,17 @@
@@ -1855,6 +1940,69 @@
`
).join('');
+
+ // Update trigger button with selected icon
+ const selectedIconDisplay = document.getElementById('selectedIconDisplay');
+ selectedIconDisplay.setAttribute('data-lucide', selectedIcon);
+
+ lucide.createIcons();
+ }
+
+ // Toggle icon picker dropdown
+ function toggleIconPicker() {
+ const content = document.getElementById('iconPickerContent');
+ const trigger = document.getElementById('iconPickerTrigger');
+ const isOpen = content.classList.contains('visible');
+
+ if (isOpen) {
+ closeIconPicker();
+ } else {
+ openIconPicker();
+ }
+ }
+
+ // Open icon picker dropdown
+ function openIconPicker() {
+ const content = document.getElementById('iconPickerContent');
+ const trigger = document.getElementById('iconPickerTrigger');
+ const search = document.getElementById('iconSearch');
+
+ content.classList.add('visible');
+ trigger.classList.add('open');
+
+ // Reset search
+ search.value = '';
+ filterIcons();
+
+ // Focus search input
+ setTimeout(() => search.focus(), 50);
+ }
+
+ // Close icon picker dropdown
+ function closeIconPicker() {
+ const content = document.getElementById('iconPickerContent');
+ const trigger = document.getElementById('iconPickerTrigger');
+
+ content.classList.remove('visible');
+ trigger.classList.remove('open');
+ }
+
+ // Filter icons based on search query
+ function filterIcons() {
+ const searchQuery = document.getElementById('iconSearch').value.toLowerCase();
+ const iconPickerContainer = document.getElementById('iconPicker');
+
+ const filteredIcons = commonIcons.filter(icon =>
+ icon.toLowerCase().includes(searchQuery)
+ );
+
+ iconPickerContainer.innerHTML = filteredIcons.map(icon =>
+ `
+
+
`
+ ).join('');
+
lucide.createIcons();
}
@@ -1864,13 +2012,20 @@
// 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');
- }
+ iconOptions.forEach(option => {
+ option.classList.remove('selected');
});
+
+ // Add selected class to clicked option
+ event.target.closest('.icon-option')?.classList.add('selected');
+
+ // Update trigger button display
+ const selectedIconDisplay = document.getElementById('selectedIconDisplay');
+ selectedIconDisplay.setAttribute('data-lucide', icon);
+ lucide.createIcons();
+
+ // Close dropdown after selection
+ closeIconPicker();
}
// Update frequency params based on selected type
@@ -2472,6 +2627,18 @@
}
});
+ // Click outside icon picker to close
+ document.addEventListener('click', (e) => {
+ const dropdown = document.querySelector('.icon-picker-dropdown');
+ const content = document.getElementById('iconPickerContent');
+
+ if (content && content.classList.contains('visible')) {
+ if (!dropdown.contains(e.target)) {
+ closeIconPicker();
+ }
+ }
+ });
+
// Restore collapsed/expanded state from localStorage
restoreWeeklySummaryState();
diff --git a/dashboard/tests/test_habits_frontend.py b/dashboard/tests/test_habits_frontend.py
index 4ce2d50..c344885 100644
--- a/dashboard/tests/test_habits_frontend.py
+++ b/dashboard/tests/test_habits_frontend.py
@@ -1767,9 +1767,23 @@ def run_all_tests():
test_css_transition_300ms,
test_height_constraint_collapsed,
test_typecheck_us005,
+ # US-006 tests
+ test_icon_picker_is_dropdown,
+ test_icon_picker_has_search_input,
+ test_icon_picker_max_height_300px,
+ test_icon_picker_toggle_functions,
+ test_icon_picker_visible_class_toggle,
+ test_icon_picker_filter_function,
+ test_icon_picker_closes_on_selection,
+ test_icon_picker_click_outside_closes,
+ test_icon_picker_updates_trigger_display,
+ test_icon_picker_css_dropdown_styles,
+ test_icon_picker_chevron_rotation,
+ test_icon_picker_search_autofocus,
+ test_typecheck_us006,
]
- print(f"\nRunning {len(tests)} frontend tests for US-002, US-003, US-005, US-006 through US-014...\n")
+ print(f"\nRunning {len(tests)} frontend tests for US-002, US-003, US-004, US-005, US-006 through US-014...\n")
failed = []
for test in tests:
@@ -2530,3 +2544,190 @@ def test_typecheck_us005():
if __name__ == '__main__':
run_all_tests()
+
+# US-006: Frontend: Icon picker as compact dropdown
+def test_icon_picker_is_dropdown():
+ """Test 151: Icon picker renders as dropdown with trigger button"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ html = habits_path.read_text()
+
+ # Check for dropdown structure
+ assert 'icon-picker-dropdown' in html, "Icon picker should be a dropdown"
+ assert 'icon-picker-trigger' in html, "Dropdown should have trigger button"
+ assert 'icon-picker-content' in html, "Dropdown should have content container"
+
+ # Check trigger button has icon display and chevron
+ assert 'selectedIconDisplay' in html, "Trigger should display selected icon"
+ assert 'iconPickerChevron' in html, "Trigger should have chevron icon"
+ assert 'chevron-down' in html, "Chevron should be down arrow"
+
+ print("✓ Test 151 passed: Icon picker is dropdown structure")
+
+def test_icon_picker_has_search_input():
+ """Test 152: Dropdown has search input for filtering icons"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ html = habits_path.read_text()
+
+ # Check for search input
+ assert 'icon-search-input' in html, "Dropdown should have search input"
+ assert 'id="iconSearch"' in html, "Search input should have iconSearch id"
+ assert 'placeholder="Search icons..."' in html, "Search input should have placeholder"
+ assert 'oninput="filterIcons()"' in html, "Search input should trigger filterIcons()"
+
+ print("✓ Test 152 passed: Dropdown has search input")
+
+def test_icon_picker_max_height_300px():
+ """Test 153: Dropdown content has max-height 300px with scroll"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ html = habits_path.read_text()
+
+ # Check for max-height in CSS
+ assert 'max-height: 300px' in html, "Icon picker content should have max-height 300px"
+ assert 'overflow-y: auto' in html or 'overflow: hidden' in html, "Should have overflow handling"
+
+ print("✓ Test 153 passed: Max-height 300px with scroll")
+
+def test_icon_picker_toggle_functions():
+ """Test 154: Toggle functions exist for opening/closing dropdown"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ html = habits_path.read_text()
+
+ # Check for toggle functions
+ assert 'function toggleIconPicker()' in html, "toggleIconPicker function should exist"
+ assert 'function openIconPicker()' in html, "openIconPicker function should exist"
+ assert 'function closeIconPicker()' in html, "closeIconPicker function should exist"
+ assert 'onclick="toggleIconPicker()"' in html, "Trigger should call toggleIconPicker"
+
+ print("✓ Test 154 passed: Toggle functions exist")
+
+def test_icon_picker_visible_class_toggle():
+ """Test 155: Dropdown uses visible class to show/hide content"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ html = habits_path.read_text()
+
+ # Check for visible class handling in CSS
+ assert '.icon-picker-content.visible' in html, "CSS should have visible class rule"
+ assert 'display: block' in html, "Visible class should display content"
+ assert 'display: none' in html, "Hidden state should have display none"
+
+ # Check for visible class toggling in JS
+ assert "classList.add('visible')" in html, "Should add visible class to open"
+ assert "classList.remove('visible')" in html, "Should remove visible class to close"
+
+ print("✓ Test 155 passed: Visible class toggle logic")
+
+def test_icon_picker_filter_function():
+ """Test 156: filterIcons function filters icons based on search"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ html = habits_path.read_text()
+
+ # Check for filterIcons function
+ assert 'function filterIcons()' in html, "filterIcons function should exist"
+
+ # Check filter logic
+ search_section = html[html.find('function filterIcons()'):html.find('function filterIcons()') + 1000]
+ assert 'toLowerCase()' in search_section, "Should use case-insensitive search"
+ assert 'filter' in search_section, "Should use filter method"
+ assert 'includes' in search_section, "Should check if icon name includes query"
+
+ print("✓ Test 156 passed: filterIcons function implementation")
+
+def test_icon_picker_closes_on_selection():
+ """Test 157: Selecting an icon closes the dropdown"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ html = habits_path.read_text()
+
+ # Check that selectIcon calls closeIconPicker
+ select_icon_section = html[html.find('function selectIcon('):html.find('function selectIcon(') + 1500]
+ assert 'closeIconPicker()' in select_icon_section, "selectIcon should call closeIconPicker"
+
+ print("✓ Test 157 passed: Selection closes dropdown")
+
+def test_icon_picker_click_outside_closes():
+ """Test 158: Clicking outside dropdown closes it"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ html = habits_path.read_text()
+
+ # Check for click outside listener comment and handler
+ assert "Click outside icon picker to close" in html, "Should have icon picker click-outside comment"
+
+ # Find the icon picker click handler (after the comment)
+ click_handler_start = html.find("Click outside icon picker to close")
+ if click_handler_start > 0:
+ click_handler = html[click_handler_start:click_handler_start + 500]
+ assert 'icon-picker-dropdown' in click_handler, "Should reference icon-picker-dropdown"
+ assert 'contains' in click_handler, "Should check if click is inside dropdown"
+ assert 'closeIconPicker()' in click_handler, "Should close if clicked outside"
+
+ print("✓ Test 158 passed: Click outside closes dropdown")
+
+def test_icon_picker_updates_trigger_display():
+ """Test 159: Selected icon updates trigger button display"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ html = habits_path.read_text()
+
+ # Check that selectIcon updates the trigger display
+ select_icon_section = html[html.find('function selectIcon('):html.find('function selectIcon(') + 1500]
+ assert 'selectedIconDisplay' in select_icon_section, "Should update selectedIconDisplay element"
+ assert 'setAttribute' in select_icon_section, "Should set icon attribute"
+ assert 'data-lucide' in select_icon_section, "Should update data-lucide attribute"
+ assert 'lucide.createIcons()' in select_icon_section, "Should refresh Lucide icons"
+
+ print("✓ Test 159 passed: Trigger display updates on selection")
+
+def test_icon_picker_css_dropdown_styles():
+ """Test 160: CSS includes dropdown-specific styles"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ html = habits_path.read_text()
+
+ # Check for dropdown-specific CSS
+ assert '.icon-picker-dropdown' in html, "Should have dropdown container styles"
+ assert '.icon-picker-trigger' in html, "Should have trigger button styles"
+ assert '.icon-picker-content' in html, "Should have content container styles"
+ assert 'position: absolute' in html, "Content should be absolutely positioned"
+ assert 'z-index: 100' in html, "Content should have high z-index"
+
+ # Check for hover states
+ assert '.icon-picker-trigger:hover' in html, "Should have trigger hover state"
+
+ print("✓ Test 160 passed: Dropdown CSS styles present")
+
+def test_icon_picker_chevron_rotation():
+ """Test 161: Chevron rotates when dropdown opens"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ html = habits_path.read_text()
+
+ # Check for chevron rotation in CSS
+ assert 'trigger-chevron' in html, "Chevron should have trigger-chevron class"
+ assert 'transform: rotate(180deg)' in html, "Chevron should rotate 180deg when open"
+ assert '.open .trigger-chevron' in html or '.icon-picker-trigger.open .trigger-chevron' in html, "Should rotate when trigger has open class"
+
+ # Check that open class is toggled
+ assert "classList.add('open')" in html, "Should add open class"
+ assert "classList.remove('open')" in html, "Should remove open class"
+
+ print("✓ Test 161 passed: Chevron rotation logic")
+
+def test_icon_picker_search_autofocus():
+ """Test 162: Search input gets focus when dropdown opens"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ html = habits_path.read_text()
+
+ # Check that openIconPicker focuses the search input
+ open_icon_picker = html[html.find('function openIconPicker()'):html.find('function openIconPicker()') + 800]
+ assert 'focus()' in open_icon_picker, "Should focus search input when opening"
+ assert 'setTimeout' in open_icon_picker, "Should use setTimeout for focus after animation"
+
+ print("✓ Test 162 passed: Search input autofocus")
+
+def test_typecheck_us006():
+ """Test 163: Typecheck passes"""
+ result = subprocess.run(
+ ['python3', '-m', 'py_compile', 'dashboard/api.py', 'dashboard/habits_helpers.py'],
+ cwd='/home/moltbot/clawd',
+ capture_output=True,
+ text=True
+ )
+ assert result.returncode == 0, f"Typecheck failed: {result.stderr}"
+ print("✓ Test 163 passed: Typecheck successful")
+