// Shared UI primitives + tweak defaults. Requires React + tweaks-panel.jsx.
const { useState, useEffect, useRef, useMemo } = React;
window.SHARED_TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"accent": "cyan",
"motion": "subtle",
"grain": true
}/*EDITMODE-END*/;
window.useTime = function useTime() {
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(id);
}, []);
return now;
};
window.useReveal = function useReveal() {
const ref = useRef(null);
const [shown, setShown] = useState(false);
const [armed, setArmed] = useState(false);
useEffect(() => {
if (!ref.current) return;
const el = ref.current;
if (typeof IntersectionObserver === "undefined") { setShown(true); return; }
setArmed(true);
let done = false;
const reveal = () => { if (!done) { done = true; setShown(true); } };
const io = new IntersectionObserver(
([entry]) => { if (entry.isIntersecting) { reveal(); io.disconnect(); } },
{ threshold: 0.05, rootMargin: "0px 0px -10px 0px" }
);
io.observe(el);
requestAnimationFrame(() => {
const r = el.getBoundingClientRect();
if (r.top < window.innerHeight && r.bottom > 0) reveal();
});
const t = setTimeout(reveal, 1200);
return () => { io.disconnect(); clearTimeout(t); };
}, []);
return [ref, shown, armed];
};
window.Reveal = function Reveal({ children, delay = 0, className = "", as: Tag = "div" }) {
const [ref, shown, armed] = window.useReveal();
const cls = ["reveal", armed ? "is-armed" : "", shown ? "is-shown" : "", className].filter(Boolean).join(" ");
return {children};
};
window.Caret = function Caret() {
return ;
};
// Viewer modal — embeds PDF or demo in an iframe, with a download fallback.
// kind: "pdf" | "demo"
// src: path to file (e.g. "papers/foo.pdf" or "demos/foo/index.html")
// download: optional separate downloadable file (used when src can't be embedded — Word, Excel, Python, zip)
// title: heading text
window.Viewer = function Viewer({ open, onClose, kind, src, download, title }) {
useEffect(() => {
if (!open) return;
const onKey = (e) => { if (e.key === "Escape") onClose(); };
document.addEventListener("keydown", onKey);
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = "";
};
}, [open, onClose]);
if (!open) return null;
const hasEmbed = !!src;
const isPdf = kind === "pdf" || (src && /\.pdf($|\?)/i.test(src));
return (
e.stopPropagation()}>
{hasEmbed ? (
) : (
∅
{kind === "pdf" ? "PDF coming soon." : "Demo coming soon."}
{download && (
download file ↓
)}
)}
);
};
window.SiteHeader = function SiteHeader({ here }) {
const now = window.useTime();
const stamp = useMemo(() => now.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false }), [now]);
// Test pages can override link targets by setting window.NAV_TARGETS
const nav = window.NAV_TARGETS || { home: "index.html", papers: "papers.html", projects: "projects.html" };
return (
);
};
window.SiteFooter = function SiteFooter() {
const now = window.useTime();
return (
);
};
window.SharedTweaks = function SharedTweaks() {
const [tweaks, setTweak] = useTweaks(window.SHARED_TWEAK_DEFAULTS);
useEffect(() => {
const accent = window.ACCENTS[tweaks.accent] || window.ACCENTS.cyan;
const root = document.documentElement;
root.style.setProperty("--accent-h", accent.hue);
root.style.setProperty("--accent-c", accent.chroma);
root.dataset.motion = tweaks.motion;
root.dataset.grain = tweaks.grain ? "on" : "off";
}, [tweaks]);
return (
<>
{tweaks.grain && }
setTweak("accent", v)}
options={[
{ value: "cyan", label: "cyan" },
{ value: "lime", label: "lime" },
{ value: "amber", label: "amber" },
{ value: "rose", label: "rose" },
{ value: "violet", label: "violet" },
{ value: "bone", label: "bone" },
]}
/>
setTweak("motion", v)}
options={[
{ value: "off", label: "off" },
{ value: "subtle", label: "subtle" },
{ value: "playful", label: "playful" },
]}
/>
setTweak("grain", v)} />
>
);
};