- Remove fonts/material-symbols-outlined.woff2 (4MB external font eliminated) - Inline 38 Material Symbols as SVG paths (extracted to icon-paths.json) - Convert 5 Google-hosted images to self-hosted WebP - Add favicon.svg (adaptive auto_awesome icon, light + dark) - Add scrollbar-gutter: stable + body.modal-open to prevent modal jumps - Clean up preload links to fonts no longer used Net reduction: ~3.9MB external dependencies, zero CLS on icon swap
203 lines
6.0 KiB
JavaScript
203 lines
6.0 KiB
JavaScript
// ============================================
|
|
// Theme Toggle - Dark/Light Mode
|
|
// ============================================
|
|
|
|
/**
|
|
* Initialize theme on page load
|
|
* Checks localStorage for saved preference, falls back to light mode
|
|
*/
|
|
function initTheme() {
|
|
const savedTheme = localStorage.getItem("theme") || "light";
|
|
if (savedTheme === "dark") {
|
|
document.documentElement.classList.add("dark");
|
|
} else {
|
|
document.documentElement.classList.remove("dark");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Toggle between dark and light theme
|
|
* Saves preference to localStorage
|
|
*/
|
|
function toggleTheme() {
|
|
const html = document.documentElement;
|
|
if (html.classList.contains("dark")) {
|
|
html.classList.remove("dark");
|
|
localStorage.setItem("theme", "light");
|
|
} else {
|
|
html.classList.add("dark");
|
|
localStorage.setItem("theme", "dark");
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// Smooth Scrolling
|
|
// ============================================
|
|
|
|
/**
|
|
* Scroll to projects section
|
|
*/
|
|
function scrollToProjects() {
|
|
const projectsSection = document.getElementById("projects");
|
|
if (projectsSection) {
|
|
projectsSection.scrollIntoView({ behavior: "smooth" });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Scroll to contact section
|
|
*/
|
|
function scrollToContact() {
|
|
const contactSection = document.getElementById("contact");
|
|
if (contactSection) {
|
|
contactSection.scrollIntoView({ behavior: "smooth" });
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// Modal Management
|
|
// ============================================
|
|
|
|
/** Element that had focus before a modal was opened */
|
|
let _modalPreviousFocus = null;
|
|
|
|
/** Scrollbar width in px, measured on first open */
|
|
let _scrollbarWidth = null;
|
|
|
|
/**
|
|
* Measure and cache the browser scrollbar width so we can compensate
|
|
* for layout shift when body.overflow is toggled.
|
|
*/
|
|
function getScrollbarWidth() {
|
|
if (_scrollbarWidth !== null) return _scrollbarWidth;
|
|
const outer = document.createElement("div");
|
|
outer.style.cssText =
|
|
"visibility:hidden;position:absolute;overflow:scroll;width:100px;height:100px;";
|
|
document.body.appendChild(outer);
|
|
const inner = document.createElement("div");
|
|
inner.style.width = "100%";
|
|
outer.appendChild(inner);
|
|
const w = outer.offsetWidth - inner.offsetWidth;
|
|
outer.remove();
|
|
_scrollbarWidth = w;
|
|
return w;
|
|
}
|
|
|
|
/**
|
|
* Open modal by project ID.
|
|
* Saves the currently focused element and moves focus into the modal.
|
|
* Uses a CSS class (body.modal-open) so the scrollbar gutter stays reserved
|
|
* via html { scrollbar-gutter: stable }, preventing page jumps.
|
|
* @param {string} projectId - The project identifier
|
|
*/
|
|
function openModal(projectId) {
|
|
const modal = document.getElementById(projectId + "-modal");
|
|
if (modal) {
|
|
_modalPreviousFocus = document.activeElement;
|
|
modal.classList.remove("hidden");
|
|
document.body.classList.add("modal-open");
|
|
// Defensive fallback for browsers without scrollbar-gutter support
|
|
document.body.style.paddingRight = getScrollbarWidth() + "px";
|
|
|
|
// Move focus to the first focusable element inside the modal.
|
|
// PreventScroll keeps the page from jumping when the focused
|
|
// element would otherwise trigger a scroll-into-view.
|
|
const focusable = modal.querySelectorAll(
|
|
'button, [href], input, select, textarea, [contenteditable="true"], [tabindex]:not([tabindex="-1"])'
|
|
);
|
|
if (focusable.length > 0) {
|
|
focusable[0].focus({ preventScroll: true });
|
|
} else {
|
|
modal.setAttribute("tabindex", "-1");
|
|
modal.focus({ preventScroll: true });
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Close modal by project ID.
|
|
* Restores focus to the element that was focused before the modal opened.
|
|
* @param {string} projectId - The project identifier
|
|
*/
|
|
function closeModal(projectId) {
|
|
const modal = document.getElementById(projectId + "-modal");
|
|
if (modal) {
|
|
modal.classList.add("hidden");
|
|
document.body.classList.remove("modal-open");
|
|
document.body.style.paddingRight = "";
|
|
|
|
// Return focus to the element that triggered the modal.
|
|
// preventScroll keeps the page from jumping back to that element.
|
|
if (_modalPreviousFocus) {
|
|
try {
|
|
_modalPreviousFocus.focus({ preventScroll: true });
|
|
} catch (e) {
|
|
_modalPreviousFocus.focus();
|
|
}
|
|
_modalPreviousFocus = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// Laboratorio Tabs Management
|
|
// ============================================
|
|
|
|
/**
|
|
* Initialize tabs behavior for the Laboratorio section
|
|
*/
|
|
function initTabs() {
|
|
const tabButtons = document.querySelectorAll(".tab-btn");
|
|
const tabPanes = document.querySelectorAll(".tab-pane");
|
|
|
|
tabButtons.forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
const targetTab = btn.getAttribute("data-tab");
|
|
|
|
// Update button styles to reflect active/inactive state
|
|
tabButtons.forEach((b) => {
|
|
b.classList.remove("bg-primary", "text-white", "shadow-md", "shadow-primary/25");
|
|
b.classList.add("text-slate-600", "dark:text-slate-400", "hover:bg-slate-100", "dark:hover:bg-white/5");
|
|
});
|
|
btn.classList.add("bg-primary", "text-white", "shadow-md", "shadow-primary/25");
|
|
btn.classList.remove("text-slate-600", "dark:text-slate-400", "hover:bg-slate-100", "dark:hover:bg-white/5");
|
|
|
|
// Show the selected tab content and hide the others
|
|
tabPanes.forEach((pane) => {
|
|
if (pane.id === `tab-${targetTab}`) {
|
|
pane.classList.remove("hidden");
|
|
pane.classList.add("block");
|
|
} else {
|
|
pane.classList.remove("block");
|
|
pane.classList.add("hidden");
|
|
}
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
// ============================================
|
|
// Event Listeners
|
|
// ============================================
|
|
|
|
/**
|
|
* Close modal when ESC key is pressed
|
|
*/
|
|
document.addEventListener("keydown", function (event) {
|
|
if (event.key === "Escape") {
|
|
const modals = document.querySelectorAll(
|
|
'[id$="-modal"]:not(.hidden)'
|
|
);
|
|
modals.forEach((modal) => {
|
|
const projectId = modal.id.replace("-modal", "");
|
|
closeModal(projectId);
|
|
});
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Initialize theme and tabs
|
|
*/
|
|
initTheme();
|
|
initTabs();
|