Created a detailed 1,354-line migration guide to complete the Alpine.js integration from the current hybrid state (manual DOM manipulation) to full reactive Alpine.js. Document contents: - Current state analysis (121 manual DOM manipulations identified) - Complete migration strategy with 4-step pattern - Phase-by-phase implementation guide (header.templ reference + 7 modals) - Before/after code examples with line numbers - Alpine.store pattern for global modal state - Verification checklists and testing procedures - Troubleshooting guide for common issues - Success criteria and metrics Key benefits documented: - Eliminates 121 instances of manual DOM manipulation - Reduces header.ts from 100 to 40 lines (60% reduction) - Adds smooth transitions with x-transition - Implements click-outside detection with @click.outside - Provides clean, maintainable architecture This guide completes the ESBUILD_MIGRATION_PLAN.md Phase 3 (Template Migration) with actionable steps for any developer to finish the integration in 10-12 hours. Related: ESBUILD_MIGRATION_PLAN.md Phase 3, lines 998-1242
45 KiB
Alpine.js Integration Completion Guide
Executive Summary
This guide completes the migration from hybrid onclick/@click with manual DOM manipulation to full reactive Alpine.js with state-driven UI.
Current State: Hybrid approach with 121 manual DOM manipulations Target State: Full reactive Alpine.js with zero manual DOM manipulation Estimated Time: 10-12 hours Impact: Cleaner code, better maintainability, smoother UX
Table of Contents
- Current State Analysis
- Migration Strategy
- Phase 1: Header Template (Reference Implementation)
- Phase 2: Modal Templates
- Phase 3: Verification & Testing
- Phase 4: Cleanup
- Troubleshooting
- Success Criteria
Current State Analysis
What's Already Done ✅
- All
onclickhandlers converted to@clickdirectives - Functions registered with
Alpine.global()in TypeScript - 18 templates have
x-data="namespace"attributes - HTMX integration working for forms
What's Still Missing ❌
Problem: 121 instances of manual DOM manipulation in TypeScript files
Example from header.ts:7-17:
const toggleThemeDropdown = (): void => {
const dropdown = document.getElementById("theme-dropdown");
if (dropdown) {
dropdown.classList.toggle("hidden"); // ← Manual DOM manipulation!
const userMenu = document.getElementById("user-menu");
if (userMenu && !dropdown.classList.contains("hidden")) {
userMenu.classList.add("hidden"); // ← Manual DOM manipulation!
}
}
};
Templates still using:
id="theme-dropdown"+class="hidden"for show/hide- No reactive state variables
- No
x-showdirectives - No
@click.outsidefor closing dropdowns - No
x-transitionfor animations
What Needs Migration
8 stateful templates (modals, dropdowns, wizards):
- ✅ header.templ - Theme dropdown + user menu (P0 - used in 17 places)
- ✅ collection_modal.templ - Create/edit collection modal
- ✅ collections.templ - Add books modal + navigation
- ✅ conflicts.templ - Conflict resolution modal
- ✅ queue.templ - Queue actions modal
- ✅ admin.templ - Scan progress modal
- ✅ devices.templ - Device token modal
- ✅ profile_modal.templ - Profile edit modal
Note: Simple buttons with @click handlers are fine - no migration needed.
Migration Strategy
The Pattern
Every migration follows the same 4-step pattern:
- Template Changes: Add
x-datastate, replaceclass="hidden"withx-show, add transitions - TypeScript Cleanup: Remove manual DOM manipulation functions
- Alpine Registration: Remove deleted functions from
Alpine.global() - Testing: Verify functionality, build, check for regressions
Key Principles
- State lives in template (
x-data="{ open: false }") - UI updates automatically (
x-show="open") - No manual DOM manipulation in TypeScript
- Pure business logic only in TypeScript functions
Phase 1: Header Template (Reference Implementation)
Priority: P0 (highest - used in 17 templates) Time: 2-3 hours Complexity: High (2 dropdowns + theme switching + click-outside)
Step 1.1: Update header.templ
Location: templates/header.templ
Lines to modify: 46-191
Current Structure (lines 46-53):
<div class="relative">
<button @click="toggleThemeDropdown()" class="p-2 rounded-lg hover:bg-gray-700 transition-colors" style="background-color: var(--bg-primary);">
<svg class="h-6 w-6" style="color: var(--text-primary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"></path>
</svg>
</button>
<div id="theme-dropdown" class="hidden absolute right-0 mt-2 w-64 rounded-lg shadow-lg z-50" style="background-color: var(--bg-secondary); border: 1px solid var(--border);">
New Structure:
<div x-data="{ themeDropdownOpen: false, userMenuOpen: false }">
<!-- Theme Switcher -->
<div class="relative">
<button @click="themeDropdownOpen = !themeDropdownOpen"
class="p-2 rounded-lg hover:bg-gray-700 transition-colors"
style="background-color: var(--bg-primary);">
<svg class="h-6 w-6" style="color: var(--text-primary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"></path>
</svg>
</button>
<div x-show="themeDropdownOpen"
@click.outside="themeDropdownOpen = false"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="absolute right-0 mt-2 w-64 rounded-lg shadow-lg z-50"
style="background-color: var(--bg-secondary); border: 1px solid var(--border); display: none;">
Key Changes:
- ✅ Wrapped both dropdowns in single
x-datacontainer (line 1) - ✅ Replaced
@click="toggleThemeDropdown()"with@click="themeDropdownOpen = !themeDropdownOpen"(line 4) - ✅ Replaced
id="theme-dropdown" class="hidden"withx-show="themeDropdownOpen"(line 13) - ✅ Added
@click.outside="themeDropdownOpen = false"(line 14) - ✅ Added
x-transitiondirectives for smooth animations (lines 15-20) - ✅ Added
style="display: none;"to prevent flash of unstyled content (line 22)
Update theme buttons (lines 56, 60, 64, 68, 72, 76, 80):
Replace:
<button @click="changeThemeTo('tokyo-night')" class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity" style="color: var(--text-primary); background-color: var(--bg-primary);">
With:
<button @click="header.changeThemeTo('tokyo-night'); themeDropdownOpen = false"
class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
style="color: var(--text-primary); background-color: var(--bg-primary);">
Key Changes:
- ✅ Namespace added:
header.changeThemeTo('tokyo-night') - ✅ Close dropdown after selection:
themeDropdownOpen = false
Update wood paneling buttons (lines 87, 96, 108, 120):
Replace:
<button @click="changeWoodPaneling('none')" class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity" style="color: var(--text-primary);" data-wood="none">
With:
<button @click="woodPaneling.change('none'); themeDropdownOpen = false"
class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
data-wood="none">
Note: We'll need to add woodPaneling namespace in Alpine registration (Step 1.3).
Update user menu section (lines 136-160):
Replace:
<div class="relative">
<button @click="toggleUserMenu()" class="flex items-center space-x-2 p-2 rounded-lg hover:bg-gray-700 transition-colors" style="background-color: var(--bg-primary);">
<svg class="h-6 w-6" style="color: var(--text-primary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
</svg>
<span class="text-sm hidden sm:block" style="color: var(--text-primary)">{ user.Username }</span>
</button>
<div id="user-menu" class="hidden absolute right-0 mt-2 w-48 rounded-lg shadow-lg z-50" style="background-color: var(--bg-secondary); border: 1px solid var(--border);">
With:
<div class="relative">
<button @click="userMenuOpen = !userMenuOpen"
class="flex items-center space-x-2 p-2 rounded-lg hover:bg-gray-700 transition-colors"
style="background-color: var(--bg-primary);">
<svg class="h-6 w-6" style="color: var(--text-primary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
</svg>
<span class="text-sm hidden sm:block" style="color: var(--text-primary)">{ user.Username }</span>
</button>
<div x-show="userMenuOpen"
@click.outside="userMenuOpen = false"
x-transition
class="absolute right-0 mt-2 w-48 rounded-lg shadow-lg z-50"
style="background-color: var(--bg-secondary); border: 1px solid var(--border); display: none;">
Update logout button (line 156):
Replace:
<button @click="logout()" class="block w-full text-left px-4 py-2 text-sm hover:opacity-80" style="color: var(--text-primary); background-color: var(--bg-secondary);">
Logout
</button>
With:
<button @click="header.logout(); userMenuOpen = false"
class="block w-full text-left px-4 py-2 text-sm hover:opacity-80"
style="color: var(--text-primary); background-color: var(--bg-secondary);">
Logout
</button>
Complete header.templ changes summary:
- ✅ Wrapped nav in
x-data="{ themeDropdownOpen: false, userMenuOpen: false }" - ✅ Replaced all
@click="toggleXxx()"with state toggles - ✅ Replaced
id="xxx"+class="hidden"withx-show="xxxOpen" - ✅ Added
@click.outsideto both dropdowns - ✅ Added
x-transitionfor smooth animations - ✅ Added namespace calls:
header.changeThemeTo(),header.logout() - ✅ Close dropdowns after action:
themeDropdownOpen = false
Step 1.2: Update header.ts
Location: web/src/header.ts
Lines to delete: 7-31, 64-88
Delete manual DOM manipulation functions:
// DELETE lines 7-31:
const toggleThemeDropdown = (): void => {
const dropdown = document.getElementById("theme-dropdown");
if (dropdown) {
dropdown.classList.toggle("hidden");
const userMenu = document.getElementById("user-menu");
if (userMenu && !dropdown.classList.contains("hidden")) {
userMenu.classList.add("hidden");
}
}
};
const toggleUserMenu = (): void => {
const menu = document.getElementById("user-menu");
if (menu) {
menu.classList.toggle("hidden");
const themeDropdown = document.getElementById("theme-dropdown");
if (themeDropdown && !menu.classList.contains("hidden")) {
themeDropdown.classList.add("hidden");
}
}
};
Simplify changeThemeTo function (lines 33-55):
Before:
const changeThemeTo = (theme: string): void => {
applyTheme(theme);
const token = localStorage.getItem("token");
if (token) {
fetch("/api/auth/theme", {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ theme }),
}).catch((err) => console.log("Theme save failed", err));
}
// Close dropdown ← Manual DOM manipulation!
const dropdown = document.getElementById("theme-dropdown");
if (dropdown) {
dropdown.classList.add("hidden");
}
};
After:
const changeThemeTo = (theme: string): void => {
// Apply theme
applyTheme(theme);
// Save to server if logged in
const token = localStorage.getItem("token");
if (token) {
fetch("/api/auth/theme", {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ theme }),
}).catch((err) => console.log("Theme save failed", err));
}
// No dropdown manipulation - Alpine handles it via template state!
};
Delete click-outside event listener (lines 64-88):
// DELETE entire event listener - Alpine's @click.outside handles this:
document.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
const themeDropdown = document.getElementById("theme-dropdown");
const userMenu = document.getElementById("user-menu");
const themeButton = target?.closest(
'button[onclick="toggleThemeDropdown()"]',
);
const userButton = target?.closest('button[onclick="toggleUserMenu()"]');
if (
!themeButton &&
themeDropdown &&
!themeDropdown.classList.contains("hidden")
) {
if (!themeDropdown.contains(target)) {
themeDropdown.classList.add("hidden");
}
}
if (!userButton && userMenu && !userMenu.classList.contains("hidden")) {
if (!userMenu.contains(target)) {
userMenu.classList.add("hidden");
}
}
});
Final header.ts (after cleanup):
// Header functionality
import { Alpine } from "./alpine";
import { applyTheme } from "./theme";
import { updateThemeIndicators } from "./themeDropdown";
const changeThemeTo = (theme: string): void => {
// Apply the theme using the consolidated function from theme.ts
applyTheme(theme);
// Save to server if logged in
const token = localStorage.getItem("token");
if (token) {
fetch("/api/auth/theme", {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ theme }),
}).catch((err) => console.log("Theme save failed", err));
}
// Alpine closes dropdown automatically via template state
};
const logout = (): void => {
localStorage.removeItem("token");
localStorage.removeItem("user");
window.location.href = "/";
};
export { changeThemeTo, logout };
Alpine.global("header", {
logout,
changeThemeTo: (theme: string) => {
changeThemeTo(theme);
updateThemeIndicators();
},
});
Result: header.ts reduced from 100 lines to 40 lines (60% reduction)
Step 1.3: Create woodPaneling.ts Namespace
Location: web/src/woodPaneling.ts
Current: Functions exist but need Alpine registration for namespace calls
Add to bottom of woodPaneling.ts:
import { Alpine } from "./alpine";
// ... existing functions ...
Alpine.global("woodPaneling", {
change: changeWoodPaneling,
});
Step 1.4: Verify header.templ Migration
Build verification:
npm run build:ts
templ generate
Expected: No errors, templates compile successfully
Manual testing:
- Start application:
go run . - Navigate to any page with header (all pages)
- Test theme dropdown:
- Click theme button → dropdown opens with smooth transition
- Click outside → dropdown closes
- Click theme option → theme changes, dropdown closes
- User menu closes if open
- Test user menu:
- Click user button → menu opens with smooth transition
- Click outside → menu closes
- Click logout → logout, menu closes
- Theme dropdown closes if open
- Test wood paneling:
- Click wood paneling option → changes, dropdown closes
Debug with Alpine DevTools (optional):
# Install Alpine DevTools browser extension
# Open DevTools → Alpine tab
# Inspect reactive state: themeDropdownOpen, userMenuOpen
Common issues:
- ❌ Dropdown doesn't open: Check if
x-datais on parent container - ❌ Dropdown doesn't close: Check if
@click.outsideis on dropdown div - ❌ No smooth transition: Check if
x-transitiondirectives are present - ❌ Flash of unstyled content: Verify
style="display: none;"is on x-show elements
Phase 2: Modal Templates
All modal templates follow the same pattern. Apply consistently.
2.1: collection_modal.templ
Location: templates/collection_modal.templ
Time: 1 hour
Complexity: Medium (modal with color/icon pickers)
Template Changes:
Current (line 4):
<div x-data="collections" class="fixed inset-0 z-50 flex items-center justify-center bg-black/70">
No change needed - x-data="collections" namespace is correct, but we need local state.
Add local state to wrapper:
Replace line 4:
<div x-data="{ colorModalOpen: false }" x-data="collections" class="fixed inset-0 z-50 flex items-center justify-center bg-black/70">
Better approach - use Alpine.data():
In web/src/collections.ts, add:
Alpine.data("collections", () => ({
colorModalOpen: false,
iconModalOpen: false,
openColorModal() {
this.colorModalOpen = true;
},
closeColorModal() {
this.colorModalOpen = false;
},
// ... existing methods ...
}));
Actually, for modals, the simplest approach:
Since this is a modal that's loaded via HTMX, we don't need x-data state. The modal itself is shown/hidden by HTMX.
Skip this template - it's already using Alpine namespace correctly via HTMX loading.
2.2: collections.templ
Location: templates/collections.templ
Time: 1-2 hours
Complexity: Medium (add books modal)
Find "add-books-modal" section (lines 227-260):
Current:
<div id="add-books-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center" style="background-color: rgba(0, 0, 0, 0.7);">
<div class="card rounded-lg p-6 w-full max-w-2xl mx-4 my-8" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Add Books to Collection</h2>
<button @click="hideAddBooksModal()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)">✕</button>
</div>
Replace with:
<div x-data="{ addBooksModalOpen: false }">
<!-- Button to open modal (line 156) -->
<button @click="addBooksModalOpen = true" class="btn-primary px-4 py-2 rounded-lg">
➕ Add Books
</button>
<!-- Modal (line 227) -->
<div x-show="addBooksModalOpen"
x-transition
@click.self="addBooksModalOpen = false"
class="fixed inset-0 z-50 flex items-center justify-center"
style="background-color: rgba(0, 0, 0, 0.7); display: none;">
<div @click.stop
class="card rounded-lg p-6 w-full max-w-2xl mx-4 my-8"
style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Add Books to Collection</h2>
<button @click="addBooksModalOpen = false"
class="p-2 hover:opacity-80 rounded"
style="color: var(--text-primary)">✕</button>
</div>
Note: The modal needs to be wrapped in x-data container that includes the button. Since the button and modal are far apart, we need to restructure.
Better approach - use Alpine.store():
In web/src/collections.ts, add:
// Create a global store for modal state
Alpine.store('modals', {
addBooks: false,
showAddBooks() {
this.addBooks = true;
},
hideAddBooks() {
this.addBooks = false;
}
});
In template, use:
<!-- Button (line 156) -->
<button @click="$store.modals.showAddBooks()" class="btn-primary px-4 py-2 rounded-lg">
➕ Add Books
</button>
<!-- Modal (line 227) -->
<div x-show="$store.modals.addBooks"
x-transition
@click.self="$store.modals.hideAddBooks()"
style="background-color: rgba(0, 0, 0, 0.7); display: none;">
Remove from TypeScript:
In web/src/collections.ts, delete:
function showAddBooksModal() { /* ... */ }
function hideAddBooksModal() { /* ... */ }
Keep only business logic:
async function addSelectedBooks() { /* API call */ }
async function searchBooksForCollections() { /* API call */ }
2.3: conflicts.templ
Location: templates/conflicts.templ
Time: 1 hour
Complexity: Medium (resolution modal)
Use Alpine.store pattern (same as collections):
In web/src/conflicts.ts, add:
Alpine.store('modals', {
resolveConflict: false,
showResolveConflict() {
this.resolveConflict = true;
},
hideResolveConflict() {
this.resolveConflict = false;
}
});
Template changes:
Current (line 123):
<div id="conflict-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto" style="background-color: rgba(0, 0, 0, 0.7);">
Replace with:
<div x-show="$store.modals.resolveConflict"
x-transition
@click.self="$store.modals.hideResolveConflict()"
class="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto"
style="background-color: rgba(0, 0, 0, 0.7); display: none;">
Update button (line 110):
Replace:
<button @click="showConflictModal('{ conflict.ID }')" class="btn-primary px-4 py-2 rounded-lg">
With:
<button @click="conflicts.showResolve('{ conflict.ID }'); $store.modals.showResolveConflict()"
class="btn-primary px-4 py-2 rounded-lg">
Remove from TypeScript:
Delete showConflictModal(), hideConflictModal() functions.
2.4: queue.templ
Location: templates/queue.templ
Time: 1 hour
Complexity: Low (simple action modals)
Use Alpine.store pattern - same as conflicts.templ
2.5: admin.templ
Location: templates/admin.templ
Time: 1 hour
Complexity: Medium (scan progress modal)
Use Alpine.store pattern - same as collections.templ
2.6: devices.templ
Location: templates/devices.templ
Time: 1 hour
Complexity: Low (token generation modal)
Use Alpine.store pattern - same as conflicts.templ
2.7: profile_modal.templ
Location: templates/profile_modal.templ
Time: 1 hour
Complexity: Low (edit profile modal)
Use Alpine.store pattern - same as conflicts.templ
Phase 3: Verification & Testing
For Each Migrated Template
Build Verification
# 1. Build TypeScript
npm run build:ts
# 2. Generate templates
templ generate
# 3. Check for errors
# Expected: "Complete" with 0 errors
Manual Testing Checklist
For header.templ:
- Theme dropdown opens with smooth transition
- Theme dropdown closes when clicking outside
- Theme changes when option clicked
- Theme dropdown closes after selection
- User menu opens with smooth transition
- User menu closes when clicking outside
- Logout works and closes menu
- Wood paneling changes work
- Both dropdowns don't open simultaneously (mutual exclusion)
For each modal template:
- Modal opens with smooth transition
- Modal closes when clicking outside
- Modal closes when clicking X button
- Modal closes when clicking Cancel button
- Form submission works (HTMX)
- No console errors
Regression Testing
After each template migration:
# 1. Start application
go run .
# 2. Test all pages
- [ ] Dashboard loads
- [ ] Library loads
- [ ] Collections loads
- [ ] Devices loads
- [ ] Admin loads (if admin)
# 3. Test HTMX functionality
- [ ] Forms submit correctly
- [ ] Toast notifications appear
- [ ] Page updates work
# 4. Check browser console
- [ ] No JavaScript errors
- [ ] No 404s for .js files
- [ ] Alpine is loaded (check: window.Alpine in console)
Phase 4: Cleanup
4.1: Remove Unused Functions
After all templates are migrated, search for remaining manual DOM manipulation:
cd /home/nymusicman/Code/bookhoard/web/src
# Find remaining classList manipulation
grep -rn "classList.toggle\|classList.add\|classList.remove" *.ts
# Find remaining getElementById for show/hide
grep -rn "getElementById.*dropdown\|getElementById.*modal" *.ts
# Find remaining event listeners for click-outside
grep -rn "addEventListener.*click" *.ts
Expected: Only 0-5 instances remaining (legitimate use cases)
4.2: Update ESBUILD_MIGRATION_PLAN.md
Add completion note:
## Migration Status
### ✅ Completed
- Phase 0: ES Module Exports
- Phase 1: Internal TypeScript Dependencies
- Phase 2: Dual Exports (Alpine Bridge)
- Phase 3: Template Migration (ALL templates)
### 🎯 Final State
- 0 manual DOM manipulations (down from 121)
- All stateful templates using x-data/x-show
- Smooth transitions with x-transition
- Click-outside handling with @click.outside
- 60% less TypeScript code (header.ts: 100 → 40 lines)
4.3: Create Migration Documentation
Create docs/contributing/alpinejs-patterns.md:
# Alpine.js Patterns in Bookhoard
## Modal Pattern
For modals loaded via HTMX, use Alpine.store:
```typescript
// In TypeScript
Alpine.store('modals', {
myModal: false,
show() { this.myModal = true; },
hide() { this.myModal = false; }
});
<!-- In template -->
<button @click="$store.modals.show()">Open</button>
<div x-show="$store.modals.myModal"
@click.self="$store.modals.hide()"
x-transition>
<!-- Modal content -->
</div>
Dropdown Pattern
For dropdowns in same component, use local x-data:
<div x-data="{ dropdownOpen: false }">
<button @click="dropdownOpen = !dropdownOpen">Toggle</button>
<div x-show="dropdownOpen"
@click.outside="dropdownOpen = false"
x-transition>
<!-- Dropdown content -->
</div>
</div>
Best Practices
- ✅ State lives in template (x-data)
- ✅ UI updates automatically (x-show)
- ✅ No manual DOM manipulation in TypeScript
- ✅ Pure business logic only in TypeScript functions
- ✅ Use Alpine.store for global state
- ✅ Use local x-data for component state
- ❌ Never use classList in TypeScript
- ❌ Never use getElementById for show/hide
---
## Troubleshooting
### Issue: Dropdown doesn't open
**Symptoms**: Click button, nothing happens
**Diagnosis**:
```javascript
// Open browser console
console.log(window.Alpine); // Should be defined
// Check if x-data is present
// Inspect element - should have x-data attribute
Solutions:
- Check if
x-datais on parent container - Check if Alpine is loaded:
<script src="/static/main.js"></script> - Check for JavaScript errors in console
- Verify variable name matches:
x-data="{ open: false }"andx-show="open"
Issue: Dropdown doesn't close when clicking outside
Symptoms: Click outside, dropdown stays open
Solutions:
- Check if
@click.outsideis on dropdown div (not button) - Check for z-index conflicts
- Check if other elements are blocking clicks
Issue: No smooth transition
Symptoms: Dropdown appears instantly without animation
Solutions:
- Check if
x-transitionis present - Check if
display: none;is inline style (prevents FOUC) - Check for CSS conflicts (transition properties)
Issue: Flash of unstyled content (FOUC)
Symptoms: Dropdown briefly visible before Alpine loads
Solutions:
- Add inline style:
style="display: none;" - Alpine will override this when it loads
- Combine with
x-showfor reactive behavior
Issue: Modal closes when clicking inside
Symptoms: Click inside modal, it closes
Solutions:
- Use
@click.selfinstead of@click.outside - Add
@click.stopon inner elements to stop propagation - Check modal structure - ensure backdrop has click handler, not content
Issue: TypeScript compilation error
Symptoms: npm run build:ts fails after changes
Solutions:
- Check for undefined functions (removed but still referenced)
- Check Alpine.global() registration (remove deleted functions)
- Run
npm run build:ts -- --verbosefor detailed error
Success Criteria
Phase Completion Checklist
- All 8 stateful templates migrated
- All manual DOM manipulation removed (121 → 0 instances)
- All templates build successfully:
templ generate - TypeScript compiles successfully:
npm run build:ts - All dropdowns work with smooth transitions
- All modals work with smooth transitions
- Click-outside behavior works for all dropdowns/modals
- No console errors on any page
- No regressions in HTMX functionality
- All forms submit correctly
- Alpine DevTools shows reactive state correctly
Metrics
Before Migration:
- 121 manual DOM manipulations
- 100 lines in header.ts
- No reactive state
- No smooth transitions
- Manual click-outside handling
After Migration:
- 0 manual DOM manipulations
- 40 lines in header.ts (60% reduction)
- Full reactive state with x-data
- Smooth transitions with x-transition
- Built-in click-outside with @click.outside
Final Verification
# 1. Complete build
npm run build:ts
templ generate
go build
# 2. Check for remaining manual DOM manipulation
cd web/src
grep -rn "classList.toggle\|classList.add.*hidden\|classList.remove.*hidden" *.ts | wc -l
# Expected: 0-5 (legitimate use cases only)
# 3. Run application
go run .
# 4. Test all pages
# - Navigate through all major pages
# - Test all dropdowns
# - Test all modals
# - Test all forms
# 5. Check Alpine DevTools
# - Install browser extension
# - Inspect reactive state
# - Verify state changes correctly
Appendix: Complete Code Examples
A.1: Complete header.templ (After Migration)
package templates
templ Header(user User, currentPath string) {
<nav class="border-b header-nav" style="border-color: var(--border); background-color: var(--bg-secondary);">
<div class="w-full px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16">
<!-- Left: App Title & Navigation -->
<div class="flex items-center space-x-6">
<a href="/dashboard" class="text-xl font-bold hover:opacity-80 transition-opacity" style="color: var(--text-primary); text-decoration: none;">
📚 Bookhoard
</a>
<div class="hidden md:flex items-center space-x-4">
<a href="/dashboard" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
Library
</a>
<a href="/collections" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
Collections
</a>
<a href="/progress" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
Progress
</a>
<a href="/devices" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
Devices
</a>
</div>
</div>
<!-- Center: Search Box -->
<div class="flex-1 max-w-2xl mx-8">
<div class="relative">
<input
type="text"
id="header-search"
placeholder="Search your library..."
class="w-full px-4 py-2 pl-10 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
autocomplete="off"
/>
<svg class="absolute left-3 top-2.5 h-5 w-5" style="color: var(--text-secondary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
</div>
</div>
<!-- Right: Theme Switcher & User Menu -->
<div x-data="{ themeDropdownOpen: false, userMenuOpen: false }">
<!-- Theme Switcher -->
<div class="relative">
<button @click="themeDropdownOpen = !themeDropdownOpen"
class="p-2 rounded-lg hover:bg-gray-700 transition-colors"
style="background-color: var(--bg-primary);">
<svg class="h-6 w-6" style="color: var(--text-primary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"></path>
</svg>
</button>
<div x-show="themeDropdownOpen"
@click.outside="themeDropdownOpen = false"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="absolute right-0 mt-2 w-64 rounded-lg shadow-lg z-50"
style="background-color: var(--bg-secondary); border: 1px solid var(--border); display: none;">
<div class="p-4">
<h3 class="text-sm font-semibold mb-3" style="color: var(--text-primary)">Select Theme</h3>
<div class="space-y-2">
<button @click="header.changeThemeTo('tokyo-night'); themeDropdownOpen = false"
class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
style="color: var(--text-primary); background-color: var(--bg-primary);">
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #7aa2f7;"></span>
Tokyo Night
</button>
<button @click="header.changeThemeTo('dracula'); themeDropdownOpen = false"
class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
style="color: var(--text-primary); background-color: var(--bg-primary);">
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #bd93f9;"></span>
Dracula
</button>
<button @click="header.changeThemeTo('nord'); themeDropdownOpen = false"
class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
style="color: var(--text-primary); background-color: var(--bg-primary);">
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #88c0d0;"></span>
Nord
</button>
<button @click="header.changeThemeTo('solarized-dark'); themeDropdownOpen = false"
class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
style="color: var(--text-primary); background-color: var(--bg-primary);">
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #2aa198;"></span>
Solarized Dark
</button>
<button @click="header.changeThemeTo('monokai'); themeDropdownOpen = false"
class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
style="color: var(--text-primary); background-color: var(--bg-primary);">
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #a6e22e;"></span>
Monokai
</button>
<button @click="header.changeThemeTo('one-dark-pro'); themeDropdownOpen = false"
class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
style="color: var(--text-primary); background-color: var(--bg-primary);">
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #61dafb;"></span>
One Dark Pro
</button>
<button @click="header.changeThemeTo('material-dark'); themeDropdownOpen = false"
class="w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
style="color: var(--text-primary); background-color: var(--bg-primary);">
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #80cbc4;"></span>
Material Dark
</button>
<div class="border-t pt-2 mt-2" style="border-color: var(--border);">
<p class="text-xs mb-2" style="color: var(--text-secondary)">Bookshelf Background</p>
<button @click="woodPaneling.change('none'); themeDropdownOpen = false"
class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
data-wood="none">
None
</button>
<button @click="woodPaneling.change('wood-light'); themeDropdownOpen = false"
class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
data-wood="wood-light">
<span class="inline-block w-4 h-4 rounded mr-2"
style="background: url('/static/textures/wood-light.png'); background-size: cover;"></span>
Wood Light
</button>
<button @click="woodPaneling.change('wood-dark'); themeDropdownOpen = false"
class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
data-wood="wood-dark">
<span class="inline-block w-4 h-4 rounded mr-2"
style="background: url('/static/textures/wood-dark.png'); background-size: cover;"></span>
Wood Dark
</button>
<button @click="woodPaneling.change('wood-mahogany'); themeDropdownOpen = false"
class="wood-paneling-btn w-full text-left px-3 py-2 rounded hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
data-wood="wood-mahogany">
<span class="inline-block w-4 h-4 rounded mr-2"
style="background: url('/static/textures/wood-mahogany.png'); background-size: cover;"></span>
Wood Mahogany
</button>
</div>
</div>
</div>
</div>
</div>
<!-- User Icon with Dropdown -->
<div class="relative">
if user.ID != "" {
<!-- LOGGED IN: Show user menu with logout -->
<button @click="userMenuOpen = !userMenuOpen"
class="flex items-center space-x-2 p-2 rounded-lg hover:bg-gray-700 transition-colors"
style="background-color: var(--bg-primary);">
<svg class="h-6 w-6" style="color: var(--text-primary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
</svg>
<span class="text-sm hidden sm:block" style="color: var(--text-primary)">{ user.Username }</span>
</button>
<div x-show="userMenuOpen"
@click.outside="userMenuOpen = false"
x-transition
class="absolute right-0 mt-2 w-48 rounded-lg shadow-lg z-50"
style="background-color: var(--bg-secondary); border: 1px solid var(--border); display: none;">
<div class="py-1">
<a href="/profile" class="block px-4 py-2 text-sm hover:opacity-80" style="color: var(--text-primary); background-color: var(--bg-secondary); text-decoration: none;">
Profile
</a>
if user.Role == "admin" {
<a href="/admin" class="block px-4 py-2 text-sm hover:opacity-80" style="color: var(--text-primary); background-color: var(--bg-secondary); text-decoration: none;">
Admin Panel
</a>
}
<div class="border-t my-1" style="border-color: var(--border);"></div>
<button @click="header.logout(); userMenuOpen = false"
class="block w-full text-left px-4 py-2 text-sm hover:opacity-80"
style="color: var(--text-primary); background-color: var(--bg-secondary);">
Logout
</button>
</div>
</div>
} else {
<!-- LOGGED OUT: Show login form -->
<button @click="userMenuOpen = !userMenuOpen"
class="flex items-center space-x-2 p-2 rounded-lg hover:bg-gray-700 transition-colors"
style="background-color: var(--bg-primary);">
<svg class="h-6 w-6" style="color: var(--text-primary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
</svg>
<span class="text-sm hidden sm:block" style="color: var(--text-primary)">Login</span>
</button>
<div x-show="userMenuOpen"
@click.outside="userMenuOpen = false"
x-transition
class="absolute right-0 mt-2 w-64 rounded-lg shadow-lg z-50 p-4"
style="background-color: var(--bg-secondary); border: 1px solid var(--border); display: none;">
<form hx-post="/api/auth/login" hx-target="#login-result" hx-swap="innerHTML" class="space-y-3">
<input type="hidden" name="redirect" value="{ currentPath }"/>
<div>
<label class="block text-sm mb-1" style="color: var(--text-secondary)">Email or Username</label>
<input type="text" name="login" class="w-full px-3 py-2 border rounded text-sm" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" required/>
</div>
<div>
<label class="block text-sm mb-1" style="color: var(--text-secondary)">Password</label>
<input type="password" name="password" class="w-full px-3 py-2 border rounded text-sm" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" required/>
</div>
<button type="submit" class="w-full py-2 rounded text-sm" style="background-color: var(--accent); color: white;">
Sign In
</button>
</form>
<div id="login-result"></div>
<div class="border-t my-2" style="border-color: var(--border);"></div>
<a href="/register" class="block text-center text-sm hover:opacity-80" style="color: var(--text-secondary); text-decoration: none;">
Don't have an account? Sign Up
</a>
</div>
}
</div>
</div>
</div>
</div>
</nav>
<script src="/static/main.js"></script>
}
A.2: Complete header.ts (After Migration)
// Header functionality
import { Alpine } from "./alpine";
import { applyTheme } from "./theme";
import { updateThemeIndicators } from "./themeDropdown";
const changeThemeTo = (theme: string): void => {
// Apply the theme using the consolidated function from theme.ts
applyTheme(theme);
// Save to server if logged in
const token = localStorage.getItem("token");
if (token) {
fetch("/api/auth/theme", {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ theme }),
}).catch((err) => console.log("Theme save failed", err));
}
// Alpine closes dropdown automatically via template state
};
const logout = (): void => {
localStorage.removeItem("token");
localStorage.removeItem("user");
window.location.href = "/";
};
export { changeThemeTo, logout };
Alpine.global("header", {
logout,
changeThemeTo: (theme: string) => {
changeThemeTo(theme);
updateThemeIndicators();
},
});
A.3: Alpine.store Pattern for Modals
TypeScript (web/src/collections.ts):
import { Alpine } from "./alpine";
// Create global modal store
Alpine.store('modals', {
addBooks: false,
editCollection: false,
showAddBooks() {
this.addBooks = true;
},
hideAddBooks() {
this.addBooks = false;
},
showEditCollection() {
this.editCollection = true;
},
hideEditCollection() {
this.editCollection = false;
}
});
// Business logic functions (no DOM manipulation)
async function addSelectedBooks() {
const token = localStorage.getItem("token");
// ... API call logic ...
}
async function searchBooksForCollections(query: string) {
// ... search logic ...
}
export { addSelectedBooks, searchBooksForCollections };
Template (templates/collections.templ):
<!-- Button to open modal -->
<button @click="$store.modals.showAddBooks()" class="btn-primary">
➕ Add Books
</button>
<!-- Modal -->
<div x-show="$store.modals.addBooks"
x-transition
@click.self="$store.modals.hideAddBooks()"
class="fixed inset-0 z-50 flex items-center justify-center"
style="background-color: rgba(0, 0, 0, 0.7); display: none;">
<div @click.stop class="card rounded-lg p-6">
<div class="flex justify-between items-center mb-6">
<h2>Add Books to Collection</h2>
<button @click="$store.modals.hideAddBooks()">✕</button>
</div>
<!-- Modal content -->
<input type="text" placeholder="Search books...">
<button @click="addSelectedBooks()">Add Selected</button>
</div>
</div>
Next Steps
- Start with header.templ migration (highest priority, reference implementation)
- Apply Alpine.store pattern to all modal templates
- Test thoroughly after each migration
- Clean up unused functions from TypeScript files
- Update documentation with patterns learned
- Verify final state: 0 manual DOM manipulations
Estimated completion time: 10-12 hours
Success metrics:
- ✅ All 8 templates migrated
- ✅ 121 manual DOM manipulations → 0
- ✅ All dropdowns/modals use reactive state
- ✅ Smooth transitions throughout
- ✅ Clean, maintainable codebase
Good luck with the migration! 🚀