const { useState, useEffect, useMemo, useRef } = React; const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ "accent": "#FFD53E", "borderColor": "#EFEFEF", "showAvailability": true, "cardRadius": 6 } /*EDITMODE-END*/; // ---------- Site config (loaded from site.config.json) ---------- const SITE_CONFIG_DEFAULTS = { contact: { email: "batuich@gmail.com", telegram: "https://t.me/batuich" }, social: { linkedin: "https://www.linkedin.com/in/oleg-tcybikov-1742a273/", x: "https://x.com/batuich", instagram: "https://www.instagram.com/madebybatuich/", dribbble: "https://dribbble.com/batuich", behance: "https://www.behance.net/batuich" }, features: { showAvailability: true } }; const SiteConfigContext = React.createContext(SITE_CONFIG_DEFAULTS); const useSiteConfig = () => React.useContext(SiteConfigContext); // ---------- Tooltip ---------- function Tooltip({ children, label, side = "bottom" }) { const [open, setOpen] = useState(false); const timer = useRef(null); const show = () => { clearTimeout(timer.current); timer.current = setTimeout(() => setOpen(true), 120); }; const hide = () => { clearTimeout(timer.current); setOpen(false); }; return ( {children} {label} ); } // ---------- Buttons ---------- function PrimaryButton({ href, onClick, children, type = "a" }) { const cls = "inline-flex items-center justify-center h-10 px-4 bg-[#111] text-white text-[13px] font-medium rounded-[4px] transition-colors duration-150 hover:bg-black active:bg-[#000]"; if (type === "button") return ( ); return ( {children} ); } function SecondaryButton({ href, onClick, children, target, rel, type = "a" }) { const cls = "inline-flex items-center justify-center h-10 px-4 bg-white text-[#111] text-[13px] font-medium border border-[#EFEFEF] rounded-[4px] transition-colors duration-150 hover:border-[#111]"; if (type === "button") return ( ); return ( {children} ); } // ---------- Contact actions ---------- function ContactActions({ accent, compact = false }) { const config = useSiteConfig(); const EMAIL = config.contact.email; const TG_URL = config.contact.telegram; const [copied, setCopied] = useState(false); const [hovered, setHovered] = useState(false); const copy = async () => { try { await navigator.clipboard.writeText(EMAIL); } catch (e) { // fallback const ta = document.createElement("textarea"); ta.value = EMAIL; document.body.appendChild(ta); ta.select(); try {document.execCommand("copy");} catch {} ta.remove(); } setCopied(true); setTimeout(() => setCopied(false), 1800); }; return (
setHovered(true)} onMouseLeave={() => setHovered(false)}> {/* Hover/click popup — same placement as Telegram tooltip */} {copied ? "Email copied to clipboard" : "Copy email"} e.currentTarget.style.borderColor = "#111"} onMouseLeave={(e) => e.currentTarget.style.borderColor = "rgb(255, 213, 62)"}> Telegram
); } // ---------- Bouncing badge (DVD-screensaver style) ---------- function BouncingBadge({ containerRef }) { const config = useSiteConfig(); const EMAIL = config.contact.email; const elRef = useRef(null); // Use refs (not state) for mutable physics so rAF doesn't trigger re-renders. const posRef = useRef({ x: 24, y: 24 }); const velRef = useRef({ vx: 0, vy: 0 }); const rotRef = useRef({ angle: 0, omega: 0 }); // degrees, deg/sec useEffect(() => { const container = containerRef.current; const el = elRef.current; if (!container || !el) return; const SPEED = 100; // px / second — constant const ROT_SPEED = 90; // deg / second — constant magnitude // initial direction: 45° upward (right + up) const angle = -Math.PI / 4; velRef.current = { vx: Math.cos(angle) * SPEED, vy: Math.sin(angle) * SPEED }; // initial rotation: clockwise since dx > 0 (matches "next top/bottom hit" rule preemptively) rotRef.current = { angle: 0, omega: ROT_SPEED }; // Start position: somewhere inside the container const cw0 = container.clientWidth; const ch0 = container.clientHeight; const bw0 = el.offsetWidth; const bh0 = el.offsetHeight; posRef.current = { x: Math.max(0, Math.min(cw0 - bw0, 700)), y: Math.max(0, Math.min(ch0 - bh0, 200)) }; el.style.transform = `translate3d(${posRef.current.x}px, ${posRef.current.y}px, 0) rotate(0deg)`; let rafId; let last = performance.now(); const tick = (now) => { const dt = Math.min(0.05, (now - last) / 1000); // clamp to avoid jumps on tab focus last = now; const cw = container.clientWidth; const ch = container.clientHeight; const bw = el.offsetWidth; const bh = el.offsetHeight; let { x, y } = posRef.current; let { vx, vy } = velRef.current; let { angle: rotAngle, omega } = rotRef.current; x += vx * dt; y += vy * dt; // Horizontal edges — keep current rotation direction (do not change it) if (x <= 0) {x = 0;vx = Math.abs(vx);} else if (x + bw >= cw) {x = cw - bw;vx = -Math.abs(vx);} // Vertical edges — set rotation direction from current dx if (y <= 0) { y = 0; vy = Math.abs(vy); omega = vx >= 0 ? ROT_SPEED : -ROT_SPEED; // CW if moving right, CCW if left } else if (y + bh >= ch) { y = ch - bh; vy = -Math.abs(vy); omega = vx >= 0 ? ROT_SPEED : -ROT_SPEED; } // Smooth continuous rotation rotAngle += omega * dt; posRef.current = { x, y }; velRef.current = { vx, vy }; rotRef.current = { angle: rotAngle, omega }; el.style.transform = `translate3d(${x}px, ${y}px, 0) rotate(${rotAngle}deg)`; rafId = requestAnimationFrame(tick); }; rafId = requestAnimationFrame(tick); // On container resize, re-clamp position so badge stays inside const ro = new ResizeObserver(() => { const cw = container.clientWidth; const ch = container.clientHeight; const bw = el.offsetWidth; const bh = el.offsetHeight; posRef.current.x = Math.max(0, Math.min(cw - bw, posRef.current.x)); posRef.current.y = Math.max(0, Math.min(ch - bh, posRef.current.y)); }); ro.observe(container); return () => { cancelAnimationFrame(rafId); ro.disconnect(); }; }, [containerRef]); return ( Available for new projects ); } // ---------- Hero ---------- function Hero({ accent, showAvailability }) { const heroRef = useRef(null); return (
{showAvailability && }
Oleg Tcybikov
OLEG TCYBIKOV, A MULTIDISCIPLINARY DESIGNER
{/* Inversion/blend treatment — source colors are light so that mix-blend-mode: difference renders them DARK over the white page (normal, readable) and LIGHT over the dark sphere image cards. - Headline source = pure white -> black on white, white over dark. - Subheadline source = warm desaturated gray -> reads as neutral gray on white, shifts toward warm/yellowish light over dark. Applied to the

wrapper ONLY; siblings (avatar, eyebrow, buttons, badge, nav) are untouched. No isolation/opacity/filter sits between this and the sphere, so the blend reaches it. */}

Identity, decks, websites, UI/UX, and more —
full design support for startups & growing teams.

); } // ---------- Filters ---------- function Filters({ tags, active, counts, totalCount, onToggle, onClear }) { const allActive = !active; return (
{tags.map((tag) => { const isActive = active === tag; return ( ); })}
); } // ---------- Works section ---------- function Works() { const [active, setActive] = useState(null); const [works, setWorks] = useState([]); const [loadError, setLoadError] = useState(null); useEffect(() => { let cancelled = false; window.loadWorks(). then((data) => {if (!cancelled) setWorks(data);}). catch((err) => {if (!cancelled) setLoadError(err.message || String(err));}); return () => {cancelled = true;}; }, []); // Build the tag list dynamically from cases.json — every unique tag that // appears on at least one case becomes a filter, with its true count. const counts = useMemo(() => { const c = {}; works.forEach((w) => (w.tags || []).forEach((t) => { c[t] = (c[t] || 0) + 1; })); return c; }, [works]); const filtered = useMemo(() => { const list = active ? works.filter((w) => w.tags.includes(active)) : works; return [...list].sort((a, b) => (b.order || 0) - (a.order || 0)); }, [active, works]); // Sort by count desc, then alpha for stable order on ties. const sortedTags = useMemo(() => { return Object.keys(counts).sort((a, b) => { const diff = (counts[b] || 0) - (counts[a] || 0); return diff !== 0 ? diff : a.localeCompare(b); }); }, [counts]); // If the active tag disappears from the data (e.g. cases.json edited), // gracefully fall back to "All". useEffect(() => { if (active && !counts[active]) setActive(null); }, [active, counts]); const toggle = (tag) => setActive((cur) => cur === tag ? null : tag); return (

Selected tasks

setActive(null)} />
{loadError &&
Failed to load cases.json — {loadError}
}
{filtered.map((w) =>
)}
{filtered.length === 0 &&
No work matches that filter yet.
}
); } // ---------- Footer ---------- function Footer({ accent }) { const config = useSiteConfig(); const year = new Date().getFullYear(); return ( ); } // ---------- Header ---------- function Header() { return (
); } // ---------- App ---------- function App() { const [tweaks, setTweak] = useTweaks(TWEAK_DEFAULTS); const [siteConfig, setSiteConfig] = useState(SITE_CONFIG_DEFAULTS); // Load site.config.json (email, social links, feature flags) useEffect(() => { let cancelled = false; fetch("site.config.json", { cache: "no-cache" }). then((r) => r.ok ? r.json() : null). then((cfg) => { if (cancelled || !cfg) return; setSiteConfig({ contact: { ...SITE_CONFIG_DEFAULTS.contact, ...(cfg.contact || {}) }, social: { ...SITE_CONFIG_DEFAULTS.social, ...(cfg.social || {}) }, features: { ...SITE_CONFIG_DEFAULTS.features, ...(cfg.features || {}) } }); }). catch(() => {/* keep defaults */}); return () => {cancelled = true;}; }, []); // Live-apply tweakable tokens via CSS variables useEffect(() => { const r = document.documentElement; r.style.setProperty("--accent", tweaks.accent); r.style.setProperty("--border", tweaks.borderColor); r.style.setProperty("--radius", `${tweaks.cardRadius}px`); }, [tweaks]); const showAvailability = tweaks.showAvailability && siteConfig.features.showAvailability !== false; return (
setTweak("accent", v)} /> setTweak("accent", v)} options={[ { label: "Yellow", value: "#FFD53E" }, { label: "Lime", value: "#D4FF3E" }, { label: "Coral", value: "#FF6F3E" }, { label: "Sky", value: "#3EB8FF" }] } /> setTweak("cardRadius", v)} /> setTweak("borderColor", v)} /> setTweak("showAvailability", v)} />
); } ReactDOM.createRoot(document.getElementById("root")).render();