fix: resolve HTMX toggle swap error and restore smooth animations

PROBLEM:
- htmx:swapError with "Cannot read properties of null (reading 'insertBefore')" on double-click
- Toggle animations were "digital" (instant snap) instead of "analogical" (smooth slide)
- Conflict between server templates with hx-swap-oob and client-side hyperscript

ROOT CAUSE:
- Server templates returned HTML with hx-swap="outerHTML" + hx-swap-oob="true"
- This destroyed and recreated DOM elements during swap
- Second click tried to insert into null parent (element was destroyed)
- CSS transitions broke because element was destroyed mid-animation

SOLUTION:
- Remove all HTML from toggle templates (length-toggle.html, logo-toggle.html, theme-toggle.html)
- Templates now return empty comment: "<!-- Template not used - toggles use hx-swap="none" with inline hyperscript -->"
- Toggles use hx-swap="none" to prevent any DOM replacement
- All visual updates handled client-side via inline hyperscript
- Server only saves cookies in background (no HTML returned)

BENEFITS:
-  No more null reference errors (no DOM destruction)
-  Smooth CSS transitions work perfectly (element preserved)
-  Desktop/mobile toggles sync via direct ID manipulation
-  Zero HTMX swap conflicts
-  Clean separation: client handles visuals, server persists state

DOCUMENTATION:
- Updated MODERN-WEB-TECHNIQUES.md with Phase 8
- Documented the complete debug journey and solution
- Added architecture pattern for client-first toggles
This commit is contained in:
juanatsap
2025-11-15 13:45:48 +00:00
parent 06eb490950
commit aeab81dd42
4 changed files with 194 additions and 169 deletions
+1 -56
View File
@@ -1,56 +1 @@
<!-- Primary response: Desktop length toggle -->
<div class="selector-group" id="desktop-length-toggle">
<label class="selector-label">{{if eq .Lang "es"}}Longitud{{else}}Length{{end}}:</label>
<label class="icon-toggle">
<input type="checkbox"
id="lengthToggle"
{{if eq .CVLengthClass "cv-long"}}checked{{end}}
hx-post="/toggle/length?lang={{.Lang}}"
hx-target="#desktop-length-toggle"
hx-swap="outerHTML"
_="on htmx:afterRequest
if my.checked
remove .cv-short from .cv-paper
add .cv-long to .cv-paper
set localStorage['cv-length'] to 'long'
else
remove .cv-long from .cv-paper
add .cv-short to .cv-paper
set localStorage['cv-length'] to 'short'
end">
<span class="icon-toggle-slider">
<iconify-icon icon="mdi:file-document-outline" width="16" height="16" class="icon-left"></iconify-icon>
<iconify-icon icon="mdi:file-document-multiple-outline" width="16" height="16" class="icon-right"></iconify-icon>
</span>
</label>
</div>
<!-- Out-of-band swap: Mobile length toggle -->
<div class="menu-control-item" id="mobile-length-toggle" hx-swap-oob="true">
<label class="menu-control-label">
<iconify-icon icon="mdi:file-document-outline" width="20" height="20"></iconify-icon>
<span>{{if eq .Lang "es"}}Longitud{{else}}Length{{end}}</span>
</label>
<label class="icon-toggle">
<input type="checkbox"
id="lengthToggleMenu"
{{if eq .CVLengthClass "cv-long"}}checked{{end}}
hx-post="/toggle/length?lang={{.Lang}}"
hx-target="#mobile-length-toggle"
hx-swap="outerHTML"
_="on htmx:afterRequest
if my.checked
remove .cv-short from .cv-paper
add .cv-long to .cv-paper
set localStorage['cv-length'] to 'long'
else
remove .cv-long from .cv-paper
add .cv-short to .cv-paper
set localStorage['cv-length'] to 'short'
end">
<span class="icon-toggle-slider">
<iconify-icon icon="mdi:file-document-outline" width="16" height="16" class="icon-left"></iconify-icon>
<iconify-icon icon="mdi:file-document-multiple-outline" width="16" height="16" class="icon-right"></iconify-icon>
</span>
</label>
</div>
<!-- Template not used - toggles use hx-swap="none" with inline hyperscript -->
+1 -52
View File
@@ -1,52 +1 @@
<!-- Primary response: Desktop logo toggle -->
<div class="selector-group" id="desktop-logo-toggle">
<label class="selector-label">{{if eq .Lang "es"}}Logos{{else}}Logos{{end}}:</label>
<label class="icon-toggle">
<input type="checkbox"
id="logoToggle"
{{if .ShowLogos}}checked{{end}}
hx-post="/toggle/logos?lang={{.Lang}}"
hx-target="#desktop-logo-toggle"
hx-swap="outerHTML"
_="on htmx:afterRequest
if my.checked
add .show-logos to .cv-paper
set localStorage['cv-logos'] to 'true'
else
remove .show-logos from .cv-paper
set localStorage['cv-logos'] to 'false'
end">
<span class="icon-toggle-slider">
<iconify-icon icon="mdi:image-off-outline" width="16" height="16" class="icon-left"></iconify-icon>
<iconify-icon icon="mdi:image-multiple-outline" width="16" height="16" class="icon-right"></iconify-icon>
</span>
</label>
</div>
<!-- Out-of-band swap: Mobile logo toggle -->
<div class="menu-control-item" id="mobile-logo-toggle" hx-swap-oob="true">
<label class="menu-control-label">
<iconify-icon icon="mdi:image-multiple-outline" width="20" height="20"></iconify-icon>
<span>{{if eq .Lang "es"}}Logos{{else}}Logos{{end}}</span>
</label>
<label class="icon-toggle">
<input type="checkbox"
id="logoToggleMenu"
{{if .ShowLogos}}checked{{end}}
hx-post="/toggle/logos?lang={{.Lang}}"
hx-target="#mobile-logo-toggle"
hx-swap="outerHTML"
_="on htmx:afterRequest
if my.checked
add .show-logos to .cv-paper
set localStorage['cv-logos'] to 'true'
else
remove .show-logos from .cv-paper
set localStorage['cv-logos'] to 'false'
end">
<span class="icon-toggle-slider">
<iconify-icon icon="mdi:image-off-outline" width="16" height="16" class="icon-left"></iconify-icon>
<iconify-icon icon="mdi:image-multiple-outline" width="16" height="16" class="icon-right"></iconify-icon>
</span>
</label>
</div>
<!-- Template not used - toggles use hx-swap="none" with inline hyperscript -->
+1 -52
View File
@@ -1,52 +1 @@
<!-- Primary response: Desktop theme toggle -->
<div class="selector-group" id="desktop-theme-toggle">
<label class="selector-label">{{if eq .Lang "es"}}Vista{{else}}View{{end}}:</label>
<label class="icon-toggle">
<input type="checkbox"
id="themeToggle"
{{if .ThemeClean}}checked{{end}}
hx-post="/toggle/theme?lang={{.Lang}}"
hx-target="#desktop-theme-toggle"
hx-swap="outerHTML"
_="on htmx:afterRequest
if my.checked
add .theme-clean to the body
set localStorage['cv-theme'] to 'clean'
else
remove .theme-clean from the body
set localStorage['cv-theme'] to 'default'
end">
<span class="icon-toggle-slider">
<iconify-icon icon="mdi:page-layout-sidebar-left" width="16" height="16" class="icon-left"></iconify-icon>
<iconify-icon icon="mdi:page-layout-body" width="16" height="16" class="icon-right"></iconify-icon>
</span>
</label>
</div>
<!-- Out-of-band swap: Mobile theme toggle -->
<div class="menu-control-item" id="mobile-theme-toggle" hx-swap-oob="true">
<label class="menu-control-label">
<iconify-icon icon="mdi:page-layout-sidebar-left" width="20" height="20"></iconify-icon>
<span>{{if eq .Lang "es"}}Vista{{else}}View{{end}}</span>
</label>
<label class="icon-toggle">
<input type="checkbox"
id="themeToggleMenu"
{{if .ThemeClean}}checked{{end}}
hx-post="/toggle/theme?lang={{.Lang}}"
hx-target="#mobile-theme-toggle"
hx-swap="outerHTML"
_="on htmx:afterRequest
if my.checked
add .theme-clean to the body
set localStorage['cv-theme'] to 'clean'
else
remove .theme-clean from the body
set localStorage['cv-theme'] to 'default'
end">
<span class="icon-toggle-slider">
<iconify-icon icon="mdi:page-layout-sidebar-left" width="16" height="16" class="icon-left"></iconify-icon>
<iconify-icon icon="mdi:page-layout-body" width="16" height="16" class="icon-right"></iconify-icon>
</span>
</label>
</div>
<!-- Template not used - toggles use hx-swap="none" with inline hyperscript -->