Files
bookhoard/ALPINE_COMPLETION_GUIDE.md
T
john-okeefe e9e568e67e docs: Add comprehensive Alpine.js integration completion guide
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
2026-03-09 20:11:36 -04:00

45 KiB
Raw Blame History

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

  1. Current State Analysis
  2. Migration Strategy
  3. Phase 1: Header Template (Reference Implementation)
  4. Phase 2: Modal Templates
  5. Phase 3: Verification & Testing
  6. Phase 4: Cleanup
  7. Troubleshooting
  8. Success Criteria

Current State Analysis

What's Already Done

  • All onclick handlers converted to @click directives
  • 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-show directives
  • No @click.outside for closing dropdowns
  • No x-transition for animations

What Needs Migration

8 stateful templates (modals, dropdowns, wizards):

  1. header.templ - Theme dropdown + user menu (P0 - used in 17 places)
  2. collection_modal.templ - Create/edit collection modal
  3. collections.templ - Add books modal + navigation
  4. conflicts.templ - Conflict resolution modal
  5. queue.templ - Queue actions modal
  6. admin.templ - Scan progress modal
  7. devices.templ - Device token modal
  8. 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:

  1. Template Changes: Add x-data state, replace class="hidden" with x-show, add transitions
  2. TypeScript Cleanup: Remove manual DOM manipulation functions
  3. Alpine Registration: Remove deleted functions from Alpine.global()
  4. 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:

  1. Wrapped both dropdowns in single x-data container (line 1)
  2. Replaced @click="toggleThemeDropdown()" with @click="themeDropdownOpen = !themeDropdownOpen" (line 4)
  3. Replaced id="theme-dropdown" class="hidden" with x-show="themeDropdownOpen" (line 13)
  4. Added @click.outside="themeDropdownOpen = false" (line 14)
  5. Added x-transition directives for smooth animations (lines 15-20)
  6. 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:

  1. Namespace added: header.changeThemeTo('tokyo-night')
  2. 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" with x-show="xxxOpen"
  • Added @click.outside to both dropdowns
  • Added x-transition for 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:

  1. Start application: go run .
  2. Navigate to any page with header (all pages)
  3. 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
  4. 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
  5. 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-data is on parent container
  • Dropdown doesn't close: Check if @click.outside is on dropdown div
  • No smooth transition: Check if x-transition directives 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:

  1. Check if x-data is on parent container
  2. Check if Alpine is loaded: <script src="/static/main.js"></script>
  3. Check for JavaScript errors in console
  4. Verify variable name matches: x-data="{ open: false }" and x-show="open"

Issue: Dropdown doesn't close when clicking outside

Symptoms: Click outside, dropdown stays open

Solutions:

  1. Check if @click.outside is on dropdown div (not button)
  2. Check for z-index conflicts
  3. Check if other elements are blocking clicks

Issue: No smooth transition

Symptoms: Dropdown appears instantly without animation

Solutions:

  1. Check if x-transition is present
  2. Check if display: none; is inline style (prevents FOUC)
  3. Check for CSS conflicts (transition properties)

Issue: Flash of unstyled content (FOUC)

Symptoms: Dropdown briefly visible before Alpine loads

Solutions:

  1. Add inline style: style="display: none;"
  2. Alpine will override this when it loads
  3. Combine with x-show for reactive behavior

Issue: Modal closes when clicking inside

Symptoms: Click inside modal, it closes

Solutions:

  1. Use @click.self instead of @click.outside
  2. Add @click.stop on inner elements to stop propagation
  3. Check modal structure - ensure backdrop has click handler, not content

Issue: TypeScript compilation error

Symptoms: npm run build:ts fails after changes

Solutions:

  1. Check for undefined functions (removed but still referenced)
  2. Check Alpine.global() registration (remove deleted functions)
  3. Run npm run build:ts -- --verbose for 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

  1. Start with header.templ migration (highest priority, reference implementation)
  2. Apply Alpine.store pattern to all modal templates
  3. Test thoroughly after each migration
  4. Clean up unused functions from TypeScript files
  5. Update documentation with patterns learned
  6. 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! 🚀