/* ========================================================= App — Fran Ruiz portfolio composer ========================================================= */ const { useEffect, useState } = React; const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ "theme": "light", "lang": "de", "accent": "#e8624a" }/*EDITMODE-END*/; // Initial language resolution. Priority (highest → lowest): // 1. ?lang=xx URL query → shareable per-language URLs (hreflang) // 2. localStorage "fran-lang" → user's previous manual choice // 3. fallback "de" → German is the site's primary language // NOTE: navigator.language auto-detection was removed deliberately. Googlebot // renders with an en-US browser, so browser-sniffing on the clean URLs made // Google index ENGLISH content at the canonical German URLs. The clean URL // must be deterministic (German) for Swiss SEO; EN/ES live at ?lang= URLs // declared via hreflang, and a manual pick from the toggle still persists. // SUPPORTED_LANGS is the source of truth for which short codes we ship. const SUPPORTED_LANGS = ["en", "de", "es"]; const _resolveInitialLang = () => { try { // (1) explicit ?lang= query — wins over everything so a shared URL like // franruiz.ch/?lang=es always shows Spanish regardless of stored prefs. if (typeof URLSearchParams !== "undefined" && typeof location !== "undefined") { const q = new URLSearchParams(location.search).get("lang"); if (q && SUPPORTED_LANGS.includes(q)) return q; } // (2) localStorage — set by setLang() when the user picks from the toggle. if (typeof localStorage !== "undefined") { const s = localStorage.getItem("fran-lang"); if (s && SUPPORTED_LANGS.includes(s)) return s; } } catch (e) { /* localStorage / URLSearchParams blocked → fall through */ } // (3) default — German (primary market: Swiss small businesses) return "de"; }; const STORED_LANG = _resolveInitialLang(); // Theme bootstrapping. Same shape as the inline boot script in index.html: // 1. "fran-theme-override" in localStorage → manual toggle wins // 2. prefers-color-scheme: dark → OS-level dark mode // 3. fall through to TWEAK_DEFAULTS.theme → designer default (light) // We compute this once at module load so the toggle button mounts in the // right state (light or dark) on the very first render — no flicker from // the React tree disagreeing with the inline boot script. const _readOverride = () => { try { return localStorage.getItem("fran-theme-override"); } catch (e) { return null; } }; const _systemDark = () => typeof window !== "undefined" && !!window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches; const STORED_THEME = _readOverride(); const INITIAL_THEME = STORED_THEME === "light" || STORED_THEME === "dark" ? STORED_THEME : (_systemDark() ? "dark" : "light"); const RUNTIME_DEFAULTS = { ...TWEAK_DEFAULTS, theme: INITIAL_THEME, ...(STORED_LANG ? { lang: STORED_LANG } : {}), }; const ACCENT_OPTIONS = [ "#e8624a", // terracotta (default — from Fimed) "#5fa7d4", // sky blue (JP) "#a3b59a", // sage green "#f4ae5b", // warm sand (Garden Brasas) "#9fc9be", // mint (Bodegas) "#1a1a1a", // ink (monochrome) ]; const apply = (t) => { const r = document.documentElement; r.setAttribute("data-theme", t.theme); r.setAttribute("data-lang", t.lang); r.setAttribute("lang", t.lang); r.style.setProperty("--accent", t.accent); // Reflect the override flag on so the CSS @media block in // styles.css knows whether to defer to the OS or stand down. The // flag is sticky once set — the user has signalled they want manual // control, so we keep it set even if they toggle back to the OS's // current colour. (The boot script does the same thing for first // paint; this keeps it consistent across React re-renders.) try { if (localStorage.getItem("fran-theme-override")) { r.setAttribute("data-theme-override", "true"); } } catch (e) { /* localStorage blocked — fine, override just won't persist */ } }; const App = () => { const [tweaks, setTweak] = useTweaks(RUNTIME_DEFAULTS); const lang = tweaks.lang; const t = T[lang] || T.en; useEffect(() => { apply(tweaks); }, [tweaks]); // Scroll reveal (manual fallback) — opts the page into hide-then-reveal animation useEffect(() => { document.documentElement.setAttribute("data-anim", ""); // Suppress smooth-scroll-behavior briefly while React mounts so any // programmatic position adjustments (carousel, fonts loading) don't // get animated and feel like the page is "fighting" the user. document.documentElement.classList.add("scroll-settling"); const settleTimer = setTimeout(() => { document.documentElement.classList.remove("scroll-settling"); }, 800); // If the animation timeline isn't advancing (e.g. sandboxed iframe), // disable hide-then-reveal so content stays visible. const t0 = document.timeline?.currentTime ?? 0; setTimeout(() => { const t1 = document.timeline?.currentTime ?? 0; if (t0 === t1) document.documentElement.removeAttribute("data-anim"); }, 200); // Reveal-on-scroll using IntersectionObserver (off-main-thread, no // per-frame layout reads). One observer instance is reused for the // whole page; new `.reveal` nodes mounted later get picked up by a // few scheduled sweeps + a focused MutationObserver scoped to //
, which is where reveal content lives. We deliberately do // NOT observe the whole body subtree — the chatbot/floating UI // mutates frequently and would re-run the sweep needlessly. const io = (typeof IntersectionObserver !== "undefined") ? new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { entry.target.classList.add("in"); io.unobserve(entry.target); } }); }, { rootMargin: "80px 0px", threshold: 0.01 }) : null; const sweep = () => { const els = document.querySelectorAll(".reveal:not(.in), .reveal-stagger:not(.in)"); if (io) { els.forEach((el) => io.observe(el)); } else { els.forEach((el) => el.classList.add("in")); } }; sweep(); const t1 = setTimeout(sweep, 300); const t2 = setTimeout(sweep, 1200); // Scoped MutationObserver on
only, debounced via rAF so a // burst of React re-renders coalesces into one sweep per frame. const mainEl = document.querySelector("main") || document.body; let moRaf = 0; const mo = (typeof MutationObserver !== "undefined") ? new MutationObserver(() => { if (moRaf) return; moRaf = requestAnimationFrame(() => { moRaf = 0; sweep(); }); }) : null; if (mo) mo.observe(mainEl, { childList: true, subtree: true }); // Safety net: after 2.5s, reveal anything still hidden so a missed // observation never leaves content invisible. const safety = setTimeout(() => { document.querySelectorAll(".reveal:not(.in), .reveal-stagger:not(.in)").forEach((el) => el.classList.add("in")); }, 2500); return () => { if (io) io.disconnect(); if (mo) mo.disconnect(); if (moRaf) cancelAnimationFrame(moRaf); clearTimeout(t1); clearTimeout(t2); clearTimeout(safety); clearTimeout(settleTimer); }; }, []); const setLang = (v) => { setTweak("lang", v); try { localStorage.setItem("fran-lang", v); } catch (e) {} }; // Every code path that lets the user pick a theme funnels through this // helper so the override flag is recorded exactly once, in one place. const setTheme = (v) => { setTweak("theme", v); try { localStorage.setItem("fran-theme-override", v); } catch (e) {} document.documentElement.setAttribute("data-theme-override", "true"); }; const toggleTheme = () => setTheme(tweaks.theme === "dark" ? "light" : "dark"); // Live-follow OS theme changes for visitors who haven't manually chosen. // As soon as they touch the toggle, `fran-theme-override` lands in // localStorage and this handler bails out — their choice wins. useEffect(() => { if (typeof window === "undefined" || !window.matchMedia) return; const mq = window.matchMedia("(prefers-color-scheme: dark)"); const onChange = (e) => { let override = null; try { override = localStorage.getItem("fran-theme-override"); } catch (_) {} if (override === "light" || override === "dark") return; setTweak("theme", e.matches ? "dark" : "light"); }; if (mq.addEventListener) mq.addEventListener("change", onChange); else if (mq.addListener) mq.addListener(onChange); // Safari < 14 return () => { if (mq.removeEventListener) mq.removeEventListener("change", onChange); else if (mq.removeListener) mq.removeListener(onChange); }; }, []); return ( <>