diff --git a/hifi-src/src/App.jsx b/hifi-src/src/App.jsx
index c532652..df81db0 100644
--- a/hifi-src/src/App.jsx
+++ b/hifi-src/src/App.jsx
@@ -1,5 +1,6 @@
import { Routes, Route, Navigate } from 'react-router-dom';
import ScrollToTop from './components/ScrollToTop.jsx';
+import CustomCursor from './components/CustomCursor.jsx';
import SetupGate from './components/SetupGate.jsx';
import PublicLayout from './components/PublicLayout.jsx';
import AdminLayout from './components/AdminLayout.jsx';
@@ -48,6 +49,7 @@ export default function App() {
return (
<>
+
} />
diff --git a/hifi-src/src/components/CustomCursor.jsx b/hifi-src/src/components/CustomCursor.jsx
new file mode 100644
index 0000000..3e842bd
--- /dev/null
+++ b/hifi-src/src/components/CustomCursor.jsx
@@ -0,0 +1,152 @@
+import { useEffect, useRef } from 'react';
+
+// Eigener Mauszeiger im Orbital-Motiv des HifiPlanet-Logos: ein gefuellter markengruener
+// Kern, um den ein kleiner "Satellit" auf einer schraeg gestellten Ellipsenbahn kreist
+// (wie der Orbit-Schwung im Logo). Ueber interaktiven Elementen weitet sich die Bahn,
+// der Satellit beschleunigt und alles leuchtet heller; ein Klick loest einen kurzen
+// "Bass-Thump"-Puls aus. Kern UND Bahn folgen der Maus synchron (kein Nachziehen -
+// das wirkte bei schnellen Bewegungen wie zwei getrennte Zeiger); nur Umlauf-
+// geschwindigkeit und Bahnradius wechseln weich zwischen den Zustaenden.
+//
+// Aktiv nur auf Geraeten mit echter Maus (pointer: fine) und NICHT bei reduzierter
+// Bewegung (prefers-reduced-motion) - sonst bleibt der native Cursor unangetastet.
+// Ueber Textfeldern blendet sich der Orbit aus und der native Text-Cursor uebernimmt
+// (siehe zugehoerige CSS-Regeln in index.css unter "Custom Cursor").
+
+const INTERACTIVE_SELECTOR =
+ 'a, button, [role="button"], label, select, summary, [data-cursor="hover"], .cursor-pointer';
+const TEXTLIKE_SELECTOR =
+ 'input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="submit"]):not([type="button"]):not([type="file"]), textarea, [contenteditable="true"]';
+
+export default function CustomCursor() {
+ const rootRef = useRef(null);
+ const coreRef = useRef(null);
+ const orbitRef = useRef(null);
+ const satRef = useRef(null);
+ const pulseRef = useRef(null);
+
+ useEffect(() => {
+ const finePointer = window.matchMedia('(pointer: fine)');
+ const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
+ if (!finePointer.matches || reducedMotion.matches) return undefined;
+
+ const root = rootRef.current;
+ const core = coreRef.current;
+ const orbit = orbitRef.current;
+ const sat = satRef.current;
+ const pulse = pulseRef.current;
+ if (!root || !core || !orbit || !sat || !pulse) return undefined;
+
+ document.documentElement.classList.add('hifi-cursor-on');
+
+ // Bahn-Neigung wie der Orbit-Schwung im Logo.
+ const TILT = (-24 * Math.PI) / 180;
+ const cosT = Math.cos(TILT);
+ const sinT = Math.sin(TILT);
+
+ let mouseX = -100;
+ let mouseY = -100;
+ let angle = 0;
+ // Umlaufgeschwindigkeit (rad/s) und Bahnradien werden pro Frame weich in Richtung
+ // ihrer Zielwerte gezogen, damit Hover-Wechsel nie springen.
+ let speed = 2.4;
+ let targetSpeed = 2.4;
+ let radiusX = 16;
+ let radiusY = 6.5;
+ let targetRadiusX = 16;
+ let targetRadiusY = 6.5;
+ let visible = false;
+ let rafId;
+ let last = performance.now();
+
+ const tick = (now) => {
+ const dt = Math.min((now - last) / 1000, 0.05);
+ last = now;
+ speed += (targetSpeed - speed) * Math.min(1, dt * 8);
+ radiusX += (targetRadiusX - radiusX) * Math.min(1, dt * 10);
+ radiusY += (targetRadiusY - radiusY) * Math.min(1, dt * 10);
+ angle += speed * dt;
+
+ core.style.transform = `translate3d(${mouseX}px, ${mouseY}px, 0)`;
+ orbit.style.transform = `translate3d(${mouseX}px, ${mouseY}px, 0)`;
+ const ex = Math.cos(angle) * radiusX;
+ const ey = Math.sin(angle) * radiusY;
+ sat.style.transform = `translate(${ex * cosT - ey * sinT}px, ${ex * sinT + ey * cosT}px)`;
+
+ rafId = requestAnimationFrame(tick);
+ };
+
+ const applyState = (state) => {
+ root.dataset.state = state;
+ const hovering = state === 'hover';
+ targetSpeed = hovering ? 7 : 2.4;
+ targetRadiusX = hovering ? 23 : 16;
+ targetRadiusY = hovering ? 9.5 : 6.5;
+ };
+
+ const onMouseMove = (e) => {
+ mouseX = e.clientX;
+ mouseY = e.clientY;
+ if (!visible) {
+ visible = true;
+ root.dataset.hidden = 'false';
+ }
+ };
+
+ const onMouseOver = (e) => {
+ const target = e.target;
+ if (!(target instanceof Element)) return;
+ if (target.closest(TEXTLIKE_SELECTOR)) applyState('text');
+ else if (target.closest(INTERACTIVE_SELECTOR)) applyState('hover');
+ else applyState('default');
+ };
+
+ const onMouseDown = () => {
+ root.dataset.down = 'true';
+ pulse.style.left = `${mouseX}px`;
+ pulse.style.top = `${mouseY}px`;
+ // Animation neu anstossen, auch wenn sie gerade noch laeuft.
+ pulse.classList.remove('is-live');
+ void pulse.offsetWidth;
+ pulse.classList.add('is-live');
+ };
+ const onMouseUp = () => {
+ root.dataset.down = 'false';
+ };
+
+ const onLeave = () => {
+ visible = false;
+ root.dataset.hidden = 'true';
+ };
+
+ document.addEventListener('mousemove', onMouseMove, { passive: true });
+ document.addEventListener('mouseover', onMouseOver, { passive: true });
+ document.addEventListener('mousedown', onMouseDown, { passive: true });
+ document.addEventListener('mouseup', onMouseUp, { passive: true });
+ document.documentElement.addEventListener('mouseleave', onLeave);
+ window.addEventListener('blur', onLeave);
+ rafId = requestAnimationFrame(tick);
+
+ return () => {
+ cancelAnimationFrame(rafId);
+ document.removeEventListener('mousemove', onMouseMove);
+ document.removeEventListener('mouseover', onMouseOver);
+ document.removeEventListener('mousedown', onMouseDown);
+ document.removeEventListener('mouseup', onMouseUp);
+ document.documentElement.removeEventListener('mouseleave', onLeave);
+ window.removeEventListener('blur', onLeave);
+ document.documentElement.classList.remove('hifi-cursor-on');
+ };
+ }, []);
+
+ return (
+
+ );
+}
diff --git a/hifi-src/src/index.css b/hifi-src/src/index.css
index 96be5c9..0018193 100644
--- a/hifi-src/src/index.css
+++ b/hifi-src/src/index.css
@@ -35,3 +35,135 @@ html {
.dark .grain-band {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.7' numOctaves='3' stitchTiles='stitch' result='noise'/%3E%3CfeColorMatrix in='noise' type='matrix' values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.14 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
+
+/* ==== Custom Cursor (Orbital-Motiv, siehe components/CustomCursor.jsx) ====
+ Die Klasse hifi-cursor-on setzt NUR die Komponente selbst - und nur auf Geraeten
+ mit echter Maus ohne reduzierte Bewegung. Ohne die Klasse bleibt ueberall der
+ native Cursor (Touch, prefers-reduced-motion, JS-Fehler). */
+html.hifi-cursor-on,
+html.hifi-cursor-on * {
+ cursor: none !important;
+}
+/* Ueber Textfeldern uebernimmt der native Text-Cursor (die Orbit-Teile blenden sich
+ per data-state="text" aus) - Praezision beim Textmarkieren geht vor Show. */
+html.hifi-cursor-on :is(input, textarea, [contenteditable='true']) {
+ cursor: auto !important;
+}
+
+.hifi-cursor {
+ position: fixed;
+ inset: 0;
+ pointer-events: none;
+ z-index: 99999;
+ opacity: 1;
+ transition: opacity 0.2s ease;
+}
+.hifi-cursor[data-hidden='true'] {
+ opacity: 0;
+}
+
+/* Kern: gefuellter Punkt, folgt der Maus direkt. Weisser Rand + Glow machen ihn auf
+ hellem Papier UND dunklen Kacheln gleichermassen sichtbar. */
+.hifi-cursor__core {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 10px;
+ height: 10px;
+ margin: -5px 0 0 -5px;
+ border-radius: 9999px;
+ background: #7fd44a;
+ box-shadow:
+ 0 0 0 1.5px rgba(255, 255, 255, 0.9),
+ 0 0 10px rgba(127, 212, 74, 0.75);
+ transition:
+ width 0.18s ease,
+ height 0.18s ease,
+ margin 0.18s ease,
+ background-color 0.18s ease,
+ opacity 0.15s ease;
+}
+
+.hifi-cursor__orbit {
+ position: fixed;
+ top: 0;
+ left: 0;
+ transition: opacity 0.15s ease;
+}
+
+/* Schraege Ellipsenbahn wie der Orbit-Schwung im Logo. */
+.hifi-cursor__ring {
+ position: absolute;
+ width: 38px;
+ height: 15px;
+ margin: -7.5px 0 0 -19px;
+ border: 1px solid rgba(127, 212, 74, 0.5);
+ border-radius: 50%;
+ transform: rotate(-24deg);
+ transition:
+ transform 0.25s ease,
+ border-color 0.25s ease;
+}
+
+/* Satellit: Position auf der Bahn wird per JS gesetzt (rotierender Punkt). */
+.hifi-cursor__sat {
+ position: absolute;
+ width: 5px;
+ height: 5px;
+ margin: -2.5px 0 0 -2.5px;
+ border-radius: 9999px;
+ background: #eaffdb;
+ box-shadow: 0 0 6px rgba(139, 234, 60, 0.9);
+}
+
+/* Hover ueber Interaktivem: Kern waechst, Bahn weitet sich und leuchtet auf
+ (Satellit beschleunigt via JS). */
+.hifi-cursor[data-state='hover'] .hifi-cursor__core {
+ width: 16px;
+ height: 16px;
+ margin: -8px 0 0 -8px;
+ background: #8bea3c;
+}
+.hifi-cursor[data-state='hover'] .hifi-cursor__ring {
+ transform: rotate(-24deg) scale(1.45);
+ border-color: rgba(139, 234, 60, 0.9);
+}
+
+/* Ueber Textfeldern: komplett ausblenden, nativer Text-Cursor uebernimmt. */
+.hifi-cursor[data-state='text'] .hifi-cursor__core,
+.hifi-cursor[data-state='text'] .hifi-cursor__orbit {
+ opacity: 0;
+}
+
+/* Gedrueckte Maustaste: Kern "thumpt" wie eine Subwoofer-Membran. */
+.hifi-cursor[data-down='true'] .hifi-cursor__core {
+ width: 20px;
+ height: 20px;
+ margin: -10px 0 0 -10px;
+}
+
+/* Klick-Puls: einmalig auslaufender Ring am Klickpunkt. */
+.hifi-cursor__pulse {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 12px;
+ height: 12px;
+ margin: -6px 0 0 -6px;
+ border-radius: 9999px;
+ border: 1.5px solid rgba(139, 234, 60, 0.9);
+ opacity: 0;
+}
+.hifi-cursor__pulse.is-live {
+ animation: hifi-cursor-pulse 0.45s ease-out forwards;
+}
+@keyframes hifi-cursor-pulse {
+ from {
+ opacity: 0.9;
+ transform: scale(1);
+ }
+ to {
+ opacity: 0;
+ transform: scale(3.2);
+ }
+}