/* global React, PROJECTS, STACK, TIMELINE, NOW_LIST, MARQUEE, PROFILE */
const { useEffect, useRef, useState } = React;
/* ─── visuals (per-card) ───────────────────────────── */
function VisFlow() {
return (
);
}
function VisBars() {
const heights = [40, 65, 35, 80, 55, 90, 45, 70, 30, 85, 50, 75];
return (
{heights.map((h, i) => (
))}
);
}
function CardVis({ kind }) {
if (kind === 'flow') return
;
if (kind === 'bars') return ;
if (kind === 'grid') return ;
if (kind === 'rings') return ;
return (
);
}
/* ─── reveal observer ──────────────────────────────── */
function useReveal() {
useEffect(() => {
const els = document.querySelectorAll('.reveal');
const io = new IntersectionObserver((entries) => {
entries.forEach(e => { if (e.isIntersecting) { e.target.classList.add('in'); io.unobserve(e.target); } });
}, { threshold: 0.12 });
els.forEach(el => io.observe(el));
return () => io.disconnect();
}, []);
}
/* ─── card with cursor spotlight ───────────────────── */
function Card({ p }) {
const ref = useRef(null);
const onMove = (e) => {
const r = ref.current.getBoundingClientRect();
ref.current.style.setProperty('--mx', (e.clientX - r.left) + 'px');
ref.current.style.setProperty('--my', (e.clientY - r.top) + 'px');
};
return (
{p.num} · {p.cat}
{p.year} · {p.metric.v} {p.metric.l}
{p.title}
{p.desc}
{p.tags.map(t => {t})}
Case study ↗
);
}
/* ─── nav ──────────────────────────────────────────── */
function Nav() {
return (
);
}
/* ─── status bar ───────────────────────────────────── */
function StatusBar() {
const [t, setT] = useState(() => fmt());
useEffect(() => { const id = setInterval(() => setT(fmt()), 60_000); return () => clearInterval(id); }, []);
function fmt() {
const d = new Date();
const opts = { timeZone: PROFILE.status.timezone, hour: '2-digit', minute: '2-digit', hour12: false };
return new Intl.DateTimeFormat('en-GB', opts).format(d) + ' ' + PROFILE.status.tzLabel;
}
return (
{PROFILE.status.availability}
{PROFILE.status.version}
/
{PROFILE.status.city} · {t}
/
{PROFILE.status.build}
);
}
/* ─── hero ─────────────────────────────────────────── */
function Hero() {
return (
{PROFILE.hero.badge}
{PROFILE.hero.h1[0]}
{PROFILE.hero.h1[1]} {PROFILE.hero.h1[2]}
{PROFILE.hero.h1[3]}
{PROFILE.hero.lede}
);
}
/* ─── marquee ──────────────────────────────────────── */
function Marquee() {
const items = [...MARQUEE, ...MARQUEE];
return (
{items.map((m, i) => (
{m}
))}
);
}
/* ─── work ─────────────────────────────────────────── */
function Work() {
return (
{PROFILE.sections.work[0]}{PROFILE.sections.work[1]}{PROFILE.sections.work[2]}
{PROJECTS.map(p => )}
);
}
/* ─── stack ────────────────────────────────────────── */
function Stack() {
const groups = STACK.reduce((acc, s) => { (acc[s.cat] ||= []).push(s); return acc; }, {});
return (
{PROFILE.sections.stack[0]}{PROFILE.sections.stack[1]}{PROFILE.sections.stack[2]}
{Object.entries(groups).map(([cat, items]) => (
))}
);
}
/* ─── timeline ─────────────────────────────────────── */
function Timeline() {
return (
{PROFILE.sections.timeline[0]}{PROFILE.sections.timeline[1]}{PROFILE.sections.timeline[2]}
{TIMELINE.map((t, i) => (
))}
);
}
/* ─── about ────────────────────────────────────────── */
function About() {
return (
{PROFILE.sections.about[0]}{PROFILE.sections.about[1]}{PROFILE.sections.about[2]}
{PROFILE.about.bio.map((para, i) =>
{para}
)}
);
}
/* ─── footer / contact ─────────────────────────────── */
function Footer() {
return (
);
}
/* ─── tweaks panel ─────────────────────────────────── */
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"accentMode": "moonlight",
"displayFont": "geist",
"italicFont": "instrument",
"grain": true,
"marquee": true,
"density": "comfortable"
}/*EDITMODE-END*/;
function ApplyTweaks({ tweaks }) {
useEffect(() => {
const root = document.documentElement;
const map = {
moonlight: { glow: 'rgba(255,255,255,0.55)', tint: '255,255,255' },
arctic: { glow: 'rgba(200,225,255,0.55)', tint: '210,225,245' },
ember: { glow: 'rgba(255,225,200,0.50)', tint: '245,225,205' },
};
const c = map[tweaks.accentMode] || map.moonlight;
root.style.setProperty('--glow', c.glow);
root.style.setProperty('--tint', c.tint);
const sans = {
geist: "'Geist',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif",
inter: "'Inter',-apple-system,sans-serif",
neue: "'Neue Haas Grotesk Display Pro','Helvetica Neue',Helvetica,Arial,sans-serif",
}[tweaks.displayFont] || "'Geist',sans-serif";
root.style.setProperty('--sans', sans);
const it = {
instrument: "'Instrument Serif',serif",
cormorant: "'Cormorant Garamond',serif",
none: "'Geist',sans-serif",
}[tweaks.italicFont] || "'Instrument Serif',serif";
document.querySelectorAll('.it').forEach(el => { el.style.fontFamily = it; });
document.querySelector('.grain').style.display = tweaks.grain ? 'block' : 'none';
document.querySelector('.marquee').style.display = tweaks.marquee ? 'block' : 'none';
const pad = tweaks.density === 'compact' ? '60px' : tweaks.density === 'spacious' ? '160px' : '110px';
document.querySelectorAll('section.block').forEach(el => {
el.style.paddingTop = pad; el.style.paddingBottom = pad;
});
}, [tweaks]);
return null;
}
function Tweaks() {
const [tweaks, setTweak] = window.useTweaks(TWEAK_DEFAULTS);
const T = window.TweaksPanel, S = window.TweakSection,
R = window.TweakRadio, B = window.TweakToggle, Sel = window.TweakSelect;
return (
<>
setTweak('accentMode', v)}
options={[{value:'moonlight',label:'Moonlight'},{value:'arctic',label:'Arctic'},{value:'ember',label:'Ember'}]} />
setTweak('displayFont', v)}
options={[{value:'geist',label:'Geist (default)'},{value:'inter',label:'Inter'},{value:'neue',label:'Neue Haas / Helvetica'}]} />
setTweak('italicFont', v)}
options={[{value:'instrument',label:'Instrument Serif'},{value:'cormorant',label:'Cormorant Garamond'},{value:'none',label:'No italic accent'}]} />
setTweak('grain', v)} />
setTweak('marquee', v)} />
setTweak('density', v)}
options={[{value:'compact',label:'Compact'},{value:'comfortable',label:'Default'},{value:'spacious',label:'Spacious'}]} />
>
);
}
/* ─── app ──────────────────────────────────────────── */
function App() {
useReveal();
return (
<>
>
);
}
ReactDOM.createRoot(document.getElementById('root')).render();