/* .v1 — платформа разметки мультимодальных данных для VLA / гуманоидов — dark theme */
const { useState, useEffect, useRef } = React;
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"accent": "#EA0A23",
"cursor": true,
"loader": true
} /*EDITMODE-END*/;
/* ============== HOOKS ============== */
function useReveal(threshold = 0.15) {
const ref = useRef(null);
useEffect(() => {
const el = ref.current;if (!el) return;
const io = new IntersectionObserver((entries) => {
entries.forEach((e) => {if (e.isIntersecting) {el.classList.add("in");io.disconnect();}});
}, { threshold });
io.observe(el);
return () => io.disconnect();
}, [threshold]);
return ref;
}
function useScrollY() {
const [y, setY] = useState(0);
useEffect(() => {
const on = () => setY(window.scrollY);
on();window.addEventListener("scroll", on, { passive: true });
return () => window.removeEventListener("scroll", on);
}, []);
return y;
}
function useClock() {
const [t, setT] = useState("");
useEffect(() => {
const fmt = () => {
try {setT(new Intl.DateTimeFormat("ru-RU", { hour: "2-digit", minute: "2-digit", timeZone: "Europe/Moscow", hour12: false }).format(new Date()));}
catch {setT("");}
};
fmt();const id = setInterval(fmt, 30000);
return () => clearInterval(id);
}, []);
return t;
}
/* ============== PRIMITIVES ============== */
function getText(node) {
if (node == null || typeof node === "boolean") return "";
if (typeof node === "string" || typeof node === "number") return String(node);
if (Array.isArray(node)) return node.map(getText).join("");
if (React.isValidElement(node)) return getText(node.props.children);
return "";
}
function Words({ children, delay = 0 }) {
const ref = useRef(null);
useEffect(() => {
const el = ref.current;if (!el) return;
const words = el.querySelectorAll(".word");
const fire = () => words.forEach((w, i) => setTimeout(() => w.classList.add("in"), delay + i * 70));
const r = el.getBoundingClientRect();
if (r.top < innerHeight && r.bottom > 0) {fire();} else
{
const io = new IntersectionObserver((entries) => {
entries.forEach((e) => {if (e.isIntersecting) {fire();io.disconnect();}});
}, { threshold: 0 });
io.observe(el);
// safety: force-show after 2.5s no matter what
const safety = setTimeout(() => {words.forEach((w) => w.classList.add("in"));io.disconnect();}, 2500);
return () => {io.disconnect();clearTimeout(safety);};
}
// safety for in-viewport too
const safety = setTimeout(() => words.forEach((w) => w.classList.add("in")), 2500);
return () => clearTimeout(safety);
}, [delay]);
const parts = getText(children).split(" ");
return (
{parts.map((w, i) =>
{w}
{i < parts.length - 1 ? " " : ""}
)}
);
}
function SectionNum({ label, num, total = "07" }) {
return (
{label}
{num}
| {total}
);
}
function Bracket({ children, accent = false }) {
return (
[
{children}
]
);
}
/* ============== TYPEWRITER LINES — types each bracket word in sequence, then loops ============== */
function TypeLines({ words }) {
const [shown, setShown] = useState(() => words.map(() => ""));
const [revealedMax, setRevealedMax] = useState(0);
const [caretLine, setCaretLine] = useState(0);
useEffect(() => {
let timer,mounted = true;
let line = 0,pos = 0;
const typeChar = () => {
if (!mounted) return;
const full = words[line];
pos++;
setShown((s) => {const n = [...s];n[line] = full.slice(0, pos);return n;});
if (pos >= full.length) {
if (line < words.length - 1) {
// brief pause, then drop to next line and keep typing
timer = setTimeout(() => {
if (!mounted) return;
line++;pos = 0;
setRevealedMax(line);setCaretLine(line);
typeChar();
}, 440);
} else {
setCaretLine(-1); // all three typed — hold, hide caret
timer = setTimeout(restart, 2400);
}
} else {
timer = setTimeout(typeChar, 78 + Math.random() * 72);
}
};
const restart = () => {
if (!mounted) return;
line = 0;pos = 0;
setShown(words.map(() => ""));
setRevealedMax(0);setCaretLine(0);
timer = setTimeout(typeChar, 460);
};
timer = setTimeout(typeChar, 700);
return () => {mounted = false;clearTimeout(timer);};
}, []);
return words.map((w, i) =>
[
{shown[i]}
{caretLine === i ? : null}
]
{i < words.length - 1 ? : null}
);
}
/* ============== DECORATIVE PRIMITIVES (generic graphic techniques) ============== */
function HalftoneRing({ size = 260, color = "rgba(255,255,255,0.95)" }) {
const cx = size / 2,cy = size / 2;
const rings = [
{ r: size * 0.20, n: 12, d: 1.6 },
{ r: size * 0.27, n: 22, d: 1.9 },
{ r: size * 0.34, n: 32, d: 2.2 },
{ r: size * 0.40, n: 42, d: 2.4 },
{ r: size * 0.46, n: 28, d: 1.8 } // sparser outer
];
const dots = [];
rings.forEach((ring, ri) => {
for (let i = 0; i < ring.n; i++) {
const a = i / ring.n * Math.PI * 2 + ri * 0.12;
dots.push( );
}
});
return {dots} ;
}
function DotCluster({ size = 90, color = "var(--accent)" }) {
return (
);
}
/* ============== HERO BACKDROP — dotted concentric orbits (programmatic SVG) ============== */
function HeroBackdrop({ size = 720 }) {
const cx = size / 2,cy = size / 2;
const rings = [];
const ringCount = 28;
for (let i = 0; i < ringCount; i++) {
const t = i / (ringCount - 1);
const r = 90 + t * (size * 0.48 - 90);
const density = 1 - Math.pow(t, 1.4) * 0.85;
const n = Math.max(8, Math.round(r * 0.38 * density));
const dotR = 1.6 - t * 1.0;
const opacity = 0.18 + (1 - t) * 0.42;
rings.push({ r, n, dotR, opacity });
}
const dots = [];
rings.forEach((ring, ri) => {
const phase = ri * 0.13;
for (let i = 0; i < ring.n; i++) {
const a = i / ring.n * Math.PI * 2 + phase;
dots.push(
);
}
});
return (
{dots}
);
}
/* ============== HERO SPHERE — canvas particle sphere ============== */
function HeroSphere({ size = 380 }) {
const ref = useRef(null);
useEffect(() => {
const canvas = ref.current;if (!canvas) return;
const dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.width = size * dpr;canvas.height = size * dpr;
canvas.style.width = size + "px";canvas.style.height = size + "px";
const ctx = canvas.getContext("2d");
ctx.scale(dpr, dpr);
// Fibonacci-sphere uniform distribution
const N = 720;
const pts = [];
const golden = Math.PI * (3 - Math.sqrt(5));
for (let i = 0; i < N; i++) {
const y = 1 - i / (N - 1) * 2;
const r = Math.sqrt(1 - y * y);
const theta = golden * i;
pts.push({
x: Math.cos(theta) * r,
y,
z: Math.sin(theta) * r,
accent: Math.random() < 0.045,
sz: 0.7 + Math.random() * 0.6
});
}
let mx = 0,my = 0,tx = 0,ty = 0;
const onMove = (e) => {
const rect = canvas.getBoundingClientRect();
const ccx = rect.left + rect.width / 2;
const ccy = rect.top + rect.height / 2;
tx = (e.clientX - ccx) / window.innerWidth * 0.55;
ty = (e.clientY - ccy) / window.innerHeight * 0.4;
};
window.addEventListener("pointermove", onMove);
let raf,t = 0,stopped = false;
const cx = size / 2,cy = size / 2;
const R = size * 0.42;
const accentRgb = (() => {
const v = getComputedStyle(document.documentElement).getPropertyValue("--accent").trim() || "#EA0A23";
const hex = v.replace("#", "");
return [parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(4, 6), 16)];
})();
// ===== annotation bounding box (data-labeling motif) =====
const classes = ["obj", "kp", "grasp", "edge", "node", "pose"];
let targetIdx = 0,holdUntil = 0;
let boxCX = cx,boxCY = cy,boxHS = 64,lockT = 0;
let conf = 0.95,label = "obj 0.95";
// ===== letter flock — "Базис / Консенсус / Контур" =====
const FWORDS = ["Базис", "Консенсус", "Контур"];
const wordFontPx = Math.max(20, Math.round(size * 0.048));
const flockFontPx = Math.max(12, Math.round(size * 0.030));
ctx.font = `700 ${wordFontPx}px Onest, system-ui, sans-serif`;
const layouts = FWORDS.map((w) => {
const chars = Array.from(w);
const widths = chars.map((c) => ctx.measureText(c).width);
const trk = wordFontPx * 0.04;
const total = widths.reduce((a, b) => a + b, 0) + trk * (chars.length - 1);
let xx = cx - total / 2;
const slots = chars.map((c, i) => {
const s = { x: xx + widths[i] / 2, y: cy };
xx += widths[i] + trk;
return s;
});
return { chars, slots };
});
ctx.textAlign = "start";ctx.textBaseline = "alphabetic";
const flockBaseX = size * 0.115,flockBaseY = size * 0.090;
const flockSpread = size * 0.045;
const flock = [];
FWORDS.forEach((w, wi) => {
Array.from(w).forEach((ch, si) => {
flock.push({
ch, wi, si,
oang: Math.random() * Math.PI * 2,
orad: (0.30 + Math.sqrt(Math.random()) * 0.70) * flockSpread,
ospd: 1.30 + Math.random() * 1.00,
wph: Math.random() * Math.PI * 2,
wsp: 0.5 + Math.random() * 0.7,
wamp: size * 0.006 + Math.random() * size * 0.012,
ddx: Math.random() * 2 - 1,
x: flockBaseX, y: flockBaseY, s: flockFontPx, a: 0,
sparkCycle: -1
});
});
});
const F_IN = 1300,F_HOLD = 1500,F_OUT = 650,F_GAP = 350;
const F_CYCLE = F_IN + F_HOLD + F_OUT + F_GAP;
const F_STAG = 80,F_TRAVEL = 520;
const flockStart = performance.now();
let sparks = [];
const shOff = document.createElement("canvas");
const shCtx = shOff.getContext("2d");
const shatterLetter = (p) => {
const fs = Math.max(8, p.s);
const pad = 4;
shCtx.font = `700 ${fs}px Onest, system-ui, sans-serif`;
const w = Math.ceil(shCtx.measureText(p.ch).width) + pad * 2;
const h = Math.ceil(fs * 1.45) + pad * 2;
shOff.width = w;shOff.height = h;
shCtx.font = `700 ${fs}px Onest, system-ui, sans-serif`;
shCtx.textAlign = "center";shCtx.textBaseline = "middle";
shCtx.clearRect(0, 0, w, h);
shCtx.fillStyle = "#0A0A0A";
shCtx.fillText(p.ch, w / 2, h / 2);
const data = shCtx.getImageData(0, 0, w, h).data;
const step = 2;
const ox = p.x - w / 2,oy = p.y - h / 2;
for (let yy = 0; yy < h; yy += step) {
for (let xx = 0; xx < w; xx += step) {
if (data[(yy * w + xx) * 4 + 3] > 60) {
const dx = (xx - w / 2) / w,dy = (yy - h / 2) / h;
sparks.push({
x: ox + xx + Math.random() * step,
y: oy + yy + Math.random() * step,
vx: dx * size * 0.012 + (Math.random() - 0.5) * size * 0.006,
vy: dy * size * 0.006 - size * 0.004 - Math.random() * size * 0.005,
life: 24 + Math.random() * 34, max: 58,
sz: 0.8 + Math.random() * 1.0
});
}
}
}
};
const clampF = (v, a, b) => v < a ? a : v > b ? b : v;
const eoCubic = (p) => 1 - Math.pow(1 - p, 3);
const render = () => {
if (stopped) return;
t += 0.0035;
mx += (tx - mx) * 0.06;
my += (ty - my) * 0.06;
ctx.clearRect(0, 0, size, size);
const yaw = t + mx * 0.9;
const pitch = 0.14 + my * 0.7;
const cy_ = Math.cos(yaw),sy_ = Math.sin(yaw);
const cp_ = Math.cos(pitch),sp_ = Math.sin(pitch);
const breath = 1 + Math.sin(t * 1.2) * 0.018;
const screen = pts.map((p) => {
const x1 = p.x * cy_ + p.z * sy_;
const z1 = -p.x * sy_ + p.z * cy_;
const y1 = p.y * cp_ - z1 * sp_;
const z2 = p.y * sp_ + z1 * cp_;
return { x: x1, y: y1, z: z2, accent: p.accent, sz: p.sz };
});
screen.sort((a, b) => a.z - b.z);
// back-glow behind sphere
const grd = ctx.createRadialGradient(cx, cy, 0, cx, cy, R * 0.95);
grd.addColorStop(0, `rgba(${accentRgb[0]},${accentRgb[1]},${accentRgb[2]},0.18)`);
grd.addColorStop(1, `rgba(${accentRgb[0]},${accentRgb[1]},${accentRgb[2]},0)`);
ctx.fillStyle = grd;
ctx.beginPath();ctx.arc(cx, cy, R * 0.95, 0, Math.PI * 2);ctx.fill();
for (const p of screen) {
const depth = (p.z + 1) / 2; // 0 back, 1 front
const px = cx + p.x * R * breath;
const py = cy + p.y * R * breath;
const r = (p.sz + depth * 1.4) * (p.accent ? 1.6 : 1);
const op = p.accent ? 0.6 + depth * 0.4 : 0.10 + depth * 0.78;
ctx.fillStyle = p.accent ?
`rgba(${accentRgb[0]},${accentRgb[1]},${accentRgb[2]},${op})` :
`rgba(10,10,10,${op})`;
ctx.beginPath();
ctx.arc(px, py, r, 0, Math.PI * 2);
ctx.fill();
}
// ===== annotation bounding box — acquire / track / re-target =====
const project = (p) => {
const x1 = p.x * cy_ + p.z * sy_;
const z1 = -p.x * sy_ + p.z * cy_;
const y1 = p.y * cp_ - z1 * sp_;
const z2 = p.y * sp_ + z1 * cp_;
return { x: x1, y: y1, z: z2 };
};
const nowMs = performance.now();
if (nowMs > holdUntil) {
// gather front-facing points sitting away from the silhouette edge
const cands = [];
for (let i = 0; i < pts.length; i++) {
const pr = project(pts[i]);
const rad = Math.hypot(pr.x, pr.y);
if (pr.z > 0.5 && rad < 0.78 && (pts[i].accent || Math.random() < 0.05)) cands.push(i);
}
if (cands.length) {
let pick = cands[Math.random() * cands.length | 0];
if (pick === targetIdx && cands.length > 1) pick = cands[Math.random() * cands.length | 0];
targetIdx = pick;
}
holdUntil = nowMs + 1300 + Math.random() * 800;
boxHS = 72; // snap open, then contract = "acquire target"
lockT = 0;
conf = 0.9 + Math.random() * 0.098;
label = classes[Math.random() * classes.length | 0] + " " + conf.toFixed(2);
}
// track the locked point as the sphere keeps rotating
const tp = project(pts[targetIdx]);
const tdepth = (tp.z + 1) / 2;
const tpx = cx + tp.x * R * breath;
const tpy = cy + tp.y * R * breath;
const tightHS = 19 + tdepth * 12;
boxCX += (tpx - boxCX) * 0.2;
boxCY += (tpy - boxCY) * 0.2;
boxHS += (tightHS - boxHS) * 0.16;
lockT += (1 - lockT) * 0.12;
// fade out as the tracked point rotates toward the back
const boxVis = Math.max(0, Math.min(1, (tp.z - 0.12) / 0.32));
if (boxVis > 0.01) {
const bx = boxCX,by = boxCY,hs = boxHS;
const aC = `${accentRgb[0]},${accentRgb[1]},${accentRgb[2]}`;
ctx.save();
ctx.lineJoin = "round";
// faint full frame
ctx.strokeStyle = `rgba(${aC},${(0.20 + 0.38 * lockT) * boxVis})`;
ctx.lineWidth = 1;
ctx.strokeRect(bx - hs, by - hs, hs * 2, hs * 2);
// L-shaped corner brackets
const cl = 7 + 5 * lockT;
ctx.strokeStyle = `rgba(${aC},${0.95 * boxVis})`;
ctx.lineWidth = 2;
ctx.lineCap = "round";
const corner = (sx, sy, dx, dy) => {
ctx.beginPath();
ctx.moveTo(sx + dx * cl, sy);
ctx.lineTo(sx, sy);
ctx.lineTo(sx, sy + dy * cl);
ctx.stroke();
};
corner(bx - hs, by - hs, 1, 1);
corner(bx + hs, by - hs, -1, 1);
corner(bx - hs, by + hs, 1, -1);
corner(bx + hs, by + hs, -1, -1);
// center crosshair
ctx.strokeStyle = `rgba(${aC},${0.7 * boxVis})`;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(bx - 4, by);ctx.lineTo(bx + 4, by);
ctx.moveTo(bx, by - 4);ctx.lineTo(bx, by + 4);
ctx.stroke();
// label tag
ctx.font = "600 9px 'JetBrains Mono', ui-monospace, monospace";
const tw = ctx.measureText(label).width;
const tagH = 13,tagW = tw + 10;
let tagX = bx - hs,tagY = by - hs - tagH - 2;
if (tagY < 2) tagY = by + hs + 2;
ctx.globalAlpha = boxVis * (0.45 + 0.55 * lockT);
ctx.fillStyle = `rgb(${aC})`;
ctx.fillRect(tagX, tagY, tagW, tagH);
ctx.fillStyle = "#fff";
ctx.textBaseline = "middle";
ctx.fillText(label, tagX + 5, tagY + tagH / 2 + 0.5);
ctx.restore();
}
// ===== letter flock: drift → staggered fly-in → hold → dissolve → loop =====
const fnow = performance.now() - flockStart;
const cycleIdx = Math.floor(fnow / F_CYCLE);
const fslot = cycleIdx % 3;
const localT = fnow % F_CYCLE;
const fcx = flockBaseX + Math.sin(t * 0.5) * size * 0.016;
const fcy = flockBaseY + Math.cos(t * 0.42) * size * 0.014;
for (const p of flock) {
const active = p.wi === fslot;
const oa = p.oang + t * p.ospd;
const rr = p.orad + Math.sin(t * p.wsp + p.wph) * p.wamp;
const hx = fcx + Math.cos(oa) * rr + Math.cos(t * p.wsp * 1.3 + p.wph) * p.wamp * 0.4;
const hy = fcy + Math.sin(oa) * rr * 0.58 + Math.sin(t * p.wsp * 1.1 + p.wph) * p.wamp * 0.4;
let gx = hx,gy = hy,ga = 0.42,gs = flockFontPx,posLerp = 0.045,aLerp = 0.16;
if (active) {
const sl = layouts[p.wi].slots[p.si];
const tHoldEnd = F_IN + F_HOLD;
const tOutEnd = tHoldEnd + F_OUT;
if (localT < F_IN) {
// fly out of the flock one by one, fast, toward the word slot
const pr = clampF((localT - p.si * F_STAG) / F_TRAVEL, 0, 1);
const e = eoCubic(pr);
gx = hx + (sl.x - hx) * e;
gy = hy + (sl.y - hy) * e;
gs = flockFontPx + (wordFontPx - flockFontPx) * e;
ga = 0.42 + 0.58 * clampF(pr * 1.5, 0, 1);
posLerp = 0.5;aLerp = 0.3;
} else if (localT < tHoldEnd) {
// word formed, held in the sphere centre
gx = sl.x;gy = sl.y;gs = wordFontPx;ga = 1;posLerp = 0.45;aLerp = 0.4;
} else if (localT < tOutEnd) {
// dissolve (Telegram-style): glyph shatters into tiny pixels that scatter & fade
if (p.sparkCycle !== cycleIdx) {
p.sparkCycle = cycleIdx;
shatterLetter(p);
p.a = 0;
}
gx = sl.x;gy = sl.y;gs = p.s;ga = 0;posLerp = 0;aLerp = 1;
} else {
// gap — slip back toward the flock, invisible
gx = hx;gy = hy;gs = flockFontPx;ga = 0;posLerp = 0.1;aLerp = 0.25;
}
}
p.x += (gx - p.x) * posLerp;
p.y += (gy - p.y) * posLerp;
p.s += (gs - p.s) * posLerp;
p.a += (ga - p.a) * aLerp;
if (p.a > 0.015) {
const f = clampF((p.s - flockFontPx) / (wordFontPx - flockFontPx), 0, 1);
const cr = Math.round(107 + (10 - 107) * f);
const cg = Math.round(107 + (10 - 107) * f);
const cb = Math.round(107 + (10 - 107) * f);
ctx.save();
ctx.globalAlpha = p.a;
ctx.textAlign = "center";ctx.textBaseline = "middle";
ctx.font = `700 ${p.s.toFixed(1)}px Onest, system-ui, sans-serif`;
if (f > 0.25) {ctx.shadowColor = "rgba(255,255,255,0.92)";ctx.shadowBlur = 7 * f;}
ctx.fillStyle = `rgb(${cr},${cg},${cb})`;
ctx.fillText(p.ch, p.x, p.y);
ctx.restore();
}
}
// dissolve shards (Telegram-style break-up into tiny pieces)
if (sparks.length) {
for (let i = sparks.length - 1; i >= 0; i--) {
const s = sparks[i];
s.x += s.vx;s.y += s.vy;
s.vx *= 0.94;s.vy = s.vy * 0.94 + size * 0.0004;
s.life -= 1;
if (s.life <= 0) {sparks.splice(i, 1);continue;}
const sa = Math.pow(s.life / s.max, 0.85);
const d = s.sz * (0.5 + sa * 0.6);
ctx.fillStyle = `rgba(10,10,10,${(sa * 0.92).toFixed(3)})`;
ctx.fillRect(s.x, s.y, d, d);
}
}
raf = requestAnimationFrame(render);
};
raf = requestAnimationFrame(render);
return () => {stopped = true;cancelAnimationFrame(raf);window.removeEventListener("pointermove", onMove);};
}, [size]);
return ;
}
/* ============== HERO ORB — composition wrapper (backdrop + sphere) ============== */
function HeroOrb({ size = 520 }) {
// wrapper matches the actual visual extent so the sphere never overflows the layout
const visual = size * 1.2;
return (
);
}
/* ============== CURSOR ============== */
function Cursor({ enabled }) {
const dot = useRef(null),ring = useRef(null);
const [mode, setMode] = useState("idle");
const [label, setLabel] = useState("");
useEffect(() => {
if (!enabled) {document.body.classList.remove("has-cursor");return;}
document.body.classList.add("has-cursor");
let mx = innerWidth / 2,my = innerHeight / 2,rx = mx,ry = my,dx = mx,dy = my,raf;
const tick = () => {
rx += (mx - rx) * 0.18;ry += (my - ry) * 0.18;
dx += (mx - dx) * 0.45;dy += (my - dy) * 0.45;
if (dot.current) dot.current.style.transform = `translate(${dx}px, ${dy}px) translate(-50%, -50%)`;
if (ring.current) ring.current.style.transform = `translate(${rx}px, ${ry}px) translate(-50%, -50%)`;
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
const onMove = (e) => {mx = e.clientX;my = e.clientY;};
const onOver = (e) => {
const t = e.target.closest?.("[data-cursor]");
if (!t) {setMode("idle");setLabel("");return;}
const m = t.dataset.cursor;const l = t.dataset.cursorLabel || "";
setMode(l ? "text" : m || "big");setLabel(l);
};
const onLeave = () => {setMode("idle");setLabel("");};
addEventListener("pointermove", onMove);
document.addEventListener("pointerover", onOver);
document.addEventListener("pointerout", onLeave);
return () => {
cancelAnimationFrame(raf);
removeEventListener("pointermove", onMove);
document.removeEventListener("pointerover", onOver);
document.removeEventListener("pointerout", onLeave);
document.body.classList.remove("has-cursor");
};
}, [enabled]);
if (!enabled) return null;
return (
<>
{label ? {label} : null}
>);
}
/* ============== LOADER ============== */
function Loader({ active, onDone }) {
const [pct, setPct] = useState(0);
const [lifting, setLifting] = useState(false);
useEffect(() => {
if (!active) return;
let n = 0;
const id = setInterval(() => {
n = Math.min(100, n + Math.random() * 16 + 6);
setPct(Math.floor(n));
if (n >= 100) {
clearInterval(id);
setTimeout(() => setLifting(true), 320);
setTimeout(() => onDone?.(), 1400);
}
}, 95);
return () => clearInterval(id);
}, [active, onDone]);
if (!active) return null;
return (
. v1
{pct.toString().padStart(3, "0")} / 100
);
}
/* ============== NAV + MENU OVERLAY ============== */
function Nav({ onMenu }) {
const y = useScrollY();
return (
24 ? "scrolled" : ""}`}>
. v1
МЕНЮ
);
}
function MenuOverlay({ open, onClose }) {
const items = [
{ label: "Применения", href: "#Применения" },
{ label: "Домены", href: "#Домены" },
{ label: "Кейсы", href: "pages/cases.html" },
{ label: "Услуги", href: "pages/services.html" },
{ label: "Команда", href: "pages/team.html" },
{ label: "Медиа", href: "pages/media.html" },
{ label: "О компании", href: "pages/about.html" },
{ label: "Контакты", href: "#Контакты" }];
return (
. v1
ЗАКРЫТЬ
Телефон
+7 (499) 117-00-87
Партнёрство
partners@v1data.ru
);
}
/* ============== HERO ============== */
function Hero() {
const time = useClock();
return (
{/* sphere — sits just under the nav, fully visible */}
{/* satellite cards — stacked vertically on the right, overlaying the sphere */}
[3]
Восстановление
slip / collision / mis-pick
[1]
≥ 99,2%
AI-предразметка по DROID
[2]
≤ 50 мс
Sync video ↔ states
{/* bottom row: H1 left, announcement pill right — overlaps the sphere bottom */}
);
}
/* ============== SCENARIOS (alt dark/light cards with decor) — section 01 ============== */
function Scenarios() {
const ref = useReveal();
const items = [
{ kind: "dark", vis: "humanoid", name: "Гуманоиды", desc: "VLA-датасеты для антропоморфных роботов с двуручной манипуляцией." },
{ kind: "light", vis: "mobile", name: "Мобильные манипуляторы", desc: "Сбор данных для роботов с подвижной базой и навигацией в пространстве." },
{ kind: "light", vis: "stationary", name: "Стационарные манипуляторы", desc: "Single и dual-arm на фиксированной базе для precision-задач." },
{ kind: "dark", vis: "umi", name: "Девайсы UMI / Gloves", desc: "Сбор демонстраций без робота — портативные манипуляторы и сенсорные перчатки." }];
return (
Строим data-фундамент для VLA под любую форму воплощения робота.
Работаем с командами, которые стоят на пороге запуска , масштабирования или переосмысления своего robotics-стека.
{items.map((it, i) => {
const dark = it.kind === "dark";
return (
);
})}
);
}
/* ============== SCENARIO VISUAL — animated SVG per применение ============== */
function ScenarioVisual({ kind, dark }) {
const stroke = dark ? "rgba(255,255,255,0.85)" : "rgba(10,10,10,0.75)";
const fill = dark ? "rgba(255,255,255,0.08)" : "rgba(10,10,10,0.05)";
const accent = "#EA0A23";
const sw = 1.5;
return (
{kind === "humanoid" &&
<>
{/* head + sensors */}
{/* torso */}
{/* shoulders */}
{/* arms — static, attached at shoulders */}
{/* legs */}
{/* data flow: dashed line from heart up to head */}
{/* pulse rings expanding from heart (training-data flowing through body) */}
{/* heart core */}
>
}
{kind === "mobile" &&
<>
{/* motion trails behind base */}
{/* base body */}
{/* wheels — spinning */}
{/* vertical mast */}
{/* sensor on mast */}
{/* arm — extending */}
{/* target dot */}
>
}
{kind === "stationary" &&
<>
{/* bolted base */}
{/* vertical column */}
{/* shoulder joint */}
{/* upper arm (rotating slowly) */}
{/* forearm */}
{/* wrist + end-effector */}
{/* target workpiece with pulsing crosshair */}
>
}
{kind === "umi" &&
<>
{/* wrist data strap */}
{/* palm */}
{/* index */}
{/* middle */}
{/* ring */}
{/* pinky */}
{/* thumb */}
{/* palm sensor */}
{/* data link upward */}
>
}
);
}
/* ============== DOMAIN VISUAL — animated SVG per kind ============== */
function DomainVisual({ kind, stroke, dark }) {
const accent = "#EA0A23";
const sw = 1.4;
return (
{kind === "kitchen" &&
<>
{/* steam */}
{/* lid + pot */}
{/* handles */}
{/* stove */}
>
}
{kind === "lab" &&
<>
{/* pipette */}
{/* drop */}
{/* test tube 1 */}
{/* test tube 2 */}
{/* table */}
>
}
{kind === "retail" &&
<>
{/* shelf */}
{/* scanner */}
{/* conveyor */}
>
}
{kind === "cleaning" &&
<>
{/* surface */}
{/* streaks */}
{/* arm */}
{/* brush head */}
>
}
{kind === "factory" &&
<>
{/* gantry */}
{/* robot arm */}
{/* spark */}
{/* conveyor */}
>
}
{kind === "office" &&
<>
{/* monitor */}
{/* lines of text */}
{/* caret */}
{/* tray (papers) */}
{/* coffee */}
>
}
{kind === "hard" &&
<>
{/* glass cylinder */}
{/* glass shine */}
{/* cloth wave */}
>
}
);
}
/* ============== INDUSTRIES — large slider with header + arrows — section 02 ============== */
function Industries() {
const ref = useReveal();
const scroller = useRef(null);
const items = [
{ name: "Кухня", desc: "Кухонный домен — эпизоды приготовления, сервировки и уборки. Деформируемые объекты и жидкости.", kind: "kitchen" },
{ name: "Лаборатория", desc: "Лабораторные задачи — пипетирование, работа с пробирками и прозрачными объектами.", kind: "lab" },
{ name: "Ритейл", desc: "Ритейл — сканирование, выкладка, работа с SKU. Кассовые и складские сценарии.", kind: "retail" },
{ name: "Уборка", desc: "Уборка — протирка, сбор мусора, взаимодействие с поверхностями и разными материалами.", kind: "cleaning" },
{ name: "Производство", desc: "Сборочные линии и станки — точные манипуляции, F/T-сенсорика, повторяемые операции.", kind: "factory" },
{ name: "Офис", desc: "Офисные сценарии — работа с документами, перемещения, взаимодействия с оргтехникой.", kind: "office" },
{ name: "Сложные сценарии", desc: "Ткани, прозрачные объекты, узкие допуски и бликующие материалы. Не менее 10% в каждой поставке.", kind: "hard" }];
const scrollBy = (dir) => scroller.current?.scrollBy({ left: dir * 420, behavior: "smooth" });
React.useEffect(() => {
// ensure the slider starts at 0 — prevents scroll-snap from "eating" the left padding on mount
const el = scroller.current;
if (el) el.scrollLeft = 0;
}, []);
return (
{/* header: left meta, right lead text + arrows */}
Покрываем ключевые домены реального мира — от бытовых сценариев до промышленных сборочных линий.
Собираем мультимодальные данные в доменах, где роботы выходят из лабораторий в реальность.
scrollBy(-1)} aria-label="prev" data-cursor="big" style={{ width: 60, height: 60, background: "var(--bg-card)", color: "var(--ink)", border: "1px solid var(--hair)", borderRadius: 16, display: "flex", alignItems: "center", justifyContent: "center" }}>
scrollBy(1)} aria-label="next" data-cursor="big" style={{ width: 60, height: 60, background: "var(--accent)", color: "#fff", border: "1px solid var(--accent)", borderRadius: 16, display: "flex", alignItems: "center", justifyContent: "center" }}>
{/* horizontal slider with large square cards */}
{items.map((it, i) => {
const dark = i === 0;
const accentText = dark ? "#fff" : "var(--ink)";
const stroke = dark ? "rgba(255,255,255,0.55)" : "rgba(10,10,10,0.7)";
return (
0{i + 1} / 0{items.length}
{/* domain-specific animated visual */}
);
})}
);
}
/* ============== CASES — asymmetric Bento, each card own color — section 03 ============== */
function Cases() {
const ref = useReveal();
// each case in its own original color (not lifted from anyone)
const palette = {
mk: { bg: "#EA0A23", text: "#fff" }, // accent red
fn: { bg: "#0A0A0A", text: "#fff" }, // black
yu: { bg: "#1A1A1A", text: "#fff" }, // off-black
hr: { bg: "#F2F1EC", text: "#0A0A0A" }, // light card
sp: { bg: "#EFEDE6", text: "#0A0A0A" }, // light card 2
op: { bg: "#EA0A23", text: "#fff" } // red big
};
return (
Что уже сделали для первых партнёров — от пилотного эпизода до production-датасетов.
);
}
function CaseCard({ color, tag, title, sub, kpi, big, href, chip, decoration, oneLine }) {
const onDark = color.text === "#fff";
const Tag = href ? "a" : "article";
return (
{big && decoration === "hand" ?
:
null}
{big && decoration !== "hand" ?
:
null}
{decoration === "pig" ?
{/* dotted grid background */}
{/* heatmap glow bottom-right */}
{/* skeletal trace SVG */}
{/* pig silhouette side-view */}
{/* snout + ear */}
{/* legs */}
{/* skeletal connecting lines */}
{/* 15 keypoints — last one (right rear hoof) is the anomaly */}
{/* anomaly: right rear hoof — stays red */}
:
null}
{decoration === "hand" ?
:
null}
{decoration === "gripper" ?
{/* wrist + mount */}
{/* detection arc */}
{/* re-grasp pulse */}
{/* left jaw */}
{/* right jaw */}
{/* object */}
{/* slip marker */}
{/* recovered check */}
:
null}
{decoration === "pipeline" ?
{/* connectors */}
{/* blocks */}
{/* labels */}
INGEST
STORE
LABEL
EXPORT
{/* scan bar */}
{/* PASS / WARN indicators */}
2 / 4 PASS · 1 / 4 WARN
:
null}
{decoration === "amr" ?
{/* shelves (outline rectangles) */}
{/* dashed zig-zag route */}
{/* station ticks (green, appear as AMR passes) */}
{/* the AMR dot travelling the route (CSS offset-path for hover control) */}
:
null}
{decoration === "amr" ?
Смотреть кейс ↗ :
null}
{decoration === "race" ?
{/* MANUAL */}
MANUAL
{/* AI PRE-LABEL */}
AI PRE-LABEL
{/* ×8 result */}
× 8
:
null}
{decoration === "race" ?
Смотреть кейс ↗ :
null}
{title}
{sub}
{kpi ?
:
null}
{chip ?
{chip}
:
null}
);
}
/* ============== SERVICES (accordion: pill list left + colored panel right) — section 04 ============== */
function Services() {
const services = [
{ key: "plat", name: "Платформа разметки", desc: "Self-serve среда: загрузка эпизодов, AI pre-label, human review, экспорт в LeRobot / RT-X / OXE. От 100 000 ₽/мес." },
{ key: "collect", name: "Сбор данных под ключ", desc: "Managed teleoperation + ego-recording + UMI + data gloves. Sync ≤ 50 мс, F/T sensors, tactile maps. От 7 000 ₽ за час." },
{ key: "curated", name: "Готовые датасеты", desc: "Готовые пакеты: Kitchen-1000h, Lab-Manipulation, Recovery-Pack, Deformables-Set. От 500 000 ₽ за 100 часов." },
{ key: "recovery", name: "Разметка recovery как услуга", desc: "Единственный SKU разметки ошибок: 5–15% recovery + 5% hard failures + 80–90% success. Add-on +30%." },
{ key: "consult", name: "ИТ-консалтинг", desc: "Аудит pipeline сбора и разметки. Интервью с CTO, анализ stack, документ с рекомендациями. По запросу." }];
const [active, setActive] = useState("plat");
const cur = services.find((s) => s.key === active);
const idx = services.findIndex((s) => s.key === active);
// Auto-cycle through services at a readable pace. Resets whenever `active`
// changes — so a manual click restarts the timer instead of fighting it.
useEffect(() => {
const t = setTimeout(() => {
setActive(services[(idx + 1) % services.length].key);
}, 4600);
return () => clearTimeout(t);
}, [active, idx]);
return (
Работаем как технологический партнёр под VLA-данные — от первого эпизода до production.
{services.map((s) =>
setActive(s.key)} data-cursor="big">
{s.name}
)}
{/* corner decor: 4-dot */}
{/* top right diagonal dots */}
{[1, 2, 3, 4].map((i) => )}
04 · услуга · {String(idx + 1).padStart(2, "0")} / {String(services.length).padStart(2, "0")}
{cur.name}
{cur.desc}
{/* ===== Featured product плашка: V1 Data Capture ===== */}
);
}
/* ============== TEAM — auto-scrolling marquee of REAL people (synced with /pages/team) ============== */
function Team() {
const ref = useReveal();
// Same roster as on the team page. Initials are derived from "name".
const employees = [
{ name: "Иван Голяков", role: "Генеральный директор · основатель", dept: "Лидерство", quote: "Данные — это язык, на котором роботы учатся понимать наш мир. Наша работа — написать его начисто." },
{ name: "Максим Орлов", role: "Технический директор", dept: "Лидерство", quote: "Модель не бывает умнее данных, на которых выросла. Качество разметки — это потолок интеллекта машины." },
{ name: "Анна Лебедева", role: "Руководитель операций по данным", dept: "Лидерство", quote: "За каждым уверенным движением робота стоит тысяча аккуратно размеченных кадров. Порядок здесь рождает надёжность." },
{ name: "Дмитрий Романов", role: "Руководитель информационной безопасности", dept: "Лидерство", quote: "Доверие к данным начинается с их защиты. Чистый и безопасный датасет — фундамент, на котором держится всё остальное." },
{ name: "Кирилл Громов", role: "Руководитель VLA-исследований", dept: "Робототехника · ML", quote: "Робот учится не на идеале, а на честно размеченной реальности — со всеми её ошибками и восстановлениями." },
{ name: "Егор Васильев", role: "Старший инженер-робототехник", dept: "Робототехника · ML", quote: "Самый сильный момент — когда железо впервые повторяет жест человека. Этот мост строится из данных." },
{ name: "Сергей Кузнецов", role: "Инженер симуляции", dept: "Робототехника · ML", quote: "В симуляции я создаю тысячи миров. Хорошая разметка — то, что делает их правдой для робота." },
{ name: "Полина Соловьёва", role: "ML-инженер · AI-предразметка", dept: "Робототехника · ML", quote: "Предразметка — это диалог человека и модели. Машина предлагает, человек выверяет — так рождается точность." },
{ name: "Никита Зайцев", role: "Руководитель инженерии платформы", dept: "Платформа", quote: "Хорошая платформа незаметна: она просто даёт данным течь чисто и быстро. В этой тишине — вся инженерия." },
{ name: "Татьяна Морозова", role: "Старший дата-инженер", dept: "Платформа", quote: "Данные любят дисциплину. Когда поток выстроен верно, из хаоса сырых логов рождается знание." },
{ name: "Роман Белов", role: "Frontend-инженер", dept: "Платформа", quote: "Разметчик проводит с интерфейсом часы. Сделать эти часы лёгкими — тихая, но важная работа." },
{ name: "Юрий Воронцов", role: "DevOps-инженер · SRE", dept: "Платформа", quote: "Надёжность — это когда конвейер данных работает, а о нём не вспоминают. Стабильность дороже скорости." },
{ name: "Виктор Тихонов", role: "Руководитель teleop-студии", dept: "Teleop-фабрика", quote: "Через телеоперацию мы передаём роботу человеческую интуицию. Каждый эпизод — урок, записанный руками." },
{ name: "Ольга Новикова", role: "Старший руководитель разметки", dept: "Teleop-фабрика", quote: "Разметка — это ремесло внимания. Один честно отмеченный кадр стоит сотни сделанных наспех." },
{ name: "Александр Беляев", role: "Специалист по recovery-сценариям", dept: "Teleop-фабрика", quote: "Робот взрослеет не тогда, когда не ошибается, а когда умеет исправиться. Этому мы его и учим." },
{ name: "Мария Дмитриева", role: "Аудитор качества", dept: "Teleop-фабрика", quote: "Качество не проверяют в конце — его выстраивают на каждом шаге. Я страж этой правды в данных." },
{ name: "Лилия Котова", role: "Руководитель Customer Success", dept: "Работа с клиентами", quote: "Лучшие данные — те, что решают реальную задачу клиента. Я слежу, чтобы наша точность становилась их результатом." },
{ name: "Артём Карпов", role: "Sales-инженер", dept: "Работа с клиентами", quote: "Я объясняю инженерам ценность чистых данных их же языком. Честная цифра убеждает лучше любых слов." },
{ name: "Ксения Жукова", role: "Менеджер партнёрств", dept: "Работа с клиентами", quote: "Робототехника движется вперёд сообща. Хорошее партнёрство, как и хорошие данные, строится на доверии." },
{ name: "Глеб Михайлов", role: "Старший продуктовый дизайнер", dept: "Работа с клиентами", quote: "Дизайн для разметки — забота о тех, кто часами всматривается в детали. Ясность снижает усталость и ошибки." }];
// duplicate for seamless marquee loop
const loop = [...employees, ...employees];
const initial = (n) => (n.trim().charAt(0) || "·").toUpperCase();
return (
Робототехники, ML-исследователи, операторы teleop-станций и инженеры платформы. Вся команда →
);
}
/* ============== MEDIA — horizontal slider (placeholders) — section 06 ============== */
function Media() {
const scroller = useRef(null);
const articles = [
{ tag: "ГАЙД", source: "PDF", title: "Как собрать первый VLA-датасет за 30 дней", date: "Май 2026", abstract: "Пошаговая инструкция для команд, которые раньше не собирали robotics-данных — от episode-структуры до экспорта.", href: "pages/media/vla-dataset-30-days.html" },
{ tag: "БЕНЧМАРК", source: "блог", title: "DROID vs наш AI Pre-Label: IoU и скорость", date: "Апрель 2026", abstract: "Открытый отчёт о том, как предразметка влияет на скорость и точность human review на DROID benchmark.", href: "pages/media/droid-vs-ai-pre-label.html" },
{ tag: "КЕЙС", source: "видео", title: "Pi0.5 в кухонном домене: 800 эпизодов за неделю", date: "Март 2026", abstract: "12-минутный разбор полного цикла — от постановки до eval и выводов по Recovery-квотам.", href: "pages/media/pi05-kitchen-domain.html" },
{ tag: "СТАТЬЯ", source: "внешка", title: "Разметка данных под робототехнику: почему generic-платформы не работают", date: "Февраль 2026", abstract: "О том, почему VLA требуют особого tooling для proprioception, F/T-сенсорики и episode-timecodes.", href: "pages/media/robot-native-data-labeling.html" },
{ tag: "ГАЙД", source: "блог", title: "Recovery-эпизоды: что и зачем размечать", date: "Январь 2026", abstract: "Таксономия failure-классов, протокол error → recovery → resume, производственные квоты.", href: "pages/media/recovery-episodes.html" },
{ tag: "ПОДКАСТ", source: "эфир", title: "Внутри .v1: разговор о сборе данных для VLA", date: "Декабрь 2025", abstract: "47-минутный эфир с CTO о платформе, требованиях клиентов и sync ≤ 50 мс.", href: "pages/media/v1-podcast-vla-data.html" },
{ tag: "WHITEPAPER", source: "PDF", title: "Sync ≤ 50 мс: как мы синхронизируем мультисенсорику", date: "Ноябрь 2025", abstract: "Hardware-и software-методы синхронизации видео, проприоцепции и F/T в едином временном пространстве.", href: "pages/media/sync-50ms-multimodal.html" }];
const scrollBy = (dir) => scroller.current?.scrollBy({ left: dir * innerWidth * 0.7, behavior: "smooth" });
return (
Медиацентр
scrollBy(-1)} aria-label="prev" data-cursor="big" style={{ width: 60, height: 60, background: "var(--bg-card)", color: "var(--ink)", border: "1px solid var(--hair)", borderRadius: 20, display: "flex", alignItems: "center", justifyContent: "center" }}>
scrollBy(1)} aria-label="next" data-cursor="big" style={{ width: 60, height: 60, background: "var(--accent)", color: "#fff", borderRadius: 20, display: "flex", alignItems: "center", justifyContent: "center" }}>
);
}
/* ============== ABOUT — two-column with dark particle side — section 07 ============== */
function About() {
return (
. v1
Платформа разметки мультимодальных данных для VLA-моделей и гуманоидов.
Помогаем roboticists получать качественные данные — от первого эпизода до миллиона часов.
.v
Группа V1
О компании
{Array.from({ length: 220 }, (_, i) => {
const a = Math.random() * Math.PI * 2;
const r = 80 + Math.random() * 220;
const cx = 300 + Math.cos(a) * r;
const cy = 270 + Math.sin(a) * r * 0.6;
const sz = Math.random() * 2 + 0.5;
return 0.85 ? "var(--accent)" : "#fff"} opacity={0.3 + Math.random() * 0.5} />;
})}
. v1 / 2026
);
}
/* ============== TELEGRAM DELIVERY (via backend on Beget VPS) ============== */
const API_BASE = "https://v1data.ru"; // прод-домен; backend подключается через /api/*
async function sendToTelegram(payload) {
try {
const r = await fetch(`${API_BASE}/api/submit.php`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
const json = await r.json().catch(() => ({}));
return { ok: r.ok && json.ok, error: json.error };
} catch (e) {
return { ok: false, error: "Сеть недоступна" };
}
}
/* ============== CONTACT FORM (with success state) ============== */
function ContactForm() {
const [state, setState] = useState("idle"); // idle | sending | sent | error
const [errMsg, setErrMsg] = useState("");
const submit = async (e) => {
e.preventDefault();
setState("sending");
setErrMsg("");
const f = e.target;
const data = {
name: f.elements.namedItem("name")?.value,
company: f.elements.namedItem("company")?.value,
inn: f.elements.namedItem("inn")?.value,
phone: f.elements.namedItem("phone")?.value,
email: f.elements.namedItem("email")?.value,
robotType: f.elements.namedItem("robotType")?.value,
dataVolume: f.elements.namedItem("dataVolume")?.value,
comment: f.elements.namedItem("comment")?.value,
source: "Главная · /#contact",
_hp: f.elements.namedItem("_hp")?.value // honeypot
};
const result = await sendToTelegram(data);
if (result.ok) {
setState("sent");
} else {
setState("error");
setErrMsg(result.error || "Не удалось отправить заявку. Попробуйте позже.");
}
};
if (state === "sent") {
return (
Заявка отправлена
Спасибо! Мы свяжемся в течение 24 часов в рабочие дни. Ответ придёт с office@v1data.ru.
setState("idle")} style={{ marginTop: 12, padding: "14px 24px", borderRadius: 999, border: "1px solid var(--hair-strong)", background: "transparent", color: "var(--ink)", fontFamily: "var(--mono)", fontSize: 12, textTransform: "uppercase", letterSpacing: "0.1em", cursor: "pointer" }} data-cursor="big">Отправить ещё
);
}
return (
);
}
/* ============== CONTACT SECTION ============== */
function Contact() {
return (
);
}
/* ============== FOOTER — mono columns + GIANT serif wordmark ============== */
function Footer() {
return (
Продукт
Платформа разметки мультимодальных данных для VLA и гуманоидов
Экспорт
LeRobot v3 · Open X-Embodiment · RT-X · DROID
Облако / on-prem
AWS · GCP · Yandex Cloud · VK Cloud
Оператор
АО «НПО «Восход»
ИНН 9705223475 · КПП 772501001
ОГРН 1247700314971
Офис
Москва, ул. Ленинская Слобода, д. 26, помещ. 32/43
Партнёрство
© 2026 АО «НПО «Восход» — разметка данных под робототехнику ·
Что мы умеем
);
}
/* ============== COOKIE CONSENT BANNER ============== */
function CookieConsent() {
const [show, setShow] = useState(false);
useEffect(() => {
try {
if (!localStorage.getItem("v1-cookie-consent")) {
// delay slightly so it doesn't compete with the loader
const t = setTimeout(() => setShow(true), 1800);
return () => clearTimeout(t);
}
} catch {}
}, []);
const accept = () => {
try {localStorage.setItem("v1-cookie-consent", JSON.stringify({ accepted: true, at: new Date().toISOString() }));} catch {}
setShow(false);
};
if (!show) return null;
return (
Мы используем cookies. {" "}
Продолжая использовать сайт, вы соглашаетесь на обработку обезличенных данных cookies в соответствии с{" "}
Политикой конфиденциальности .
);
}
/* ============== SCROLL-TO-TOP ============== */
function ScrollTop() {
const [show, setShow] = useState(false);
useEffect(() => {
const on = () => setShow(window.scrollY > 600);
document.addEventListener("scroll", on, { passive: true });on();
return () => document.removeEventListener("scroll", on);
}, []);
return (
window.scrollTo({ top: 0, behavior: "smooth" })}
aria-label="Наверх"
data-cursor="big"
style={{
position: "fixed", right: 20, bottom: 20, zIndex: 90,
width: 48, height: 48, borderRadius: "50%",
background: "var(--ink)", color: "#fff", border: 0,
display: "flex", alignItems: "center", justifyContent: "center",
cursor: "pointer",
opacity: show ? 1 : 0, transform: show ? "none" : "translateY(20px) scale(0.85)",
pointerEvents: show ? "auto" : "none",
transition: "opacity .3s, transform .3s, background .25s",
boxShadow: "0 8px 20px rgba(0,0,0,0.18)"
}}>
);
}
/* ============== APP ============== */
function App() {
const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
const [loaderActive, setLoaderActive] = useState(t.loader);
const [menu, setMenu] = useState(false);
useEffect(() => {document.documentElement.style.setProperty("--accent", t.accent);}, [t.accent]);
useEffect(() => {if (!t.loader) setLoaderActive(false);}, [t.loader]);
useEffect(() => {
document.body.style.overflow = menu ? "hidden" : "";
}, [menu]);
// Hash navigation: React mounts after the browser tries to resolve #hash,
// and the loader keeps body locked for ~1.5s. Retry scroll several times
// until the target id is in the DOM AND the loader is gone.
useEffect(() => {
let cancelled = false;
const scrollToHash = () => {
const h = decodeURIComponent(window.location.hash || "").replace(/^#/, "");
if (!h) return false;
const el = document.getElementById(h);
if (!el) return false;
el.scrollIntoView({ behavior: "smooth", block: "start" });
return true;
};
// Retry up to 30 times over ~3s — covers loader + Babel compile delay.
let attempts = 0;
const tick = () => {
if (cancelled) return;
const done = scrollToHash();
attempts += 1;
if (!done && attempts < 30) setTimeout(tick, 100);
};
// Wait one frame so React layout settles, then start retries.
requestAnimationFrame(() => setTimeout(tick, 50));
const onChange = () => scrollToHash();
window.addEventListener("hashchange", onChange);
return () => {cancelled = true;window.removeEventListener("hashchange", onChange);};
}, []);
return (
<>
setLoaderActive(false)} />
setMenu(true)} />
setMenu(false)} />
setTweak("accent", v)}
options={["#EA0A23", "#FF1F36", "#1F4FE8", "#00C896", "#FFFFFF"]} />
setTweak("cursor", v)} />
setTweak("loader", v)} />
setLoaderActive(true)} />
>);
}
ReactDOM.createRoot(document.getElementById("root")).render( );