feat: add social links to footer and optional company logo toggle

**Social Links in Footer (Page 2):**
- Replace address/phone with LinkedIn, GitHub, and Behance links
- Maintain email@ link
- All links are clickable and open in new tabs
- Footer displays social media profiles prominently

**Company Logo Toggle Feature:**
- Add "Show logos" toggle switch in top action bar
- Toggle displays company logos (48x48px) to the left of each experience item
- LinkedIn-style layout when logos are shown
- Logos hidden by default, optional display via toggle
- Graceful fallback: missing logos don't break layout (onerror handler)
- Logos directory created at static/images/logos/ with README

**Technical Implementation:**
- New CSS file: logo-toggle.css for toggle switch and logo layout
- JavaScript: toggleLogos() function for show/hide functionality
- Template updates: experience items now support flex layout with logos
- Action bar grid updated to accommodate 4 columns
- Logo display uses CSS class `.show-logos` on `.cv-paper`
- Print CSS: logos hidden in PDF exports by default

**User Experience:**
- Clean toggle switch UI with smooth animations
- Mobile responsive design
- Accessibility: proper ARIA labels for toggle
- Optional feature that doesn't clutter default view
- Professional LinkedIn-style appearance when enabled

Logos can be added to static/images/logos/ directory using filenames
from the companyLogo field in CV JSON data.
This commit is contained in:
juanatsap
2025-11-05 12:15:43 +00:00
parent 38bf09196e
commit 2c372eee49
30 changed files with 4306 additions and 42 deletions
+26 -6
View File
@@ -5,13 +5,14 @@
## 🚀 Features
-**Bilingual Support** - Spanish and English with instant switching (no page reload)
-**PDF Export** - Print-optimized design for PDF generation via browser
-**Server-Side PDF Export** - Professional PDF generation using chromedp (headless Chrome)
-**Browser Print** - Alternative print-friendly layout for manual PDF creation
-**HTMX Dynamic Updates** - Smooth UX without heavy JavaScript
-**Paper Design** - Professional CV on elegant white paper with gray background
-**Responsive** - Mobile, tablet, and desktop friendly
-**JSON-Based Content** - Easy to update without touching code
-**AI Development Section** - Showcases modern AI-assisted development skills
-**Fast & Lightweight** - Go backend, minimal dependencies
-**Fast & Lightweight** - Go backend with chromedp for PDF generation
## 📋 Quick Start
@@ -41,15 +42,34 @@ No code changes needed - just refresh browser!
## 🖨️ Export to PDF
1. Click **"Download PDF"** button
2. Use browser print (Cmd/Ctrl + P)
3. Save as PDF
### Server-Side PDF Generation (Recommended)
1. Click **"Download as PDF"** button in the action bar
2. PDF is generated server-side using headless Chrome
3. File downloads automatically: `CV-Juan-Andres-Moreno-Rubio-{lang}.pdf`
**Advantages:**
- Consistent rendering across all platforms
- Perfect font rendering
- No browser compatibility issues
- Professional quality output
### Browser Print (Alternative)
1. Click **"Print Friendly"** button
2. Use browser print dialog (Cmd/Ctrl + P)
3. Select "Save as PDF"
**Endpoints:**
- English PDF: `http://localhost:1999/export/pdf?lang=en`
- Spanish PDF: `http://localhost:1999/export/pdf?lang=es`
## 🎯 Key Technologies
- Backend: **Go** (stdlib net/http)
- PDF Generation: **chromedp** (headless Chrome automation)
- Frontend: **HTMX** 1.9.10
- Styling: Custom **CSS**
- Styling: Custom **CSS** with Quicksand font
- Data: **JSON** files
---
Binary file not shown.
+11
View File
@@ -1,3 +1,14 @@
module github.com/juanatsap/cv-site
go 1.25.1
require (
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 // indirect
github.com/chromedp/chromedp v0.14.2 // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
golang.org/x/sys v0.34.0 // indirect
)
+17
View File
@@ -0,0 +1,17 @@
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E=
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM=
github.com/chromedp/chromedp v0.14.2/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs=
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+89
View File
@@ -0,0 +1,89 @@
package pdf
import (
"context"
"fmt"
"io"
"time"
"github.com/chromedp/cdproto/page"
"github.com/chromedp/chromedp"
)
// Generator handles PDF generation using headless Chrome
type Generator struct {
timeout time.Duration
}
// NewGenerator creates a new PDF generator with the specified timeout
func NewGenerator(timeout time.Duration) *Generator {
if timeout == 0 {
timeout = 30 * time.Second
}
return &Generator{
timeout: timeout,
}
}
// GenerateFromURL generates a PDF from a given URL
func (g *Generator) GenerateFromURL(ctx context.Context, url string) ([]byte, error) {
// Create context with timeout
ctx, cancel := context.WithTimeout(ctx, g.timeout)
defer cancel()
// Create chromedp context
allocCtx, allocCancel := chromedp.NewContext(ctx)
defer allocCancel()
// Buffer to store PDF
var pdfBuffer []byte
// Run chromedp tasks
err := chromedp.Run(allocCtx,
// Navigate to URL
chromedp.Navigate(url),
// Wait for page to be ready
chromedp.WaitReady("body"),
// Small delay to ensure all content is loaded
chromedp.Sleep(500*time.Millisecond),
// Generate PDF with print-optimized settings
chromedp.ActionFunc(func(ctx context.Context) error {
var err error
pdfBuffer, _, err = page.PrintToPDF().
WithPrintBackground(true).
WithPreferCSSPageSize(true).
WithMarginTop(0).
WithMarginBottom(0).
WithMarginLeft(0).
WithMarginRight(0).
WithPaperWidth(8.27). // A4 width in inches
WithPaperHeight(11.69). // A4 height in inches
Do(ctx)
return err
}),
)
if err != nil {
return nil, fmt.Errorf("chromedp execution failed: %w", err)
}
if len(pdfBuffer) == 0 {
return nil, fmt.Errorf("generated PDF is empty")
}
return pdfBuffer, nil
}
// StreamFromURL generates a PDF and writes it to the provided writer
func (g *Generator) StreamFromURL(ctx context.Context, url string, w io.Writer) error {
pdfData, err := g.GenerateFromURL(ctx, url)
if err != nil {
return err
}
_, err = w.Write(pdfData)
return err
}
+1 -1
View File
@@ -34,7 +34,7 @@ func main() {
}
// Initialize handlers
cvHandler := handlers.NewCVHandler(templateMgr)
cvHandler := handlers.NewCVHandler(templateMgr, cfg.Address())
healthHandler := handlers.NewHealthHandler(version)
// Setup router
+35
View File
@@ -0,0 +1,35 @@
// @ts-check
const { defineConfig, devices } = require('@playwright/test');
module.exports = defineConfig({
testDir: './tests',
fullyParallel: false, // Run tests sequentially for consistent measurements
forbidOnly: !!process.env.CI,
retries: 0,
workers: 1,
reporter: 'html',
use: {
trace: 'on-first-retry',
screenshot: 'on',
video: 'retain-on-failure'
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: [
{
command: 'echo "Sites should already be running on 3000 and 1999"',
url: 'http://localhost:1999',
reuseExistingServer: true,
timeout: 5000,
},
{
url: 'http://localhost:3000',
reuseExistingServer: true,
timeout: 5000,
}
],
});
+131
View File
@@ -0,0 +1,131 @@
/* Logo Toggle Component */
.logo-toggle {
display: flex;
align-items: center;
justify-content: center;
}
.toggle-switch {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
user-select: none;
}
.toggle-switch input[type="checkbox"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
background-color: #ccc;
border-radius: 24px;
transition: background-color 0.3s ease;
}
.toggle-slider::after {
content: '';
position: absolute;
width: 18px;
height: 18px;
border-radius: 50%;
background-color: white;
top: 3px;
left: 3px;
transition: transform 0.3s ease;
}
.toggle-switch input[type="checkbox"]:checked + .toggle-slider {
background-color: var(--accent-blue);
}
.toggle-switch input[type="checkbox"]:checked + .toggle-slider::after {
transform: translateX(20px);
}
.toggle-switch input[type="checkbox"]:focus + .toggle-slider {
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.2);
}
.toggle-label {
font-size: 0.9rem;
font-weight: 500;
color: #ccc;
}
/* Experience Item with Logo Support */
.experience-item {
margin-bottom: 1.5rem;
page-break-inside: avoid;
display: flex;
gap: 1rem;
position: relative;
}
.company-logo {
display: none; /* Hidden by default */
flex-shrink: 0;
}
.company-logo img {
width: 48px;
height: 48px;
object-fit: contain;
border-radius: 4px;
border: 1px solid #ddd;
background: white;
}
.experience-content {
flex: 1;
min-width: 0; /* Prevents flex item from overflowing */
}
/* Show logos when toggle is active */
.show-logos .company-logo {
display: block;
}
/* Hide logos in print by default */
@media print {
.company-logo {
display: none !important;
}
}
/* Mobile responsiveness */
@media (max-width: 768px) {
.logo-toggle {
order: 3; /* Move to bottom on mobile */
}
.toggle-label {
font-size: 0.85rem;
}
.toggle-slider {
width: 38px;
height: 20px;
}
.toggle-slider::after {
width: 14px;
height: 14px;
}
.toggle-switch input[type="checkbox"]:checked + .toggle-slider::after {
transform: translateX(18px);
}
.company-logo img {
width: 40px;
height: 40px;
}
}
+2 -5
View File
@@ -53,7 +53,7 @@ a:hover {
margin: 0 auto;
padding: 1rem 2rem;
display: grid;
grid-template-columns: 1fr auto 1fr;
grid-template-columns: auto auto auto 1fr;
align-items: center;
gap: 2rem;
}
@@ -357,10 +357,7 @@ a:hover {
}
/* Experience */
.experience-item {
margin-bottom: 1.5rem;
page-break-inside: avoid;
}
/* Experience item layout moved to logo-toggle.css */
.experience-header {
margin-bottom: 0.6rem;
+25
View File
@@ -0,0 +1,25 @@
# Company Logos
Place company logos here with the filenames matching the `companyLogo` field in the CV JSON data.
## Expected Format:
- **Size**: 48x48px minimum (will be displayed at 48x48px)
- **Format**: PNG or SVG preferred
- **Background**: Transparent or white
- **Naming**: Should match exactly the filename in `cv-en.json` / `cv-es.json`
## Current logos needed:
- olympic-broadcasting.png
- aena.png
- sap.png
- gigya.png
- everis.png
- megabanner.png
- ebantic.png
- indra.png
- emailing-network.png
- penta-msi.png
- homeria.png
- insa.png
Note: Logos are optional. If a logo file doesn't exist, it will be hidden gracefully.
+149
View File
@@ -0,0 +1,149 @@
<!-- Professional Title Badges - Full Width Top Bar -->
<div class="cv-title-badges-header">
<span class="title-badge">{{if eq .Lang "es"}}ANALISTA PROGRAMADOR{{else}}ANALYST PROGRAMMER{{end}}</span>
<span class="badge-separator">|</span>
<span class="title-badge">NODEJS + REACTJS {{if eq .Lang "es"}}DESARROLLADOR{{else}}DEVELOPER{{end}}</span>
<span class="badge-separator">|</span>
<span class="title-badge">WEB {{if eq .Lang "es"}}DESARROLLADOR{{else}}DEVELOPER{{end}}</span>
<span class="badge-separator">|</span>
<span class="title-badge">GO {{if eq .Lang "es"}}DESARROLLADOR{{else}}DEVELOPER{{end}}</span>
<span class="badge-separator">|</span>
<span class="title-badge">PHP {{if eq .Lang "es"}}DESARROLLADOR{{else}}DEVELOPER{{end}}</span>
</div>
<!-- Left Sidebar - Skills -->
<aside class="cv-sidebar">
<!-- Skills Section - Dynamically render all categories -->
{{range .CV.Skills.Technical}}
<section class="sidebar-section">
<h3 class="sidebar-title">{{.Category}}</h3>
<div class="sidebar-content">
{{range .Items}}<div class="skill-item">{{.}}</div>{{end}}
</div>
</section>
{{end}}
<!-- Languages Section -->
<section class="sidebar-section">
<h3 class="sidebar-title">{{if eq .Lang "es"}}Idiomas{{else}}Languages{{end}}</h3>
<div class="sidebar-content">
{{range .CV.Languages}}
<div class="language-item">
<strong>{{.Language}}</strong>: {{.Proficiency}}
{{if .Detail}}<br><small style="color: #666;">{{.Detail}}</small>{{end}}
</div>
{{end}}
</div>
</section>
<!-- Courses Section -->
{{if .CV.Courses}}
<section class="sidebar-section">
<h3 class="sidebar-title">{{if eq .Lang "es"}}Cursos Realizados{{else}}Training Courses{{end}}</h3>
<div class="sidebar-content">
{{range .CV.Courses}}
<div class="course-item">
<strong>{{.Title}}</strong><br>
<small>{{.Institution}} - {{.Location}}</small><br>
<small>{{.Date}} ({{.Duration}})</small>
{{if .Description}}<p class="course-desc">{{.Description}}</p>{{end}}
</div>
{{end}}
</div>
</section>
{{end}}
<!-- References Section -->
{{if .CV.References}}
<section class="sidebar-section">
<h3 class="sidebar-title">{{if eq .Lang "es"}}Referencias{{else}}References{{end}}</h3>
<div class="sidebar-content">
{{range .CV.References}}
<div class="reference-item">
<a href="{{.URL}}" target="_blank" rel="noopener noreferrer">{{.Title}}</a>
<small class="ref-type">({{.Type}})</small>
</div>
{{end}}
</div>
</section>
{{end}}
<!-- Other Section (Driver's License) -->
{{if .CV.Other.DriverLicense}}
<section class="sidebar-section">
<h3 class="sidebar-title">{{if eq .Lang "es"}}Otros{{else}}Other{{end}}</h3>
<div class="sidebar-content">
{{if eq .Lang "es"}}Carnet de conducir {{.CV.Other.DriverLicense}}{{else}}Driver's License {{.CV.Other.DriverLicense}}{{end}}
</div>
</section>
{{end}}
</aside>
<!-- Main Content Area -->
<main class="cv-main">
<!-- Header with Name and Photo -->
<div class="cv-header">
<div class="cv-header-content">
<div class="cv-header-left">
<h1 class="cv-name">{{.CV.Personal.Name}}</h1>
<p class="cv-experience-years">{{if eq .Lang "es"}}20 años de experiencia{{else}}20 years of experience{{end}}</p>
<!-- Intro/Excerpt Text - No section heading, just the text -->
<div class="intro-text">{{.CV.Summary}}</div>
</div>
<div class="cv-photo">
<img src="/static/images/profile/photo.jpg" alt="{{.CV.Personal.Name}}" onerror="this.src='/static/images/profile/placeholder.svg'">
</div>
</div>
</div>
<!-- Education -->
<section class="cv-section">
<h3 class="section-title">{{if eq .Lang "es"}}Formación{{else}}Training{{end}}</h3>
{{range .CV.Education}}
<div class="education-item">
<strong>{{.Degree}}</strong> ({{.StartDate}}-{{.EndDate}}) {{if eq $.Lang "es"}}obtenido de{{else}}obtained from the{{end}} <strong>{{.Institution}}</strong> ({{.Location}})
</div>
{{end}}
</section>
<!-- Skills Summary -->
<section class="cv-section">
<h3 class="section-title">{{if eq .Lang "es"}}Competencias{{else}}Skills{{end}}</h3>
<p class="summary-text">
{{if eq .Lang "es"}}
Amplio conocimiento en entornos web, tanto J2EE como PHP. Experto en tecnologías front-end, aunque con considerable experiencia en sistemas back-end. Receptivo al aprendizaje de nuevas tecnologías, y con una gran dosis de creatividad. Capacidad de analizar problemas y aportar soluciones específicas adaptadas a cada tipo de cliente. Me gusta trabajar tanto solo como en grupos.
{{else}}
Extensive knowledge in web environments, both J2EE and PHP. Expert in front-end technologies, although with considerable experience in back-end systems. Receptive to learning new technologies, and with a large dose of creativity. Ability to analyze problems and provide specific solutions tailored to each client type. I like to work both alone and in groups.
{{end}}
</p>
</section>
<!-- Experience -->
<section class="cv-section">
<h3 class="section-title">{{if eq .Lang "es"}}Experiencia{{else}}Experience{{end}}</h3>
{{range .CV.Experience}}
<div class="experience-item">
<div class="experience-header">
<div class="experience-title-line">
<h4 class="position">{{.Position}} / {{if eq $.Lang "es"}}Analista Programador{{else}}Analyst Programmer{{end}}</h4>
<span class="experience-period">{{.StartDate}} / {{if .Current}}{{if eq $.Lang "es"}}presente{{else}}now{{end}}{{else}}{{.EndDate}}{{end}} - ({{.Location}})</span>
</div>
</div>
{{if .ShortDescription}}
<p class="short-desc">{{.ShortDescription}}</p>
{{end}}
<div class="long-only">
<ul class="responsibilities">
{{range .Responsibilities}}
<li>{{.}}</li>
{{end}}
</ul>
</div>
</div>
{{end}}
</section>
</main>
+171
View File
@@ -0,0 +1,171 @@
<!DOCTYPE html>
<html lang="{{if eq .Lang "es"}}es{{else}}en{{end}}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="{{.CV.Personal.Name}} - {{.CV.Personal.Title}}">
<meta name="keywords" content="CV, Resume, {{.CV.Personal.Name}}, Developer, SAP, AI">
<meta name="author" content="{{.CV.Personal.Name}}">
<meta name="robots" content="index, follow">
<!-- Open Graph Meta Tags -->
<meta property="og:title" content="{{.CV.Personal.Name}} - Curriculum Vitae">
<meta property="og:description" content="{{.CV.Personal.Title}}">
<meta property="og:type" content="profile">
<meta property="og:url" content="{{.CV.Personal.Website}}">
<title>{{.CV.Personal.Name}} - Curriculum Vitae</title>
<!-- HTMX with Integrity Check -->
<script src="https://unpkg.com/htmx.org@1.9.10"
integrity="sha384-D1Kt99CQMDuVetoL1lrYwg5t+9QdHe7NLX/SoJYkXDFfX37iInKRy5xLSi8nO7UC"
crossorigin="anonymous"></script>
<!-- CSS -->
<link rel="stylesheet" href="/static/css/main.css">
<link rel="stylesheet" href="/static/css/print.css" media="print">
<!-- Fonts with Preload -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<!-- Favicon -->
<link rel="icon" type="image/png" href="/static/favicon.png">
<!-- HTMX Configuration -->
<meta name="htmx-config" content='{"timeout":5000,"defaultSwapStyle":"innerHTML","defaultSwapDelay":0,"defaultSettleDelay":20}'>
</head>
<body>
<!-- Language & Export Bar (hidden in print) -->
<div class="action-bar no-print" role="navigation" aria-label="Language and export controls">
<div class="action-bar-content">
<div class="language-toggle" role="group" aria-label="Language selection">
<button
class="lang-btn {{if eq .Lang "en"}}active{{end}}"
hx-get="/cv?lang=en"
hx-target="#cv-content"
hx-swap="innerHTML swap:200ms settle:200ms"
hx-indicator="#loading"
hx-push-url="/?lang=en"
hx-on::before-request="this.setAttribute('aria-busy', 'true')"
hx-on::after-request="this.setAttribute('aria-busy', 'false')"
aria-label="Switch to English"
aria-pressed="{{if eq .Lang "en"}}true{{else}}false{{end}}">
🇬🇧 English
</button>
<button
class="lang-btn {{if eq .Lang "es"}}active{{end}}"
hx-get="/cv?lang=es"
hx-target="#cv-content"
hx-swap="innerHTML swap:200ms settle:200ms"
hx-indicator="#loading"
hx-push-url="/?lang=es"
hx-on::before-request="this.setAttribute('aria-busy', 'true')"
hx-on::after-request="this.setAttribute('aria-busy', 'false')"
aria-label="Switch to Spanish"
aria-pressed="{{if eq .Lang "es"}}true{{else}}false{{end}}">
🇪🇸 Español
</button>
</div>
<div class="export-actions">
<button
class="export-btn"
onclick="window.print()"
aria-label="{{if eq .Lang "es"}}Descargar PDF del CV{{else}}Download CV as PDF{{end}}">
📄 {{if eq .Lang "es"}}Descargar PDF{{else}}Download PDF{{end}}
</button>
</div>
<span id="loading" class="htmx-indicator" role="status" aria-live="polite" aria-label="Loading">
<span class="loader" aria-hidden="true"></span>
<span class="sr-only">Loading...</span>
</span>
</div>
</div>
<!-- CV Content Container -->
<div class="cv-container">
<main id="cv-content"
class="cv-paper"
role="main"
aria-live="polite"
aria-atomic="false">
{{template "cv-content.html" .}}
</main>
</div>
<!-- Error Toast (hidden by default) -->
<div id="error-toast" class="error-toast no-print" role="alert" aria-live="assertive" style="display: none;">
<span id="error-message"></span>
<button onclick="this.parentElement.style.display='none'" aria-label="Close error message">×</button>
</div>
<!-- Footer (hidden in print) -->
<footer class="no-print" role="contentinfo">
<p>&copy; {{.CV.Meta.LastUpdated}} {{.CV.Personal.Name}} |
{{if eq .Lang "es"}}Última actualización{{else}}Last updated{{end}}: {{.CV.Meta.LastUpdated}}</p>
</footer>
<!-- HTMX Event Handlers -->
<script>
// Global error handler for HTMX requests
document.body.addEventListener('htmx:responseError', function(evt) {
const errorToast = document.getElementById('error-toast');
const errorMessage = document.getElementById('error-message');
errorMessage.textContent = '{{if eq .Lang "es"}}Error al cargar el contenido. Por favor, inténtelo de nuevo.{{else}}Failed to load content. Please try again.{{end}}';
errorToast.style.display = 'flex';
// Auto-hide after 5 seconds
setTimeout(() => {
errorToast.style.display = 'none';
}, 5000);
});
// Smooth scroll to top on language change
document.body.addEventListener('htmx:afterSwap', function(evt) {
if (evt.detail.target.id === 'cv-content') {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
});
// Save language preference
document.body.addEventListener('htmx:afterRequest', function(evt) {
const url = new URL(evt.detail.xhr.responseURL);
const lang = url.searchParams.get('lang');
if (lang) {
localStorage.setItem('cv-lang', lang);
}
});
// Load saved language preference on page load
window.addEventListener('DOMContentLoaded', function() {
const savedLang = localStorage.getItem('cv-lang');
const currentLang = '{{.Lang}}';
if (savedLang && savedLang !== currentLang) {
document.querySelector(`[hx-get="/cv?lang=${savedLang}"]`)?.click();
}
});
// Keyboard shortcuts
document.addEventListener('keydown', function(evt) {
// Ctrl/Cmd + P for print
if ((evt.ctrlKey || evt.metaKey) && evt.key === 'p') {
evt.preventDefault();
window.print();
}
// Ctrl/Cmd + E for English, Ctrl/Cmd + S for Spanish
if (evt.ctrlKey || evt.metaKey) {
if (evt.key === 'e') {
evt.preventDefault();
document.querySelector('[hx-get="/cv?lang=en"]')?.click();
} else if (evt.key === 's' && evt.shiftKey) {
evt.preventDefault();
document.querySelector('[hx-get="/cv?lang=es"]')?.click();
}
}
});
</script>
</body>
</html>
+274
View File
@@ -0,0 +1,274 @@
<!DOCTYPE html>
<html lang="{{if eq .Lang "es"}}es{{else}}en{{end}}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Primary Meta Tags -->
<title>{{.CV.Personal.Name}} - {{if eq .Lang "es"}}Curriculum Vitae{{else}}Curriculum Vitae{{end}}</title>
<meta name="title" content="{{.CV.Personal.Name}} - {{if eq .Lang "es"}}CV Profesional{{else}}Professional CV{{end}}">
<meta name="description" content="{{.CV.Personal.Title}} | {{if eq .Lang "es"}}18 años de experiencia en desarrollo web, SAP CDC, React, Node.js, Go, HTMX y desarrollo asistido por IA{{else}}18 years of experience in web development, SAP CDC, React, Node.js, Go, HTMX and AI-assisted development{{end}}">
<meta name="keywords" content="{{if eq .Lang "es"}}CV, Curriculum Vitae, {{.CV.Personal.Name}}, Desarrollador FullStack, SAP CDC, React, Node.js, Go, HTMX, IA, Desarrollo Web, Consultor Técnico{{else}}CV, Resume, {{.CV.Personal.Name}}, FullStack Developer, SAP CDC, React, Node.js, Go, HTMX, AI, Web Development, Technical Consultant{{end}}">
<meta name="author" content="{{.CV.Personal.Name}}">
<meta name="robots" content="index, follow">
<link rel="canonical" href="{{.CV.Personal.Website}}">
<!-- Open Graph / Facebook -->
<meta property="og:type" content="profile">
<meta property="og:url" content="{{.CV.Personal.Website}}">
<meta property="og:title" content="{{.CV.Personal.Name}} - {{if eq .Lang "es"}}CV Profesional{{else}}Professional CV{{end}}">
<meta property="og:description" content="{{.CV.Personal.Title}} | {{if eq .Lang "es"}}Consultor Técnico Senior con 18 años de experiencia{{else}}Senior Technical Consultant with 18 years of experience{{end}}">
<meta property="og:image" content="{{.CV.Personal.Website}}/static/images/profile.jpg">
<meta property="og:locale" content="{{if eq .Lang "es"}}es_ES{{else}}en_US{{end}}">
<meta property="og:site_name" content="{{.CV.Personal.Name}}">
<meta property="profile:first_name" content="Juan Andrés">
<meta property="profile:last_name" content="Moreno Rubio">
<meta property="profile:username" content="txeo">
<!-- Social Media Card (Generic) -->
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="{{.CV.Personal.Name}} - {{if eq .Lang "es"}}CV Profesional{{else}}Professional CV{{end}}">
<meta name="twitter:description" content="{{.CV.Personal.Title}}">
<meta name="twitter:image" content="{{.CV.Personal.Website}}/static/images/profile.jpg">
<!-- HTMX Configuration -->
<meta name="htmx-config" content='{"timeout":5000,"defaultSwapStyle":"innerHTML","defaultSwapDelay":0,"defaultSettleDelay":20}'>
<!-- HTMX with SRI (Subresource Integrity) -->
<script src="https://unpkg.com/htmx.org@1.9.10"
integrity="sha384-D1Kt99CQMDuVetoL1lrYwg5t+9QdHe7NLX/SoJYkXDFfX37iInKRy5xLSi8nO7UC"
crossorigin="anonymous"></script>
<!-- CSS -->
<link rel="stylesheet" href="/static/css/main.css">
<link rel="stylesheet" href="/static/css/print.css" media="print">
<!-- Fonts with Preload -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="dns-prefetch" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<!-- Structured Data (JSON-LD) -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Person",
"name": "{{.CV.Personal.Name}}",
"jobTitle": "{{.CV.Personal.Title}}",
"url": "{{.CV.Personal.Website}}",
"email": "{{.CV.Personal.Email}}",
"telephone": "{{.CV.Personal.Phone}}",
"address": {
"@type": "PostalAddress",
"addressLocality": "{{.CV.Personal.Location}}"
},
"sameAs": [
"{{.CV.Personal.LinkedIn}}",
"{{.CV.Personal.GitHub}}",
"{{.CV.Personal.Behance}}"
],
"alumniOf": {
"@type": "EducationalOrganization",
"name": "Universidad de Extremadura"
},
"knowsAbout": [
"Web Development",
"SAP Customer Data Cloud",
"React",
"Node.js",
"Go",
"HTMX",
"AI-Assisted Development",
"Full Stack Development"
],
"worksFor": {
"@type": "Organization",
"name": "Olympic Broadcasting Services"
}
}
</script>
</head>
<body>
<!-- Single Black Bar with Everything -->
<div class="action-bar no-print" role="navigation" aria-label="Language and export controls">
<div class="action-bar-content">
<!-- Left: Language buttons -->
<div class="language-toggle" role="group" aria-label="Language selection">
<button
class="lang-btn {{if eq .Lang "en"}}active{{end}}"
hx-get="/cv?lang=en"
hx-target="#cv-content"
hx-swap="innerHTML swap:200ms settle:200ms"
hx-push-url="/?lang=en"
hx-indicator="#loading"
aria-label="Switch to English"
aria-pressed="{{if eq .Lang "en"}}true{{else}}false{{end}}">
English
</button>
<button
class="lang-btn {{if eq .Lang "es"}}active{{end}}"
hx-get="/cv?lang=es"
hx-target="#cv-content"
hx-swap="innerHTML swap:200ms settle:200ms"
hx-push-url="/?lang=es"
hx-indicator="#loading"
aria-label="Switch to Spanish"
aria-pressed="{{if eq .Lang "es"}}true{{else}}false{{end}}">
Español
</button>
</div>
<!-- Center: CV Length Toggle -->
<div class="cv-length-toggle">
<button
class="length-btn active"
onclick="toggleCVLength('short')"
aria-label="{{if eq .Lang "es"}}Ver CV corto{{else}}View short CV{{end}}">
{{if eq .Lang "es"}}Corto{{else}}Short{{end}}
</button>
<button
class="length-btn"
onclick="toggleCVLength('long')"
aria-label="{{if eq .Lang "es"}}Ver CV largo{{else}}View long CV{{end}}">
{{if eq .Lang "es"}}Largo{{else}}Long{{end}}
</button>
</div>
<!-- Right: Action buttons -->
<div class="action-buttons">
<a
class="export-btn"
href="/export/pdf?lang={{.Lang}}"
download
aria-label="{{if eq .Lang "es"}}Descargar PDF del CV{{else}}Download CV as PDF{{end}}">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" style="display: inline-block; vertical-align: middle;">
<path d="M8.5 11.5l3.5-3.5h-2.5v-6h-2v6h-2.5l3.5 3.5zm-6.5 2.5v2h12v-2h-12z"/>
</svg>
{{if eq .Lang "es"}}Descargar PDF{{else}}Download as PDF{{end}}
</a>
<button
class="export-btn"
onclick="window.print()"
aria-label="{{if eq .Lang "es"}}Imprimir CV{{else}}Print CV{{end}}">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" style="display: inline-block; vertical-align: middle;">
<path d="M14 4h-3v-3h-6v3h-3c-1.1 0-2 0.9-2 2v5h3v4h8v-4h3v-5c0-1.1-0.9-2-2-2zm-7-2h2v2h-2v-2zm5 11h-8v-5h8v5zm2-7c-0.552 0-1-0.448-1-1s0.448-1 1-1 1 0.448 1 1-0.448 1-1 1z"/>
</svg>
{{if eq .Lang "es"}}Imprimir{{else}}Print Friendly{{end}}
</button>
</div>
<span id="loading"
class="htmx-indicator"
role="status"
aria-live="polite"
aria-label="Loading">
<span class="loader"></span>
</span>
</div>
</div>
<!-- CV Content Container -->
<div class="cv-container">
<main id="cv-content"
class="cv-paper"
role="main"
aria-live="polite">
{{template "cv-content.html" .}}
</main>
</div>
<!-- Footer (hidden in print) -->
<footer class="no-print">
<p>© {{.CV.Meta.LastUpdated}} {{.CV.Personal.Name}} |
{{if eq .Lang "es"}}Última actualización{{else}}Last updated{{end}}: {{.CV.Meta.LastUpdated}}</p>
</footer>
<!-- Error Toast -->
<div id="error-toast" class="error-toast no-print" role="alert" aria-live="assertive" style="display: none;">
<span class="error-icon">⚠️</span>
<span id="error-message"></span>
<button onclick="this.parentElement.style.display='none'" aria-label="Close error message" class="error-close">×</button>
</div>
<script>
function toggleCVLength(length) {
// Update button states
document.querySelectorAll('.length-btn').forEach(btn => {
btn.classList.remove('active');
});
event.target.classList.add('active');
// Toggle visibility
const paper = document.querySelector('.cv-paper');
if (length === 'short') {
paper.classList.add('cv-short');
paper.classList.remove('cv-long');
} else {
paper.classList.add('cv-long');
paper.classList.remove('cv-short');
}
}
// Initialize with short version
document.addEventListener('DOMContentLoaded', function() {
document.querySelector('.cv-paper').classList.add('cv-short');
});
// Error handling utility
function showError(message) {
const errorToast = document.getElementById('error-toast');
const errorMessage = document.getElementById('error-message');
errorMessage.textContent = message;
errorToast.style.display = 'flex';
// Auto-hide after 5 seconds
setTimeout(() => {
errorToast.style.display = 'none';
}, 5000);
}
// HTMX Global Error Handlers
document.body.addEventListener('htmx:responseError', function(evt) {
console.error('HTMX Response Error:', evt.detail);
const lang = document.documentElement.lang;
const message = lang === 'es'
? 'Error al cargar el contenido. Por favor, inténtelo de nuevo.'
: 'Failed to load content. Please try again.';
showError(message);
});
document.body.addEventListener('htmx:sendError', function(evt) {
console.error('HTMX Send Error:', evt.detail);
const lang = document.documentElement.lang;
const message = lang === 'es'
? 'Error de conexión. Verifique su conexión a internet.'
: 'Connection error. Please check your internet connection.';
showError(message);
});
document.body.addEventListener('htmx:timeout', function(evt) {
console.error('HTMX Timeout:', evt.detail);
const lang = document.documentElement.lang;
const message = lang === 'es'
? 'La solicitud tardó demasiado. Por favor, inténtelo de nuevo.'
: 'Request timed out. Please try again.';
showError(message);
});
document.body.addEventListener('htmx:afterSwap', function(evt) {
// Smooth scroll to top on language change
if (evt.detail.target.id === 'cv-content') {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
});
// Log successful swaps for debugging
document.body.addEventListener('htmx:afterRequest', function(evt) {
if (evt.detail.successful) {
console.log('HTMX request successful:', evt.detail.pathInfo.requestPath);
}
});
</script>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
# Placeholder for future partial templates
+48 -30
View File
@@ -71,34 +71,41 @@
{{range .CV.Experience}}
<div class="experience-item">
<div class="experience-header">
<div class="experience-title-line">
<h4 class="position">
<span class="position-title">{{.Position}}</span>
{{if .Company}}
{{if .CompanyURL}}
<a href="{{.CompanyURL}}" target="_blank" rel="noopener noreferrer" class="company-link">{{.Company}}</a>
{{else}}
<span class="company-name">{{.Company}}</span>
{{end}}
{{end}}
</h4>
<span class="experience-period">{{.StartDate}} / {{if .Current}}{{if eq $.Lang "es"}}presente{{else}}now{{end}}{{else}}{{.EndDate}}{{end}}</span>
<span class="experience-separator">&nbsp;-&nbsp;</span>
<span class="experience-location">({{.Location}})</span>
</div>
{{if .CompanyLogo}}
<div class="company-logo">
<img src="/static/images/logos/{{.CompanyLogo}}" alt="{{.Company}} logo" onerror="this.style.display='none'">
</div>
{{if .ShortDescription}}
<p class="short-desc">{{.ShortDescription}}</p>
{{end}}
<div class="experience-content">
<div class="experience-header">
<div class="experience-title-line">
<h4 class="position">
<span class="position-title">{{.Position}}</span>
{{if .Company}}
{{if .CompanyURL}}
<a href="{{.CompanyURL}}" target="_blank" rel="noopener noreferrer" class="company-link">{{.Company}}</a>
{{else}}
<span class="company-name">{{.Company}}</span>
{{end}}
{{end}}
</h4>
<span class="experience-period">{{.StartDate}} / {{if .Current}}{{if eq $.Lang "es"}}presente{{else}}now{{end}}{{else}}{{.EndDate}}{{end}}</span>
<span class="experience-separator">&nbsp;-&nbsp;</span>
<span class="experience-location">({{.Location}})</span>
</div>
</div>
<div class="long-only">
<ul class="responsibilities">
{{range .Responsibilities}}
<li>{{.}}</li>
{{end}}
</ul>
{{if .ShortDescription}}
<p class="short-desc">{{.ShortDescription}}</p>
{{end}}
<div class="long-only">
<ul class="responsibilities">
{{range .Responsibilities}}
<li>{{.}}</li>
{{end}}
</ul>
</div>
</div>
</div>
{{end}}
@@ -207,20 +214,31 @@
<footer class="cv-footer">
<ul class="footer-content">
<li>
<div class="footer-label">address_</div>
<div class="footer-label">linkedin_</div>
<div class="footer-separator"><i class="fa fa-circle"></i></div>
<div class="footer-value">Carrer Meer, N° 51 4° 2ª, 08003 Barcelona</div>
<div class="footer-value">
<a href="{{.CV.Personal.LinkedIn}}" target="_blank" rel="noopener noreferrer">{{.CV.Personal.LinkedIn}}</a>
</div>
</li>
<li>
<div class="footer-label">phone#</div>
<div class="footer-label">github_</div>
<div class="footer-separator"><i class="fa fa-circle"></i></div>
<div class="footer-value">+34 676875420</div>
<div class="footer-value">
<a href="{{.CV.Personal.GitHub}}" target="_blank" rel="noopener noreferrer">{{.CV.Personal.GitHub}}</a>
</div>
</li>
<li>
<div class="footer-label">behance_</div>
<div class="footer-separator"><i class="fa fa-circle"></i></div>
<div class="footer-value">
<a href="{{.CV.Personal.Behance}}" target="_blank" rel="noopener noreferrer">{{.CV.Personal.Behance}}</a>
</div>
</li>
<li>
<div class="footer-label">email@</div>
<div class="footer-separator"><i class="fa fa-circle"></i></div>
<div class="footer-value">
<a href="mailto:txeo.msx@gmail.com" target="_blank" rel="noopener noreferrer">txeo.msx@gmail.com</a>
<a href="mailto:{{.CV.Personal.Email}}" target="_blank" rel="noopener noreferrer">{{.CV.Personal.Email}}</a>
</div>
</li>
</ul>
+21
View File
@@ -41,6 +41,7 @@
<!-- CSS -->
<link rel="stylesheet" href="/static/css/main.css">
<link rel="stylesheet" href="/static/css/logo-toggle.css">
<link rel="stylesheet" href="/static/css/print.css" media="print">
<!-- Fonts with Preload -->
@@ -135,6 +136,15 @@
</button>
</div>
<!-- Center Right: Logo Toggle -->
<div class="logo-toggle">
<label class="toggle-switch">
<input type="checkbox" id="logoToggle" onclick="toggleLogos()" aria-label="{{if eq .Lang "es"}}Mostrar logos de empresas{{else}}Show company logos{{end}}">
<span class="toggle-slider"></span>
<span class="toggle-label">{{if eq .Lang "es"}}Mostrar logos{{else}}Show logos{{end}}</span>
</label>
</div>
<!-- Right: Action buttons -->
<div class="action-buttons">
<a
@@ -210,6 +220,17 @@
}
}
function toggleLogos() {
const checkbox = document.getElementById('logoToggle');
const paper = document.querySelector('.cv-paper');
if (checkbox.checked) {
paper.classList.add('show-logos');
} else {
paper.classList.remove('show-logos');
}
}
// Initialize with short version
document.addEventListener('DOMContentLoaded', function() {
document.querySelector('.cv-paper').classList.add('cv-short');
+192
View File
@@ -0,0 +1,192 @@
const { chromium } = require('playwright');
const fs = require('fs');
const path = require('path');
async function compareRendered() {
const browser = await chromium.launch({ headless: true });
console.log('\n=== COMPARING RENDERED SITES ===\n');
// OLD SITE
const pageOld = await browser.newPage({ viewport: { width: 1920, height: 1080 } });
console.log('Loading OLD site (React)...');
await pageOld.goto('http://localhost:3000', { waitUntil: 'networkidle', timeout: 30000 });
// Wait for React to render
await pageOld.waitForTimeout(2000);
// Take screenshot
await pageOld.screenshot({
path: './tests/screenshots/old-full-rendered.png',
fullPage: true
});
// Get actual rendered content
const oldContent = await pageOld.evaluate(() => {
const app = document.getElementById('app') || document.body;
return {
innerHTML: app.innerHTML.substring(0, 2000),
hasContent: app.innerHTML.length > 100,
classes: Array.from(document.querySelectorAll('[class]')).map(el => el.className).filter(c => c).slice(0, 50)
};
});
console.log('OLD site content loaded:', oldContent.hasContent);
console.log('OLD site classes found:', oldContent.classes.length);
if (oldContent.classes.length > 0) {
console.log('Sample classes:', oldContent.classes.slice(0, 10));
}
// NEW SITE
const pageNew = await browser.newPage({ viewport: { width: 1920, height: 1080 } });
console.log('\nLoading NEW site (Go+HTMX)...');
await pageNew.goto('http://localhost:1999', { waitUntil: 'networkidle' });
await pageNew.screenshot({
path: './tests/screenshots/new-full-rendered.png',
fullPage: true
});
// SIDE-BY-SIDE COMPARISON
console.log('\n=== HEADER BADGES COMPARISON ===\n');
// Try multiple selectors for old site
const oldBadgeSelectors = [
'[class*="badge"]',
'[class*="title"]',
'div[class*="cv"]',
'.badge',
'.title-badge'
];
let oldBadges = null;
for (const selector of oldBadgeSelectors) {
try {
const count = await pageOld.locator(selector).count();
if (count > 0) {
console.log(`Found ${count} elements with selector: ${selector}`);
oldBadges = await pageOld.$$eval(selector, elements =>
elements.slice(0, 5).map(el => {
const computed = window.getComputedStyle(el);
return {
tag: el.tagName,
class: el.className,
text: el.textContent?.substring(0, 50),
styles: {
fontSize: computed.fontSize,
fontWeight: computed.fontWeight,
color: computed.color,
backgroundColor: computed.backgroundColor,
padding: computed.padding,
height: computed.height
}
};
})
);
break;
}
} catch (e) {
// Try next selector
}
}
const newBadges = await pageNew.$$eval('.title-badge', elements =>
elements.slice(0, 5).map(el => {
const computed = window.getComputedStyle(el);
return {
tag: el.tagName,
class: el.className,
text: el.textContent?.trim(),
styles: {
fontSize: computed.fontSize,
fontWeight: computed.fontWeight,
color: computed.color,
backgroundColor: computed.backgroundColor,
padding: computed.padding,
height: computed.height
}
};
})
);
console.log('\nOLD site badges:');
console.log(JSON.stringify(oldBadges, null, 2));
console.log('\nNEW site badges:');
console.log(JSON.stringify(newBadges, null, 2));
// VISUAL PIXEL COMPARISON
console.log('\n=== VISUAL COMPARISON ===\n');
// Get dimensions
const oldDimensions = await pageOld.evaluate(() => ({
width: document.documentElement.scrollWidth,
height: document.documentElement.scrollHeight
}));
const newDimensions = await pageNew.evaluate(() => ({
width: document.documentElement.scrollWidth,
height: document.documentElement.scrollHeight
}));
console.log('OLD site dimensions:', oldDimensions);
console.log('NEW site dimensions:', newDimensions);
// Screenshot specific sections
try {
// Header comparison
const oldHeader = pageOld.locator('header, [class*="header"], div').first();
const newHeader = pageNew.locator('.cv-title-badges-header').first();
if (await oldHeader.count() > 0) {
await oldHeader.screenshot({ path: './tests/screenshots/old-header-section.png' });
}
await newHeader.screenshot({ path: './tests/screenshots/new-header-section.png' });
// Sidebar comparison
const oldSidebar = pageOld.locator('[class*="sidebar"], aside').first();
const newSidebar = pageNew.locator('.cv-sidebar').first();
if (await oldSidebar.count() > 0) {
await oldSidebar.screenshot({ path: './tests/screenshots/old-sidebar-section.png' });
}
await newSidebar.screenshot({ path: './tests/screenshots/new-sidebar-section.png' });
} catch (e) {
console.log('Error capturing sections:', e.message);
}
// CREATE COMPARISON REPORT
const report = {
timestamp: new Date().toISOString(),
oldSite: {
url: 'http://localhost:3000',
hasContent: oldContent.hasContent,
classesFound: oldContent.classes.length,
dimensions: oldDimensions,
badges: oldBadges
},
newSite: {
url: 'http://localhost:1999',
dimensions: newDimensions,
badges: newBadges
},
comparison: {
dimensionsMatch: Math.abs(oldDimensions.width - newDimensions.width) < 50 &&
Math.abs(oldDimensions.height - newDimensions.height) < 50,
pixelPerfect: null // To be determined by visual inspection
}
};
fs.writeFileSync(
'./tests/screenshots/comparison-report.json',
JSON.stringify(report, null, 2)
);
console.log('\n✓ Comparison complete!');
console.log('✓ Screenshots saved to tests/screenshots/');
console.log('✓ Report saved to comparison-report.json');
await browser.close();
}
compareRendered().catch(console.error);
+98
View File
@@ -0,0 +1,98 @@
const { chromium } = require('playwright');
async function inspectStructure() {
const browser = await chromium.launch();
const page = await browser.newPage();
console.log('\n=== INSPECTING OLD SITE (localhost:3000) ===\n');
await page.goto('http://localhost:3000', { waitUntil: 'networkidle' });
// Get all class names
const classes = await page.evaluate(() => {
const allElements = document.querySelectorAll('*');
const classSet = new Set();
allElements.forEach(el => {
if (el.className && typeof el.className === 'string') {
el.className.split(' ').forEach(cls => {
if (cls.trim()) classSet.add(cls.trim());
});
}
});
return Array.from(classSet).sort();
});
console.log('All classes found:');
console.log(classes.filter(c => c.includes('badge') || c.includes('title') || c.includes('cv') || c.includes('sidebar')).join('\n'));
// Get main structure
const structure = await page.evaluate(() => {
const getStructure = (el, depth = 0) => {
if (depth > 3) return null;
const tag = el.tagName.toLowerCase();
const classes = el.className || '';
const id = el.id || '';
return {
tag,
classes,
id,
children: Array.from(el.children).map(child => getStructure(child, depth + 1)).filter(Boolean)
};
};
return getStructure(document.body);
});
console.log('\n\nMain structure:');
console.log(JSON.stringify(structure, null, 2).substring(0, 5000));
// Find elements with "badge" or "title" in their classes
const badgeElements = await page.$$eval('[class*="badge"], [class*="title"]', elements =>
elements.slice(0, 20).map(el => ({
tag: el.tagName,
class: el.className,
text: el.textContent?.substring(0, 100),
computedStyles: (() => {
const computed = window.getComputedStyle(el);
return {
fontSize: computed.fontSize,
fontWeight: computed.fontWeight,
color: computed.color,
backgroundColor: computed.backgroundColor,
padding: computed.padding,
height: computed.height
};
})()
}))
);
console.log('\n\nBadge/Title elements:');
console.log(JSON.stringify(badgeElements, null, 2));
console.log('\n\n=== INSPECTING NEW SITE (localhost:1999) ===\n');
await page.goto('http://localhost:1999', { waitUntil: 'networkidle' });
const newBadgeElements = await page.$$eval('[class*="badge"], [class*="title"]', elements =>
elements.slice(0, 20).map(el => ({
tag: el.tagName,
class: el.className,
text: el.textContent?.substring(0, 100),
computedStyles: (() => {
const computed = window.getComputedStyle(el);
return {
fontSize: computed.fontSize,
fontWeight: computed.fontWeight,
color: computed.color,
backgroundColor: computed.backgroundColor,
padding: computed.padding,
height: computed.height
};
})()
}))
);
console.log('Badge/Title elements:');
console.log(JSON.stringify(newBadgeElements, null, 2));
await browser.close();
}
inspectStructure().catch(console.error);
+24
View File
@@ -0,0 +1,24 @@
{
"old": {},
"new": {
"badge": {
"box": {
"x": 528.046875,
"y": 173.96875,
"width": 164,
"height": 21.609375
},
"styles": {
"height": "21.6094px",
"padding": "0px",
"fontSize": "14.4px",
"fontWeight": "400",
"color": "rgb(204, 204, 204)",
"backgroundColor": "rgba(0, 0, 0, 0)",
"borderRadius": "0px",
"display": "block",
"alignItems": "normal"
}
}
}
}
+91
View File
@@ -0,0 +1,91 @@
{
"timestamp": "2025-10-31T15:52:05.485Z",
"oldSite": {
"url": "http://localhost:3000",
"hasContent": true,
"classesFound": 0,
"dimensions": {
"width": 1920,
"height": 1080
},
"badges": null
},
"newSite": {
"url": "http://localhost:1999",
"dimensions": {
"width": 1920,
"height": 2195
},
"badges": [
{
"tag": "SPAN",
"class": "title-badge",
"text": "ANALYST PROGRAMMER",
"styles": {
"fontSize": "14.4px",
"fontWeight": "400",
"color": "rgb(204, 204, 204)",
"backgroundColor": "rgba(0, 0, 0, 0)",
"padding": "0px",
"height": "21.6094px"
}
},
{
"tag": "SPAN",
"class": "title-badge",
"text": "NODEJS + REACTJS DEVELOPER",
"styles": {
"fontSize": "14.4px",
"fontWeight": "400",
"color": "rgb(204, 204, 204)",
"backgroundColor": "rgba(0, 0, 0, 0)",
"padding": "0px",
"height": "21.6094px"
}
},
{
"tag": "SPAN",
"class": "title-badge",
"text": "WEB DEVELOPER",
"styles": {
"fontSize": "14.4px",
"fontWeight": "400",
"color": "rgb(204, 204, 204)",
"backgroundColor": "rgba(0, 0, 0, 0)",
"padding": "0px",
"height": "21.6094px"
}
},
{
"tag": "SPAN",
"class": "title-badge",
"text": "JAVA DEVELOPER",
"styles": {
"fontSize": "14.4px",
"fontWeight": "400",
"color": "rgb(204, 204, 204)",
"backgroundColor": "rgba(0, 0, 0, 0)",
"padding": "0px",
"height": "21.6094px"
}
},
{
"tag": "SPAN",
"class": "title-badge",
"text": "PHP DEVELOPER",
"styles": {
"fontSize": "14.4px",
"fontWeight": "400",
"color": "rgb(204, 204, 204)",
"backgroundColor": "rgba(0, 0, 0, 0)",
"padding": "0px",
"height": "21.6094px"
}
}
]
},
"comparison": {
"dimensionsMatch": false,
"pixelPerfect": null
}
}
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 389 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

@@ -0,0 +1,9 @@
{
"old": {},
"new": {
"backgroundColor": "rgb(209, 212, 210)",
"padding": "32px 24px",
"width": "300px",
"minWidth": "auto"
}
}
@@ -0,0 +1,37 @@
{
"old": {},
"new": {
"name": {
"fontFamily": "Quicksand, sans-serif",
"fontSize": "35.2px",
"fontWeight": "400",
"lineHeight": "38.72px",
"color": "rgb(0, 0, 0)",
"letterSpacing": "normal"
},
"sidebarTitle": {
"fontFamily": "Quicksand, sans-serif",
"fontSize": "18.72px",
"fontWeight": "500",
"lineHeight": "22.464px",
"color": "rgb(51, 51, 51)",
"letterSpacing": "normal"
},
"sectionTitle": {
"fontFamily": "Quicksand, sans-serif",
"fontSize": "20.8px",
"fontWeight": "500",
"lineHeight": "24.96px",
"color": "rgb(51, 51, 51)",
"letterSpacing": "normal"
},
"badge": {
"fontFamily": "Quicksand, \"Source Sans Pro\", -apple-system, system-ui, sans-serif",
"fontSize": "14.4px",
"fontWeight": "400",
"lineHeight": "21.6px",
"color": "rgb(204, 204, 204)",
"letterSpacing": "normal"
}
}
}
+353
View File
@@ -0,0 +1,353 @@
/**
* Visual Comparison Test Suite
* Compares new Go+HTMX CV (localhost:1999) vs old React CV (localhost:3000)
*/
const { test, expect } = require('@playwright/test');
const fs = require('fs');
const path = require('path');
const OLD_SITE = 'http://localhost:3000';
const NEW_SITE = 'http://localhost:1999';
const SCREENSHOTS_DIR = path.join(__dirname, 'screenshots');
// Ensure screenshots directory exists
if (!fs.existsSync(SCREENSHOTS_DIR)) {
fs.mkdirSync(SCREENSHOTS_DIR, { recursive: true });
}
test.describe('Visual Comparison: New vs Old CV', () => {
test('Full page screenshots', async ({ browser }) => {
const contextOld = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
const contextNew = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
const pageOld = await contextOld.newPage();
const pageNew = await contextNew.newPage();
// Load both sites
await pageOld.goto(OLD_SITE, { waitUntil: 'networkidle' });
await pageNew.goto(NEW_SITE, { waitUntil: 'networkidle' });
// Take full page screenshots
await pageOld.screenshot({
path: path.join(SCREENSHOTS_DIR, 'old-fullpage.png'),
fullPage: true
});
await pageNew.screenshot({
path: path.join(SCREENSHOTS_DIR, 'new-fullpage.png'),
fullPage: true
});
console.log('✓ Full page screenshots saved');
await contextOld.close();
await contextNew.close();
});
test('Header section comparison', async ({ browser }) => {
const contextOld = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
const contextNew = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
const pageOld = await contextOld.newPage();
const pageNew = await contextNew.newPage();
await pageOld.goto(OLD_SITE, { waitUntil: 'networkidle' });
await pageNew.goto(NEW_SITE, { waitUntil: 'networkidle' });
// Screenshot header sections
const headerOld = await pageOld.locator('.cv-title-badges-header, [class*="header"]').first();
const headerNew = await pageNew.locator('.cv-title-badges-header').first();
if (await headerOld.count() > 0) {
await headerOld.screenshot({ path: path.join(SCREENSHOTS_DIR, 'old-header.png') });
}
if (await headerNew.count() > 0) {
await headerNew.screenshot({ path: path.join(SCREENSHOTS_DIR, 'new-header.png') });
}
console.log('✓ Header screenshots saved');
await contextOld.close();
await contextNew.close();
});
test('Badge measurements comparison', async ({ browser }) => {
const contextOld = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
const contextNew = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
const pageOld = await contextOld.newPage();
const pageNew = await contextNew.newPage();
await pageOld.goto(OLD_SITE, { waitUntil: 'networkidle' });
await pageNew.goto(NEW_SITE, { waitUntil: 'networkidle' });
// Measure badge elements
const measurements = {
old: {},
new: {}
};
// New site badge measurements
const badgeNew = pageNew.locator('.title-badge').first();
if (await badgeNew.count() > 0) {
const box = await badgeNew.boundingBox();
const styles = await badgeNew.evaluate(el => {
const computed = window.getComputedStyle(el);
return {
height: computed.height,
padding: computed.padding,
fontSize: computed.fontSize,
fontWeight: computed.fontWeight,
color: computed.color,
backgroundColor: computed.backgroundColor,
borderRadius: computed.borderRadius,
display: computed.display,
alignItems: computed.alignItems
};
});
measurements.new.badge = { box, styles };
}
// Old site badge measurements
const badgeOld = pageOld.locator('.title-badge, [class*="badge"]').first();
if (await badgeOld.count() > 0) {
const box = await badgeOld.boundingBox();
const styles = await badgeOld.evaluate(el => {
const computed = window.getComputedStyle(el);
return {
height: computed.height,
padding: computed.padding,
fontSize: computed.fontSize,
fontWeight: computed.fontWeight,
color: computed.color,
backgroundColor: computed.backgroundColor,
borderRadius: computed.borderRadius,
display: computed.display,
alignItems: computed.alignItems
};
});
measurements.old.badge = { box, styles };
}
// Save measurements
fs.writeFileSync(
path.join(SCREENSHOTS_DIR, 'badge-measurements.json'),
JSON.stringify(measurements, null, 2)
);
console.log('✓ Badge measurements saved');
console.log('\nBadge Comparison:');
console.log('OLD:', JSON.stringify(measurements.old.badge?.styles, null, 2));
console.log('NEW:', JSON.stringify(measurements.new.badge?.styles, null, 2));
await contextOld.close();
await contextNew.close();
});
test('Typography comparison', async ({ browser }) => {
const contextOld = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
const contextNew = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
const pageOld = await contextOld.newPage();
const pageNew = await contextNew.newPage();
await pageOld.goto(OLD_SITE, { waitUntil: 'networkidle' });
await pageNew.goto(NEW_SITE, { waitUntil: 'networkidle' });
const typography = {
old: {},
new: {}
};
// Selectors to compare
const selectors = {
name: '.cv-name',
sidebarTitle: '.sidebar-title',
sectionTitle: '.section-title',
badge: '.title-badge'
};
// Measure new site typography
for (const [key, selector] of Object.entries(selectors)) {
const element = pageNew.locator(selector).first();
if (await element.count() > 0) {
typography.new[key] = await element.evaluate(el => {
const computed = window.getComputedStyle(el);
return {
fontFamily: computed.fontFamily,
fontSize: computed.fontSize,
fontWeight: computed.fontWeight,
lineHeight: computed.lineHeight,
color: computed.color,
letterSpacing: computed.letterSpacing
};
});
}
}
// Measure old site typography
for (const [key, selector] of Object.entries(selectors)) {
const element = pageOld.locator(selector).first();
if (await element.count() > 0) {
typography.old[key] = await element.evaluate(el => {
const computed = window.getComputedStyle(el);
return {
fontFamily: computed.fontFamily,
fontSize: computed.fontSize,
fontWeight: computed.fontWeight,
lineHeight: computed.lineHeight,
color: computed.color,
letterSpacing: computed.letterSpacing
};
});
}
}
// Save typography comparison
fs.writeFileSync(
path.join(SCREENSHOTS_DIR, 'typography-comparison.json'),
JSON.stringify(typography, null, 2)
);
console.log('✓ Typography comparison saved');
console.log('\nTypography Comparison:');
console.log('OLD:', JSON.stringify(typography.old, null, 2));
console.log('NEW:', JSON.stringify(typography.new, null, 2));
await contextOld.close();
await contextNew.close();
});
test('Sidebar comparison', async ({ browser }) => {
const contextOld = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
const contextNew = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
const pageOld = await contextOld.newPage();
const pageNew = await contextNew.newPage();
await pageOld.goto(OLD_SITE, { waitUntil: 'networkidle' });
await pageNew.goto(NEW_SITE, { waitUntil: 'networkidle' });
// Screenshot sidebars
const sidebarOld = pageOld.locator('.cv-sidebar, [class*="sidebar"]').first();
const sidebarNew = pageNew.locator('.cv-sidebar').first();
if (await sidebarOld.count() > 0) {
await sidebarOld.screenshot({ path: path.join(SCREENSHOTS_DIR, 'old-sidebar.png') });
}
if (await sidebarNew.count() > 0) {
await sidebarNew.screenshot({ path: path.join(SCREENSHOTS_DIR, 'new-sidebar.png') });
}
// Measure sidebar styles
const sidebarComparison = {
old: {},
new: {}
};
if (await sidebarNew.count() > 0) {
sidebarComparison.new = await sidebarNew.evaluate(el => {
const computed = window.getComputedStyle(el);
return {
backgroundColor: computed.backgroundColor,
padding: computed.padding,
width: computed.width,
minWidth: computed.minWidth
};
});
}
if (await sidebarOld.count() > 0) {
sidebarComparison.old = await sidebarOld.evaluate(el => {
const computed = window.getComputedStyle(el);
return {
backgroundColor: computed.backgroundColor,
padding: computed.padding,
width: computed.width,
minWidth: computed.minWidth
};
});
}
fs.writeFileSync(
path.join(SCREENSHOTS_DIR, 'sidebar-comparison.json'),
JSON.stringify(sidebarComparison, null, 2)
);
console.log('✓ Sidebar comparison saved');
console.log('\nSidebar Comparison:');
console.log('OLD:', JSON.stringify(sidebarComparison.old, null, 2));
console.log('NEW:', JSON.stringify(sidebarComparison.new, null, 2));
await contextOld.close();
await contextNew.close();
});
test('Critical elements style extraction', async ({ browser }) => {
const contextOld = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
const contextNew = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
const pageOld = await contextOld.newPage();
const pageNew = await contextNew.newPage();
await pageOld.goto(OLD_SITE, { waitUntil: 'networkidle' });
await pageNew.goto(NEW_SITE, { waitUntil: 'networkidle' });
const criticalElements = [
'.cv-title-badges-header',
'.title-badge',
'.badge-separator',
'.sidebar-title',
'.section-title',
'.cv-name'
];
const styleComparison = {
old: {},
new: {}
};
// Extract from new site
for (const selector of criticalElements) {
const element = pageNew.locator(selector).first();
if (await element.count() > 0) {
styleComparison.new[selector] = await element.evaluate(el => {
const computed = window.getComputedStyle(el);
const styles = {};
for (let i = 0; i < computed.length; i++) {
const prop = computed[i];
styles[prop] = computed.getPropertyValue(prop);
}
return styles;
});
}
}
// Extract from old site
for (const selector of criticalElements) {
const element = pageOld.locator(selector).first();
if (await element.count() > 0) {
styleComparison.old[selector] = await element.evaluate(el => {
const computed = window.getComputedStyle(el);
const styles = {};
for (let i = 0; i < computed.length; i++) {
const prop = computed[i];
styles[prop] = computed.getPropertyValue(prop);
}
return styles;
});
}
}
fs.writeFileSync(
path.join(SCREENSHOTS_DIR, 'critical-elements-full-styles.json'),
JSON.stringify(styleComparison, null, 2)
);
console.log('✓ Critical elements styles extracted');
await contextOld.close();
await contextNew.close();
});
});