// app.jsx — root + routing + tweaks wiring const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ "palette": ["#22d3c4", "#050505", "#f4f4f5"], "markaloudTreatment": "spotlight", "motion": "lively", "density": "regular", "displayFont": "Manrope", "showGrain": true }/*EDITMODE-END*/; const PALETTES = { "Lab teal": ["#22d3c4", "#050505", "#f4f4f5"], "Phosphor": ["#5eead4", "#050608", "#f4f4f5"], "Lab green": ["#7cfc4d", "#050505", "#f4f4f5"], "Emerald": ["#34d399", "#070908", "#f4f4f5"], "Acid lime": ["#d4ff3a", "#060604", "#f4f4f5"], }; const TREATMENT_LABELS = { spotlight: "Spotlight", split: "Split", manifesto: "Manifesto", }; function hexToRgb(hex) { const m = hex.replace("#", ""); const v = parseInt(m.length === 3 ? m.split("").map(c => c + c).join("") : m, 16); return [(v >> 16) & 255, (v >> 8) & 255, v & 255]; } function rgba(hex, a) { const [r, g, b] = hexToRgb(hex); return `rgba(${r}, ${g}, ${b}, ${a})`; } function hexToHue(hex) { const [r, g, b] = hexToRgb(hex).map(v => v / 255); const max = Math.max(r, g, b); const min = Math.min(r, g, b); if (max === min) return 0; const d = max - min; let h; switch (max) { case r: h = ((g - b) / d + (g < b ? 6 : 0)); break; case g: h = ((b - r) / d + 2); break; default: h = ((r - g) / d + 4); } return (h / 6) * 360; } // Brand teal sampled from the source logo PNG. const SOURCE_LOGO_HUE = 175; function applyPalette(palette) { const [accent, bg] = palette; const root = document.documentElement; root.style.setProperty("--accent", accent); root.style.setProperty("--accent-glow", rgba(accent, 0.45)); root.style.setProperty("--accent-soft", rgba(accent, 0.12)); // Pick ink color (text on accent) based on lightness const [r, g, b] = hexToRgb(accent); const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255; root.style.setProperty("--accent-ink", lum > 0.6 ? "#042320" : "#042320"); root.style.setProperty("--bg", bg); // Shift the logo to match the palette: rotate from brand teal hue (~175°) to // the accent hue. White text and black background are unaffected by hue-rotate. let delta = hexToHue(accent) - SOURCE_LOGO_HUE; if (delta > 180) delta -= 360; if (delta < -180) delta += 360; root.style.setProperty("--logo-hue-rotate", `${delta.toFixed(1)}deg`); } function applyMotion(motion) { const v = motion === "off" ? 0 : motion === "subtle" ? 0.5 : 1; document.documentElement.style.setProperty("--motion", v); document.documentElement.setAttribute("data-motion", motion); } function applyDensity(d) { const root = document.documentElement; if (d === "compact") { root.style.setProperty("--pad", "24px"); } else if (d === "comfy") { root.style.setProperty("--pad", "40px"); } else { root.style.setProperty("--pad", "32px"); } } function applyFont(font) { document.documentElement.style.setProperty("--font-display", `"${font}", "Manrope", ui-sans-serif, system-ui, sans-serif`); if (font !== "Manrope") { document.documentElement.style.setProperty("--font-sans", `"${font}", "Manrope", ui-sans-serif, system-ui, sans-serif`); } else { document.documentElement.style.setProperty("--font-sans", `"Manrope", ui-sans-serif, system-ui, system-ui, sans-serif`); } } function useRoute() { const KNOWN = ["home", "markaloud", "work", "about", "contact"]; const parse = () => { // Prefer real pathname routing when the URL has a real path const path = window.location.pathname.replace(/^\/|\/$/g, "").toLowerCase(); if (path && KNOWN.includes(path)) return path; // Fallback: hash routing (works for file://, local previews, etc.) const h = window.location.hash || ""; const p = h.replace(/^#\/?/, "").split("/")[0].toLowerCase(); if (p && KNOWN.includes(p)) return p; return "home"; }; const [route, setRoute] = React.useState(parse); React.useEffect(() => { const onChange = () => { setRoute(parse()); window.scrollTo({ top: 0, behavior: "instant" }); }; window.addEventListener("hashchange", onChange); window.addEventListener("popstate", onChange); return () => { window.removeEventListener("hashchange", onChange); window.removeEventListener("popstate", onChange); }; }, []); const go = (r) => { // Use pushState for real URLs so each route has its own indexable URL const target = r === "home" ? "/" : "/" + r; try { // History API works on http/https; falls back to hash on file:// if (window.location.protocol === "file:") { window.location.hash = r === "home" ? "" : "#/" + r; } else { if (window.location.pathname !== target) { window.history.pushState({}, "", target); } setRoute(r); window.scrollTo({ top: 0, behavior: "instant" }); } } catch { window.location.hash = r === "home" ? "" : "#/" + r; } }; return [route, go]; } function App() { const [t, setTweak] = useTweaks(TWEAK_DEFAULTS); const [route, go] = useRoute(); React.useEffect(() => { applyPalette(t.palette); }, [t.palette]); React.useEffect(() => { applyMotion(t.motion); }, [t.motion]); React.useEffect(() => { applyDensity(t.density); }, [t.density]); React.useEffect(() => { applyFont(t.displayFont); }, [t.displayFont]); // SEO: keep , canonical, and Open Graph URL in sync with route. React.useEffect(() => { const meta = { home: { title: "Otherworld — AI & Robotics Engineering · Adam Morgan", desc: "Otherworld Dev Ltd — the AI & robotics engineering practice of Adam Morgan. Lecturer in Robotics, Swansea. Building Markaloud, an academic audio-marking tool." }, markaloud: { title: "Markaloud — Academic audio-marking · Otherworld", desc: "Markaloud — academic audio-marking for vivas and oral assessments. AI-structured, rubric-aligned, constructive feedback for educators." }, work: { title: "Selected work — Robotics, AI & embedded · Otherworld", desc: "Selected engineering work — Bifrost (Thor robot GUI), RSIPI (KUKA control), ThorRR, drone tracking, high-speed drilling imaging, and Markaloud." }, about: { title: "About Adam Morgan — Lecturer in Robotics, AI engineer · Otherworld", desc: "Adam Morgan — AI & robotics engineer. Lecturer in Robotics at Swansea. 5 peer-reviewed publications. Going full-time independent from July 2026." }, contact: { title: "Contact — Otherworld Dev Ltd", desc: "Get in touch with Otherworld Dev Ltd — consulting and build engagements in AI, robotics, and software, available from July 2026." }, }[route]; if (!meta) return; document.title = meta.title; const setMeta = (sel, attr, value) => { const el = document.querySelector(sel); if (el) el.setAttribute(attr, value); }; setMeta('meta[name="description"]', "content", meta.desc); setMeta('meta[property="og:title"]', "content", meta.title); setMeta('meta[property="og:description"]', "content", meta.desc); setMeta('meta[name="twitter:title"]', "content", meta.title); setMeta('meta[name="twitter:description"]', "content", meta.desc); const canon = "https://www.otherworld.dev" + (route === "home" ? "/" : "/" + route); setMeta('link[rel="canonical"]', "href", canon); setMeta('meta[property="og:url"]', "content", canon); }, [route]); React.useEffect(() => { // Update document body classes for grain document.body.classList.toggle("bg-grain", !!t.showGrain); document.body.classList.add("bg-ambient"); }, [t.showGrain]); const screenLabel = ({ home: "01 Home", markaloud: "02 Markaloud", work: "03 Work", about: "04 About", contact: "05 Contact", })[route]; let Page; if (route === "markaloud") Page = <MarkaloudPage go={go} />; else if (route === "work") Page = <WorkPage go={go} />; else if (route === "about") Page = <AboutPage go={go} />; else if (route === "contact") Page = <ContactPage />; else Page = <HomePage go={go} treatment={t.markaloudTreatment} />; return ( <div data-screen-label={screenLabel}> <Nav route={route} go={go} /> <div key={route + ":" + t.markaloudTreatment}> {Page} </div> <Footer go={go} /> <TweaksPanel title="Tweaks"> <TweakSection label="Color palette" /> <TweakColor label="Palette" value={t.palette} options={Object.values(PALETTES)} onChange={(v) => setTweak("palette", v)} /> <TweakSection label="Markaloud feature" /> <TweakRadio label="Treatment" value={t.markaloudTreatment} options={["spotlight", "split", "manifesto"]} onChange={(v) => setTweak("markaloudTreatment", v)} /> <TweakSection label="Motion" /> <TweakRadio label="Intensity" value={t.motion} options={["off", "subtle", "lively"]} onChange={(v) => setTweak("motion", v)} /> <TweakSection label="Layout" /> <TweakRadio label="Density" value={t.density} options={["compact", "regular", "comfy"]} onChange={(v) => setTweak("density", v)} /> <TweakSelect label="Display font" value={t.displayFont} options={["Manrope", "Space Grotesk", "Geist", "Inter Tight", "DM Sans"]} onChange={(v) => setTweak("displayFont", v)} /> <TweakToggle label="Film grain" value={t.showGrain} onChange={(v) => setTweak("showGrain", v)} /> </TweaksPanel> </div> ); } ReactDOM.createRoot(document.getElementById("root")).render(<App />); document.body.classList.add("app-ready");