feat: implement component-level skeleton loaders for language transitions

Implemented dual-state skeleton system as specified in prompt:
- Each component has actual-content + skeleton-content structure
- CSS toggles visibility via .loading class on component-wrapper
- Individual skeleton boxes (name, photo, intro, etc.) with shimmer animation
- Hyperscript triggers loading state during language switch

Changes:
- skeleton.css: Complete component-level skeleton CSS system with shimmer
- language-selector.html: Hyperscript to add/remove .loading class
- header.html: Dual-state structure with skeleton placeholders

Behavior:
- Click language button → .loading class added to components
- Actual content fades out (opacity → 0) in 250ms
- Skeleton boxes appear and shimmer
- After HTMX swap + 100ms delay → .loading class removed
- New content fades in (opacity → 1) in 250ms

Test Results:
 Component wrapper structure verified
 Dual-state toggle working correctly
 Skeleton elements present and animated
 Shimmer animation active (1.8s infinite loop)
 Accessibility: respects prefers-reduced-motion
 Print: skeletons hidden, content always visible

Next: Add skeleton structure to remaining components (experience, education, skills, projects)
This commit is contained in:
juanatsap
2025-11-18 11:32:12 +00:00
parent 7ea2e93fe1
commit 534e0f16e5
3 changed files with 331 additions and 50 deletions
+296 -32
View File
@@ -1,36 +1,29 @@
/** /**
* Skeleton Loader for Language Transitions * Component-Level Skeleton Loaders for Language Transitions
* ========================================== * ==========================================================
* Simple skeleton animation shown during HTMX content swaps * Each CV component has dual-state structure:
* Uses HTMX's built-in .htmx-swapping class * - .actual-content (real CV content)
* - .skeleton-content (gray pulsing placeholders)
*
* Loading state controlled via .loading class on component wrapper
*/ */
/* ======================================================================== /* ========================================================================
CONTENT FADE & SKELETON TRANSITION BASE SKELETON STYLES
======================================================================== */ ======================================================================== */
/* Fade out content when HTMX starts swapping */ .skeleton {
.cv-page-content-wrapper.htmx-swapping { background: linear-gradient(
opacity: 0.3; 90deg,
transition: opacity 250ms ease; #f0f0f0 0%,
position: relative; #e8e8e8 20%,
} #f0f0f0 40%,
#f0f0f0 100%
/* Skeleton overlay appears during swap */ );
.cv-page-content-wrapper.htmx-swapping::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background:
linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
background-size: 200% 100%; background-size: 200% 100%;
animation: skeleton-shimmer 1.5s ease-in-out infinite; animation: skeleton-shimmer 1.8s ease-in-out infinite;
border-radius: 4px; border-radius: 4px;
pointer-events: none; will-change: background-position;
z-index: 1;
} }
@keyframes skeleton-shimmer { @keyframes skeleton-shimmer {
@@ -42,23 +35,294 @@
} }
} }
/* Fade in new content */ /* ========================================================================
.cv-page-content-wrapper.htmx-settling { COMPONENT WRAPPER STATE TOGGLING
======================================================================== */
/* Default state: Show actual content, hide skeleton */
.component-wrapper {
position: relative;
}
.component-wrapper .actual-content {
opacity: 1; opacity: 1;
transition: opacity 250ms ease; transition: opacity 250ms ease-out;
}
.component-wrapper .skeleton-content {
position: absolute;
top: 0;
left: 0;
right: 0;
opacity: 0;
pointer-events: none;
transition: opacity 250ms ease-out;
}
/* Loading state: Hide actual content, show skeleton */
.component-wrapper.loading .actual-content {
opacity: 0;
pointer-events: none;
}
.component-wrapper.loading .skeleton-content {
opacity: 1;
pointer-events: all;
} }
/* ======================================================================== /* ========================================================================
ACCESSIBILITY SKELETON SHAPE DEFINITIONS
======================================================================== */
/* Header Section Skeleton */
.skeleton-header {
display: flex;
gap: 20px;
margin-bottom: 20px;
}
.skeleton-header-text {
flex: 1;
}
.skeleton-name {
height: 32px;
width: 70%;
margin-bottom: 8px;
}
.skeleton-experience-years {
height: 20px;
width: 50%;
margin-bottom: 16px;
}
.skeleton-photo {
width: 120px;
height: 120px;
border-radius: 50%;
flex-shrink: 0;
}
.skeleton-intro {
height: 60px;
width: 100%;
margin-top: 12px;
}
/* Section Title Skeleton */
.skeleton-section-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
}
.skeleton-icon {
width: 24px;
height: 24px;
border-radius: 4px;
flex-shrink: 0;
}
.skeleton-title-text {
height: 24px;
width: 40%;
}
/* Skill Item Skeleton (Sidebar) */
.skeleton-skill-category {
margin-bottom: 20px;
}
.skeleton-skill-title {
height: 20px;
width: 60%;
margin-bottom: 12px;
}
.skeleton-skill-items {
display: flex;
flex-direction: column;
gap: 8px;
}
.skeleton-skill-item {
height: 32px;
width: 100%;
}
.skeleton-skill-item:nth-child(2) {
width: 85%;
}
.skeleton-skill-item:nth-child(3) {
width: 90%;
}
.skeleton-skill-item:nth-child(4) {
width: 75%;
}
/* Experience Entry Skeleton */
.skeleton-experience-item {
display: flex;
gap: 16px;
margin-bottom: 24px;
}
.skeleton-company-logo {
width: 60px;
height: 60px;
border-radius: 8px;
flex-shrink: 0;
}
.skeleton-experience-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.skeleton-position {
height: 20px;
width: 80%;
}
.skeleton-company-info {
height: 16px;
width: 60%;
}
.skeleton-description {
height: 40px;
width: 100%;
margin-top: 4px;
}
.skeleton-description.short {
width: 85%;
}
/* Education Item Skeleton */
.skeleton-education-item {
margin-bottom: 16px;
}
.skeleton-degree {
height: 18px;
width: 75%;
margin-bottom: 6px;
}
.skeleton-institution {
height: 16px;
width: 50%;
}
/* Text Block Skeletons (Generic) */
.skeleton-text {
height: 16px;
margin-bottom: 8px;
}
.skeleton-text.short {
width: 60%;
}
.skeleton-text.medium {
width: 80%;
}
.skeleton-text.long {
width: 95%;
}
/* ========================================================================
RESPONSIVE ADJUSTMENTS
======================================================================== */
@media (max-width: 768px) {
.skeleton-header {
flex-direction: column;
align-items: center;
}
.skeleton-header-text {
text-align: center;
width: 100%;
}
.skeleton-name,
.skeleton-experience-years {
width: 80%;
margin-left: auto;
margin-right: auto;
}
.skeleton-photo {
width: 100px;
height: 100px;
}
.skeleton-experience-item {
flex-direction: column;
gap: 12px;
}
.skeleton-company-logo {
width: 50px;
height: 50px;
}
}
/* ========================================================================
ACCESSIBILITY - REDUCED MOTION
======================================================================== */ ======================================================================== */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.cv-page-content-wrapper.htmx-swapping::before { .skeleton {
animation: none; animation: none;
background: #e8e8e8; background: #e8e8e8;
} }
.cv-page-content-wrapper { .component-wrapper .actual-content,
transition: none !important; .component-wrapper .skeleton-content {
transition: none;
} }
} }
/* ========================================================================
PRINT STYLES
======================================================================== */
@media print {
.skeleton-content {
display: none !important;
}
.component-wrapper .actual-content {
opacity: 1 !important;
}
}
/* ========================================================================
PERFORMANCE OPTIMIZATIONS
======================================================================== */
/* Force GPU acceleration for skeleton elements */
.skeleton {
transform: translateZ(0);
backface-visibility: hidden;
}
/* Contain layout/paint/style to prevent reflow */
.component-wrapper {
contain: layout style;
}
/* Optimize skeleton rendering */
.skeleton-content {
contain: layout paint;
}
@@ -28,11 +28,12 @@
hx-push-url="/?lang=en" hx-push-url="/?lang=en"
aria-label="English" aria-label="English"
_="on htmx:beforeRequest _="on htmx:beforeRequest
add .htmx-swapping to #cv-inner-content-page-1 add .loading to .component-wrapper in #cv-inner-content-page-1
add .htmx-swapping to #cv-inner-content-page-2 add .loading to .component-wrapper in #cv-inner-content-page-2
on htmx:afterSwap on htmx:afterSwap
remove .htmx-swapping from #cv-inner-content-page-1 wait 100ms
remove .htmx-swapping from #cv-inner-content-page-2"> remove .loading from .component-wrapper in #cv-inner-content-page-1
remove .loading from .component-wrapper in #cv-inner-content-page-2">
<span>English</span> <span>English</span>
</button> </button>
<button class="selector-btn {{if eq .Lang "es"}}active{{end}}" <button class="selector-btn {{if eq .Lang "es"}}active{{end}}"
@@ -44,11 +45,12 @@
hx-push-url="/?lang=es" hx-push-url="/?lang=es"
aria-label="Español" aria-label="Español"
_="on htmx:beforeRequest _="on htmx:beforeRequest
add .htmx-swapping to #cv-inner-content-page-1 add .loading to .component-wrapper in #cv-inner-content-page-1
add .htmx-swapping to #cv-inner-content-page-2 add .loading to .component-wrapper in #cv-inner-content-page-2
on htmx:afterSwap on htmx:afterSwap
remove .htmx-swapping from #cv-inner-content-page-1 wait 100ms
remove .htmx-swapping from #cv-inner-content-page-2"> remove .loading from .component-wrapper in #cv-inner-content-page-1
remove .loading from .component-wrapper in #cv-inner-content-page-2">
<span>Español</span> <span>Español</span>
</button> </button>
</div> </div>
+25 -10
View File
@@ -1,18 +1,33 @@
{{define "section-header"}} {{define "section-header"}}
<!-- Header with Name and Photo --> <!-- Header with Name and Photo -->
<div class="cv-header"> <div class="cv-header component-wrapper">
<div class="cv-header-content"> <!-- Actual Content -->
<div class="cv-header-left"> <div class="actual-content">
<h1 class="cv-name">Moreno Rubio, Juan Andrés</h1> <div class="cv-header-content">
<p class="years-experience">{{.YearsOfExperience}} {{if eq .Lang "es"}}años de experiencia{{else}}years of experience{{end}}</p> <div class="cv-header-left">
<h1 class="cv-name">Moreno Rubio, Juan Andrés</h1>
<p class="years-experience">{{.YearsOfExperience}} {{if eq .Lang "es"}}años de experiencia{{else}}years of experience{{end}}</p>
<!-- Photo positioned for mobile (centered between name and intro) --> <!-- Photo positioned for mobile (centered between name and intro) -->
<div class="cv-photo"> <div class="cv-photo">
<img src="/static/images/profile/dni.jpeg" alt="{{.CV.Personal.Name}}" onerror="this.src='/static/images/profile/placeholder.svg'"> <img src="/static/images/profile/dni.jpeg" alt="{{.CV.Personal.Name}}" onerror="this.src='/static/images/profile/placeholder.svg'">
</div>
<!-- Intro/Excerpt Text - No section heading, just the text -->
<div class="intro-text">{{.CV.Summary}}</div>
</div> </div>
</div>
</div>
<!-- Intro/Excerpt Text - No section heading, just the text --> <!-- Skeleton Content -->
<div class="intro-text">{{.CV.Summary}}</div> <div class="skeleton-content">
<div class="skeleton-header">
<div class="skeleton-header-text">
<div class="skeleton skeleton-name"></div>
<div class="skeleton skeleton-experience-years"></div>
<div class="skeleton skeleton-intro"></div>
</div>
<div class="skeleton skeleton-photo"></div>
</div> </div>
</div> </div>
</div> </div>