// portfolio-parts.jsx — shared pieces for the Jaxon Stickler site.
// Exposes on window: FONT, GalleryItem, DepthBackground, CustomCursor,
// LocationSignifier, BioAbout, BioShort, Footer
const { useState: usePState, useEffect: usePEffect, useRef: usePRef } = React;
// Mobile detection hook — shared globally (other files use window.useMobile)
function useMobile() {
const [m, setM] = usePState(() => window.innerWidth < 768);
usePEffect(() => {
const h = () => setM(window.innerWidth < 768);
window.addEventListener('resize', h);
return () => window.removeEventListener('resize', h);
}, []);
return m;
}
const FONT = "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif";
const DEPTH_BG = 'images/about/depth-bg.png';
// ---------------------------------------------------------------- gallery item
function GalleryItem({ item, rounded }) {
const radius = rounded ? 4 : 0;
if (item.type === 'video') {
return (
);
}
if (item.type === 'vimeo') {
const vimeoSrc = item.src + (item.src.includes('?') ? '&muted=1' : '?muted=1');
return (
);
}
if (item.type === 'youtube') {
const ytSrc = item.src + (item.src.includes('?') ? '&' : '?') + 'rel=0';
return (
);
}
return (
);
}
// ---------------------------------------------------------- depth cloud bg
// Mounts the WebGL2 cursor-lit point cloud (depth-cloud.js) behind everything.
function DepthCloud() {
const ref = usePRef(null);
usePEffect(() => {
if (!ref.current || !window.initDepthCloud) return;
const cleanup = window.initDepthCloud(ref.current);
return cleanup;
}, []);
return (
);
}
// ---------------------------------------------------------------- custom cursor (+)
function CustomCursor({ enabled = true }) {
const ref = usePRef(null);
usePEffect(() => {
if (!enabled) return;
if (window.matchMedia && window.matchMedia('(pointer: coarse)').matches) return;
const el = ref.current;
if (!el) return;
const styleEl = document.createElement('style');
styleEl.textContent = '* { cursor: none !important; }';
document.head.appendChild(styleEl);
const onMove = (e) => {
const t = e.target;
const interactive = t && typeof t.closest === 'function' && t.closest('a, button, [data-clickable]');
el.style.transform = `translate(${e.clientX}px, ${e.clientY}px) translate(-50%, -50%) scale(${interactive ? 0.5 : 1})`;
el.style.opacity = interactive ? '1' : '0.65';
};
window.addEventListener('mousemove', onMove);
return () => {
window.removeEventListener('mousemove', onMove);
styleEl.remove();
};
}, [enabled]);
if (!enabled) return null;
return (
);
}
// ---------------------------------------------- live "current location" signifier
// style: 'pulse' | 'clock' | 'both'
function LocationSignifier({ style = 'both', city = 'Berlin', tz = 'Europe/Berlin' }) {
const [now, setNow] = usePState('');
usePEffect(() => {
if (style === 'pulse') return;
const fmt = () => {
try {
return new Intl.DateTimeFormat('en-GB', {
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: false, timeZone: tz
}).format(new Date());
} catch (_) {return '';}
};
setNow(fmt());
const id = setInterval(() => setNow(fmt()), 1000);
return () => clearInterval(id);
}, [style, tz]);
const dot =
;
return (
{(style === 'pulse' || style === 'both') && dot}
Current Location: {city}
{(style === 'clock' || style === 'both') && now &&
{now}
}
);
}
// ---------------------------------------------------------------- bio bubbles
const BUBBLE = {
background: 'rgba(0,0,0,var(--bubble-op,1))', borderRadius: 15, color: '#fff',
fontSize: 12, lineHeight: 1.5, fontFamily: FONT
};
// Top-of-bubble nav: About / state-glyph / Index. The middle glyph is a "+"
// normally and a "○" while a project is open. Self-contained — navigates via hash
// so it can be dropped into any bubble without prop threading.
function BubbleNav() {
const cur = (location.hash.slice(1) || (window.innerWidth < 768 ? 'index' : 'about'));
const isProject = cur && cur !== 'index' && cur !== 'about';
const mobile = window.innerWidth < 768;
const graphic = window.__navGraphic;
const go = (v) => { location.hash = (v === 'about' ? '' : v); };
const btn = (active) => ({
fontFamily: FONT, color: '#fff', fontSize: 12, letterSpacing: '0.02em',
background: 'none', border: 'none', padding: 0, cursor: 'pointer',
fontWeight: 700, opacity: active ? 1 : 0.5,
});
return (
{!mobile
?
go('about')}>About
:
}
{graphic
?
: + }
go('index')}>Index
);
}
const BioAbout = React.forwardRef(function BioAbout({ locationStyle, mobile = false }, ref) {
const italic = { opacity: 0.92 };
const [wip1, setWip1] = usePState(false);
const [wip2, setWip2] = usePState(false);
const [mx, setMx] = usePState(0);
const [my, setMy] = usePState(0);
usePEffect(() => {
const onMove = (e) => { setMx(e.clientX); setMy(e.clientY); };
window.addEventListener('mousemove', onMove);
return () => window.removeEventListener('mousemove', onMove);
}, []);
return (
Jaxon Stickler
Designer/Artist
Blessed & Cursed artefacts for a collapsing world.
Combining advanced digital fabrication with raw & reclaimed materials, working at the scale of objects, bodies & sites, my work engages the darkness & otherness of ecological & technological systems to visualize new futures.
Open to collaborative projects, commissions and design/art services.
Working on:
setWip1(true)}
onMouseLeave={() => setWip1(false)}>
embodied machine workflow for drawing forth spirit from the technosphere
setWip2(true)}
onMouseLeave={() => setWip2(false)}>
custom software for Creative Living Infrastructure
Contact:
Download CV
{(wip1 || wip2) && ReactDOM.createPortal(
,
document.body
)}
);
});
function BioShort({ locationStyle, top = 19, mobile = false, contact = true }) {
return (
Jaxon Stickler
Artist/Designer
{contact &&
Contact:
}
);
}
// ---------------------------------------------------------------- footer / nav
// navPosition: 'bottom-left' | 'top-right' | 'top-center'
function Footer({ left, navPosition = 'bottom-left' }) {
const base = { fontFamily: FONT, color: '#fff', fontSize: 12, cursor: 'pointer', background: 'none', border: 'none', padding: 0, letterSpacing: '0.02em' };
const posStyle = navPosition === 'top-right' ?
{ position: 'fixed', right: 32, top: 22, zIndex: 40, display: 'flex', alignItems: 'baseline', gap: 32 } :
navPosition === 'top-center' ?
{ position: 'fixed', left: '50%', top: 22, transform: 'translateX(-50%)', zIndex: 40, display: 'flex', alignItems: 'baseline', gap: 32 } :
{ position: 'fixed', left: 43, bottom: 26, zIndex: 40, display: 'flex', alignItems: 'baseline', gap: 32 };
const copyrightStyle = navPosition !== 'bottom-left' ?
{ position: 'fixed', left: 43, bottom: 26, zIndex: 40 } :
null;
return (
{left.map((l, i) =>
{l.label}
)}
{navPosition === 'bottom-left' &&
Jaxon Stickler 2026©
}
{navPosition !== 'bottom-left' &&
Jaxon Stickler 2026©
}
);
}
// ---------------------------------------------------------------- vector entities
// Animated "living edges" entity — persistent overlay across every view.
function AnimatedEntity() {
const isMob = useMobile();
if (isMob) return null;
return (
);
}
// Large entity — About page only. Now the ANIMATED overlay (living tendrils),
// flipped 180° and sitting in FRONT of the info bubble (z 28 > bubble z 25).
// Rendered in an iframe wrapped by a draggable div (an iframe would otherwise
// swallow the drag); the iframe is sized so its 90vh SVG lands at ~503×712.
// Draggable when positions are unlocked; position persists in localStorage.
const LARGE_ENTITY_KEY = 'large-entity-pos-v1';
const LARGE_ENTITY_DEFAULT = { left: -40, bottom: 40 };
function LargeEntity({ locked = true }) {
const isMob = useMobile();
// null → use the bottom-anchored default; {left,top} → user has dragged it
const [pos, setPos] = usePState(() => {
try {
const s = JSON.parse(localStorage.getItem(LARGE_ENTITY_KEY));
if (s && typeof s.left === 'number' && typeof s.top === 'number') return s;
} catch (_) {}
return null;
});
const dragRef = usePRef(null);
const elRef = usePRef(null);
usePEffect(() => {
if (locked) return;
const onMove = (e) => {
const d = dragRef.current;
if (!d) return;
setPos({ left: e.clientX - d.offX, top: e.clientY - d.offY });
};
const onUp = () => {
if (dragRef.current) {
dragRef.current = null;
setPos((p) => {try {localStorage.setItem(LARGE_ENTITY_KEY, JSON.stringify(p));} catch (_) {}return p;});
}
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
return () => {window.removeEventListener('mousemove', onMove);window.removeEventListener('mouseup', onUp);};
}, [locked]);
const onDown = (e) => {
if (locked) return;
e.preventDefault();
const r = elRef.current.getBoundingClientRect();
dragRef.current = { offX: e.clientX - r.left, offY: e.clientY - r.top };
};
if (isMob) return null;
return (
);
}
LargeEntity.reset = function () {try {localStorage.removeItem(LARGE_ENTITY_KEY);} catch (_) {}};
// Memoize the persistent, prop-stable pieces so hovering a diagram node (which
// updates state up in App) doesn't reconcile the WebGL canvas or the animation
// iframe every time — keeps interaction snappy on low-end machines.
const DepthCloudMemo = React.memo(DepthCloud);
const AnimatedEntityMemo = React.memo(AnimatedEntity);
const LargeEntityMemo = React.memo(LargeEntity);
LargeEntityMemo.reset = LargeEntity.reset;
const BioAboutMemo = React.memo(BioAbout);
Object.assign(window, {
FONT, useMobile, GalleryItem, CustomCursor, LocationSignifier, BioShort, Footer,
DepthCloud: DepthCloudMemo,
AnimatedEntity: AnimatedEntityMemo,
LargeEntity: LargeEntityMemo,
BioAbout: BioAboutMemo
});