Musik-Button (Van-Van Spotify) unten rechts, seitenweit persistent + Chamäleon-Farben

Vorschau-Umschalter (🎭) nach unten links verschoben, damit unten rechts Platz für
den neuen Musik-Button ist. Astro ClientRouter (View Transitions) eingeführt, damit
der Spotify-Player beim Seitenwechsel als echtes, unverändertes DOM-Element bestehen
bleibt (transition:persist) statt neu zu laden — die Musik läuft beim Weiterklicken
einfach weiter. Der Button-Farbverlauf passt sich per Canvas-Farbanalyse an das
Hauptbild der jeweiligen Seite an, mit einem seiten-spezifischen Fallback-Farbpaar
für bildlose Seiten (Warenkorb, Rechtstexte, ...).

Da Astro Skripte bei Client-seitigen Übergängen nicht automatisch neu ausführt, musste
das komplette Header-Skript (Warenkorb-Zähler, Konto-Menü, Hamburger, Installations-
Button) auf das "astro:page-load"-Ereignis umgestellt werden; echte Dauer-Listener
(document/window) wurden dabei bewusst nur einmal registriert, um Duplikate bei jeder
Navigation zu vermeiden. Ausführlich mit einem headless-Chrome-Testskript verifiziert:
Iframe-Persistenz über mehrere Navigationen hinweg, keine doppelten Klick-Listener,
keine Konsolenfehler.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
qcigano
2026-08-03 19:50:02 +02:00
co-authored by Claude Sonnet 5
parent d3efaf8aba
commit b888e682d8
2 changed files with 347 additions and 77 deletions
+252 -71
View File
@@ -4,6 +4,13 @@ import { categories } from "../data/categories";
import { defaultLocale, localeMeta, localePrefix, type Locale } from "../i18n/config";
import { useTranslations } from "../i18n/ui";
import LanguageSwitcher from "../components/LanguageSwitcher.astro";
// ClientRouter (Astros eingebaute View-Transitions-Technik) ausschließlich für EIN Ziel aktiviert:
// den Musik-Button unten rechts (siehe transition:persist weiter unten) soll beim Seitenwechsel
// nicht neu erzeugt werden, damit ein einmal gestarteter Spotify-Player weiterläuft, statt bei
// jedem Klick auf einen Link neu zu laden — genau wie beim Dogfather-Universe-Projekt, nur mit
// Astros nativer, offiziell unterstützter Lösung dafür statt eines selbstgebauten PJAX-Systems
// (das mit Astros gebündelten, gehashten Skript-Dateien nicht zuverlässig zusammenspielen würde).
import { ClientRouter } from "astro:transitions";
interface Props {
title: string;
@@ -56,6 +63,7 @@ const p = (href: string) => `${localePrefix(lang)}${href}`;
<style>
:root { --font-head-override: "Fraunces", serif; }
</style>
<ClientRouter fallback="swap" />
</head>
<body>
<div class="topbar">🩵 {t.topbar}</div>
@@ -194,13 +202,49 @@ const p = (href: string) => `${localePrefix(lang)}${href}`;
</div>
</div>
{/* Musik-Button: schwebt rechts unten auf JEDER Seite, spielt Van-Vans Musik von Spotify
(genau wie beim Dogfather-Universe-Projekt — gleiche Spotify-Artist-ID, gleiches Grund-
prinzip). transition:persist sorgt dafür, dass dieses Element (inkl. laufendem Spotify-
iframe) beim Seitenwechsel NICHT neu erzeugt wird, sondern unverändert in die neue Seite
übernommen wird — die Musik läuft also beim Klicken durch die Seite einfach weiter, statt
bei jeder Navigation neu zu starten. Die Farbe des Knopfs passt sich "chamäleonartig" an
die jeweilige Seite an (siehe adaptMusicColors() im Skript unten). */}
<div id="vandiy-music-widget" transition:persist transition:name="vandiy-music-widget">
<div class="music-panel" id="music-panel" hidden>
<div class="music-panel-head">
<strong>🎵 Musik von Van-Van</strong>
<button type="button" class="music-close" id="music-close" aria-label="Musik-Player schließen">✕</button>
</div>
<p class="small music-panel-intro">Hör beim Stöbern Van-Vans Musik auf Spotify — läuft einfach weiter, auch wenn du auf der Seite weiterklickst.</p>
<iframe
id="music-iframe"
data-src="https://open.spotify.com/embed/artist/4J9PcKZBkh6V8dVY3CGliw?utm_source=generator&theme=0"
height="352"
loading="lazy"
title="Van-Van auf Spotify"
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
allowfullscreen
></iframe>
</div>
<button type="button" class="music-toggle" id="music-toggle" aria-expanded="false" aria-controls="music-panel" title="Musik von Van-Van">
<span>🎵</span>
</button>
</div>
<script>
import { cartCount } from "../scripts/cart";
import { getAccount, logout, anzeigeName } from "../scripts/account";
// Vorschau-Umschalter: Sichtbarkeit hängt allein vom (nicht-httpOnly) Rollen-Cookie ab, das
// die Gate-Middleware nach erfolgreicher Zugangscode-Eingabe setzt.
(function initPreviewSwitcher() {
// WICHTIG seit Einführung von <ClientRouter /> (siehe Kopf dieser Datei, nur für den
// persistenten Musik-Button gedacht): Astro führt dieses Skript bei einem Seitenwechsel NICHT
// automatisch erneut aus (nur bei einem waschechten ersten Laden) — alles, was auf JEDER
// Seite neu laufen muss (Warenkorb-Zähler, Konto-Menü, Hamburger, Musik-Farbe …), gehört
// deshalb in initPage() weiter unten, aufgerufen über das Astro-Ereignis "astro:page-load"
// (feuert zuverlässig sowohl beim allerersten Laden als auch nach jedem Seitenwechsel).
// Reines Browser-/Session-Setup (Installations-Prompt, Service Worker) bleibt bewusst AUSSERHALB
// von initPage(), weil es nur einmal pro Sitzung registriert werden darf.
function initPreviewSwitcher() {
const match = document.cookie.match(/(?:^|;\s*)vandiy_gate_role=([^;]+)/);
const role = match ? decodeURIComponent(match[1]) : null;
if (!role) return;
@@ -230,9 +274,10 @@ const p = (href: string) => `${localePrefix(lang)}${href}`;
const open = panel.classList.toggle("open");
toggle.setAttribute("aria-expanded", open ? "true" : "false");
});
document.addEventListener("click", (e) => {
if (!widget.contains(e.target as Node)) panel.classList.remove("open");
});
// Kein document.addEventListener hier drin (würde sich bei jedem Seitenwechsel erneut
// aufaddieren, da #vandiy-preview-switcher zwar bei jeder Seite neu erzeugt wird, `document`
// selbst aber die ganze Sitzung über bestehen bleibt) — das Schließen bei Klick außerhalb
// übernimmt stattdessen ein einziger, dauerhafter Listener weiter unten mit Live-Abfrage.
const heute = new Date().toISOString().slice(0, 10);
@@ -287,29 +332,26 @@ const p = (href: string) => `${localePrefix(lang)}${href}`;
}
});
});
})();
}
function updateCount() {
const el = document.getElementById("cart-count");
if (el) el.textContent = String(cartCount());
}
updateCount();
window.addEventListener("cart:changed", updateCount);
window.addEventListener("storage", updateCount);
// Konto-Bereich der Kopfzeile: "Anmelden"-Link vs. Konto-Menü, je nachdem ob auf diesem
// Gerät gerade ein (Demo-)Konto angemeldet ist (siehe scripts/account.ts). Läuft auf JEDER
// Seite, weil Layout.astro von allen Seiten eingebunden wird — daher überall konsistent.
const loginBtn = document.getElementById("account-login-btn");
const menu = document.getElementById("account-menu");
const menuBtn = document.getElementById("account-menu-btn");
const dropdown = document.getElementById("account-dropdown");
const avatar = document.getElementById("account-avatar");
const usernameLabel = document.getElementById("account-username-label");
const logoutBtn = document.getElementById("account-logout-btn");
// Fragt Elemente bewusst live per document.getElementById ab (statt sie einmal zu speichern),
// damit sie sowohl beim erneuten Aufruf aus initPage() als auch aus den dauerhaften
// window/document-Listenern weiter unten immer die AKTUELLEN (nach Seitenwechsel neuen)
// Elemente treffen.
function renderAccountArea() {
const a = getAccount();
const loginBtn = document.getElementById("account-login-btn");
const menu = document.getElementById("account-menu");
const avatar = document.getElementById("account-avatar");
const usernameLabel = document.getElementById("account-username-label");
if (loginBtn) loginBtn.style.display = a.loggedIn ? "none" : "";
if (menu) menu.style.display = a.loggedIn ? "flex" : "none";
if (!a.loggedIn) return;
@@ -324,77 +366,216 @@ const p = (href: string) => `${localePrefix(lang)}${href}`;
}
}
}
renderAccountArea();
window.addEventListener("account:changed", renderAccountArea);
window.addEventListener("storage", renderAccountArea);
function closeAccountMenu() {
document.getElementById("account-menu")?.classList.remove("open");
document.getElementById("account-menu-btn")?.setAttribute("aria-expanded", "false");
}
// Nur das Verdrahten der Listener an die (bei jedem Seitenwechsel neu erzeugten) Kopfzeilen-
// Elemente selbst — sicher, wiederholt aufzurufen: alte Elemente samt ihren Listenern
// verschwinden zusammen mit dem Seitenwechsel, keine Ansammlung.
function initAccountArea() {
renderAccountArea();
const menu = document.getElementById("account-menu");
const menuBtn = document.getElementById("account-menu-btn");
const logoutBtn = document.getElementById("account-logout-btn");
menuBtn?.addEventListener("click", (e) => {
e.stopPropagation();
const open = menu?.classList.toggle("open");
menuBtn.setAttribute("aria-expanded", open ? "true" : "false");
});
menuBtn?.addEventListener("click", (e) => {
e.stopPropagation();
const open = menu?.classList.toggle("open");
menuBtn.setAttribute("aria-expanded", open ? "true" : "false");
});
// Öffnet zusätzlich automatisch beim Drüberfahren mit der Maus (Desktop) — kein Klick mehr
// nötig. Kurze Schließverzögerung beim Verlassen, damit der Wechsel von Button zu Menü nicht
// sofort wieder zuklappt. Klick bleibt als Fallback für Touch-Geräte/Tastatur erhalten.
let accountCloseTimer: ReturnType<typeof setTimeout> | null = null;
menu?.addEventListener("mouseenter", () => {
if (accountCloseTimer) { clearTimeout(accountCloseTimer); accountCloseTimer = null; }
menu.classList.add("open");
menuBtn?.setAttribute("aria-expanded", "true");
});
menu?.addEventListener("mouseleave", () => {
accountCloseTimer = setTimeout(() => {
menu.classList.remove("open");
menuBtn?.setAttribute("aria-expanded", "false");
}, 200);
});
// Öffnet zusätzlich automatisch beim Drüberfahren mit der Maus (Desktop) — kein Klick mehr
// nötig. Kurze Schließverzögerung beim Verlassen, damit der Wechsel von Button zu Menü nicht
// sofort wieder zuklappt. Klick bleibt als Fallback für Touch-Geräte/Tastatur erhalten.
let accountCloseTimer: ReturnType<typeof setTimeout> | null = null;
menu?.addEventListener("mouseenter", () => {
if (accountCloseTimer) { clearTimeout(accountCloseTimer); accountCloseTimer = null; }
menu.classList.add("open");
menuBtn?.setAttribute("aria-expanded", "true");
});
menu?.addEventListener("mouseleave", () => {
accountCloseTimer = setTimeout(closeAccountMenu, 200);
});
document.addEventListener("click", () => {
menu?.classList.remove("open");
menuBtn?.setAttribute("aria-expanded", "false");
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
menu?.classList.remove("open");
menuBtn?.setAttribute("aria-expanded", "false");
}
});
logoutBtn?.addEventListener("click", () => {
logout();
menu?.classList.remove("open");
menuBtn?.setAttribute("aria-expanded", "false");
});
logoutBtn?.addEventListener("click", () => {
logout();
closeAccountMenu();
});
}
const hamburger = document.getElementById("hamburger");
const mobileNav = document.getElementById("mobile-nav");
hamburger?.addEventListener("click", () => {
const open = mobileNav?.classList.toggle("open");
hamburger.setAttribute("aria-expanded", String(!!open));
});
function initHamburger() {
const hamburger = document.getElementById("hamburger");
const mobileNav = document.getElementById("mobile-nav");
hamburger?.addEventListener("click", () => {
const open = mobileNav?.classList.toggle("open");
hamburger.setAttribute("aria-expanded", String(!!open));
});
}
// "Auf dem Gerät installieren" — zeigt einen echten Button in der Kopfzeile, sobald der
// Browser eine Installation anbietet (Chrome/Edge auf PC & Handy), statt sich darauf zu
// verlassen, dass jemand das winzige Icon im Browser selbst findet. Läuft bereits als
// installierte App? Dann gibt es sowieso nichts mehr zu installieren — Button bleibt versteckt.
const installBtn = document.getElementById("install-btn");
// installPromptEvent lebt AUSSERHALB von initPage() (Modul-Gültigkeitsbereich, überlebt jeden
// Seitenwechsel unverändert), weil das Ereignis nur EINMAL pro Sitzung vom Browser kommt, der
// "Installieren"-Knopf selbst aber auf jeder neu geladenen Seite neu gefunden werden muss.
let installPromptEvent: any = null;
function initInstallButton() {
const installBtn = document.getElementById("install-btn");
installBtn?.addEventListener("click", async () => {
if (!installPromptEvent) return;
installBtn.style.display = "none";
installPromptEvent.prompt();
await installPromptEvent.userChoice;
installPromptEvent = null;
});
if (installPromptEvent && installBtn) installBtn.style.display = "flex";
}
// Musik-Button: #vandiy-music-widget bleibt dank transition:persist bei jedem Seitenwechsel
// dasselbe DOM-Element (siehe Markup oben) — der "wired"-Schutz verhindert, dass sich Klick-
// Listener bei jedem erneuten initPage()-Aufruf aufaddieren, während die Farbanpassung
// bewusst bei JEDER Seite neu läuft.
function initMusicWidget() {
const widget = document.getElementById("vandiy-music-widget");
const toggle = document.getElementById("music-toggle");
const panel = document.getElementById("music-panel") as HTMLElement | null;
const closeBtn = document.getElementById("music-close");
const iframe = document.getElementById("music-iframe") as HTMLIFrameElement | null;
if (!widget || !toggle || !panel) return;
if (!widget.dataset.wired) {
widget.dataset.wired = "1";
const openPanel = () => {
panel.hidden = false;
widget.classList.add("open");
toggle.setAttribute("aria-expanded", "true");
if (iframe && !iframe.getAttribute("src")) {
const src = iframe.dataset.src;
if (src) iframe.setAttribute("src", src);
}
};
const closePanel = () => {
panel.hidden = true;
widget.classList.remove("open");
toggle.setAttribute("aria-expanded", "false");
};
toggle.addEventListener("click", () => { panel.hidden ? openPanel() : closePanel(); });
closeBtn?.addEventListener("click", closePanel);
document.addEventListener("click", (e) => {
if (!panel.hidden && !widget.contains(e.target as Node)) closePanel();
});
}
adaptMusicColors(toggle);
}
// "Chamäleon"-Farblogik: nimmt das erste sinnvolle Bild im Hauptinhalt der aktuellen Seite,
// liest per Canvas Durchschnitts- und auffälligste Farbe aus und färbt den Musik-Knopf
// entsprechend ein (siehe --music-c1/--music-c2 im CSS). Fremd-Domain-Bilder oder Seiten ganz
// ohne passendes Bild (Warenkorb, Konto, Rechtstexte …) fallen sauber auf ein festes, aber je
// nach URL-Pfad unterschiedliches Markenfarben-Paar zurück.
async function adaptMusicColors(toggle: HTMLElement) {
const FALLBACK_PALETTE: [string, string][] = [
["#5fd3ec", "#9b7fd6"],
["#9b7fd6", "#e3b23c"],
["#e3b23c", "#e7879a"],
["#e7879a", "#5fd3ec"],
["#6ecb8f", "#5fd3ec"],
];
function fallbackColors(): [string, string] {
let hash = 0;
const path = location.pathname;
for (let i = 0; i < path.length; i++) hash = (hash * 31 + path.charCodeAt(i)) >>> 0;
return FALLBACK_PALETTE[hash % FALLBACK_PALETTE.length];
}
let c1: string | null = null;
let c2: string | null = null;
const img = document.querySelector<HTMLImageElement>("main img[src]");
if (img) {
try {
if (!img.complete) {
await new Promise<void>((resolve, reject) => {
img.addEventListener("load", () => resolve(), { once: true });
img.addEventListener("error", () => reject(), { once: true });
});
}
const canvas = document.createElement("canvas");
canvas.width = 24;
canvas.height = 24;
const ctx = canvas.getContext("2d");
if (ctx) {
ctx.drawImage(img, 0, 0, 24, 24);
const { data } = ctx.getImageData(0, 0, 24, 24);
let r = 0, g = 0, b = 0, n = 0;
let bestSpread = -1, sr = 0, sg = 0, sb = 0;
for (let i = 0; i < data.length; i += 4) {
if (data[i + 3] < 40) continue; // durchsichtige Pixel ignorieren
r += data[i]; g += data[i + 1]; b += data[i + 2]; n++;
const spread = Math.max(data[i], data[i + 1], data[i + 2]) - Math.min(data[i], data[i + 1], data[i + 2]);
if (spread > bestSpread) { bestSpread = spread; sr = data[i]; sg = data[i + 1]; sb = data[i + 2]; }
}
if (n > 0) {
c1 = `rgb(${Math.round(r / n)}, ${Math.round(g / n)}, ${Math.round(b / n)})`;
c2 = `rgb(${sr}, ${sg}, ${sb})`;
}
}
} catch {
// Fremd-Domain-Bild (CORS) oder Ladefehler -> unten auf Fallback-Palette zurückfallen.
}
}
if (!c1 || !c2) { [c1, c2] = fallbackColors(); }
toggle.style.setProperty("--music-c1", c1);
toggle.style.setProperty("--music-c2", c2);
}
// Dauerhafte document/window-Listener — bewusst NUR EINMAL registriert (nicht in initPage()),
// weil `document`/`window` selbst bei einem Seitenwechsel NICHT neu erzeugt werden. Würden
// diese stattdessen bei jedem astro:page-load neu angehängt, würden sie sich mit jeder
// Navigation zusätzlich aufaddieren. Fragen die betroffenen Elemente deshalb erst IM
// Ereignisfall live ab, statt sie beim Registrieren einmalig festzuhalten.
window.addEventListener("account:changed", renderAccountArea);
window.addEventListener("storage", renderAccountArea);
document.addEventListener("click", (e) => {
closeAccountMenu();
const previewWidget = document.getElementById("vandiy-preview-switcher");
const previewPanel = document.getElementById("vandiy-preview-panel");
if (previewWidget && previewPanel && !previewWidget.contains(e.target as Node)) {
previewPanel.classList.remove("open");
}
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") closeAccountMenu();
});
// Läuft zuverlässig sowohl beim allerersten Laden als auch nach jedem Seitenwechsel (siehe
// Kommentar oben) — alles, was auf JEDER Seite frisch eingerichtet werden muss, steht hier.
document.addEventListener("astro:page-load", () => {
initPreviewSwitcher();
updateCount();
initAccountArea();
initHamburger();
initInstallButton();
initMusicWidget();
});
// Reines Browser-/Sitzungs-Setup — läuft nur EINMAL (dieses Skript selbst wird von Astro nur
// beim allerersten Laden ausgeführt, siehe Kommentar oben), absichtlich AUSSERHALB von
// initPage(), weil sich diese Ereignisse sonst bei jedem Seitenwechsel erneut registrieren
// und mehrfach feuern würden.
const laeuftBereitsAlsApp = window.matchMedia("(display-mode: standalone)").matches;
let installPromptEvent = null;
if (!laeuftBereitsAlsApp) {
window.addEventListener("beforeinstallprompt", (e) => {
e.preventDefault();
installPromptEvent = e;
const installBtn = document.getElementById("install-btn");
if (installBtn) installBtn.style.display = "flex";
});
}
installBtn?.addEventListener("click", async () => {
if (!installPromptEvent) return;
installBtn.style.display = "none";
installPromptEvent.prompt();
await installPromptEvent.userChoice;
installPromptEvent = null;
});
window.addEventListener("appinstalled", () => {
const installBtn = document.getElementById("install-btn");
if (installBtn) installBtn.style.display = "none";
});
+95 -6
View File
@@ -2650,10 +2650,11 @@ a:focus-visible, button:focus-visible {
.abo-notif-date { color: var(--c-text-muted); }
@media (prefers-reduced-motion: reduce) { .abo-plan-card, .abo-plan-card.is-current-plan, .abo-reward-ready { animation: none; transition: none; } }
/* Vorschau-Umschalter (nur für qciga/vanvan sichtbar, siehe Layout.astro) — schwebt rechts unten
über jeder Seite, damit man ohne Seitenwechsel zwischen normalem Besucher/angemeldeter Kundin/
Abonnentin hin- und herschalten kann. */
#vandiy-preview-switcher { position: fixed; right: 1.2rem; bottom: 1.2rem; z-index: 200; }
/* Vorschau-Umschalter (nur für qciga/vanvan sichtbar, siehe Layout.astro) — schwebt LINKS unten
über jeder Seite (rechts unten ist der Musik-Button, siehe weiter unten), damit man ohne
Seitenwechsel zwischen normalem Besucher/angemeldeter Kundin/Abonnentin hin- und herschalten
kann. */
#vandiy-preview-switcher { position: fixed; left: 1.2rem; bottom: 1.2rem; z-index: 200; }
#vandiy-preview-toggle {
width: 52px;
height: 52px;
@@ -2671,7 +2672,7 @@ a:focus-visible, button:focus-visible {
#vandiy-preview-panel {
display: none;
position: absolute;
right: 0;
left: 0;
bottom: calc(100% + 0.7rem);
min-width: 240px;
padding: 0.8rem;
@@ -2696,7 +2697,95 @@ a:focus-visible, button:focus-visible {
transition: background 0.15s var(--ease);
}
#vandiy-preview-panel button[data-preview]:hover { background: color-mix(in srgb, var(--c-accent) 14%, transparent); }
@media (max-width: 500px) { #vandiy-preview-switcher { right: 0.7rem; bottom: 0.7rem; } }
@media (max-width: 500px) { #vandiy-preview-switcher { left: 0.7rem; bottom: 0.7rem; } }
/* Musik-Button: schwebt rechts unten auf JEDER Seite (Vorschau-Umschalter ist links unten, siehe
oben) — spielt Van-Vans Musik von Spotify, läuft dank transition:persist (Layout.astro) beim
Seitenwechsel einfach weiter statt neu zu starten. Gleiches Gestaltungsprinzip wie beim
Dogfather-Universe-Projekt: dunkler Knopf mit wandernder, weichgezeichneter Farbwolke
("Aurora") + durchlaufendem Lichtschimmer, deren Farben sich "chamäleonartig" an die jeweilige
Seite anpassen (siehe --music-c1/--music-c2, live per JS gesetzt — adaptMusicColors() im
Skript in Layout.astro). Ohne JS/Bild fällt der Knopf sauber auf die normalen Markenfarben
zurück. */
#vandiy-music-widget { position: fixed; right: 1.2rem; bottom: 1.2rem; z-index: 200; display: flex; flex-direction: column; align-items: flex-end; gap: 0.8rem; }
.music-toggle {
position: relative;
isolation: isolate;
overflow: hidden;
width: 54px;
height: 54px;
flex: none;
border-radius: 50%;
font-size: 1.45rem;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #fff;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.55);
background: var(--c-black);
border: 1px solid color-mix(in srgb, var(--music-c1, var(--c-accent)) 55%, transparent);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.45), 0 0 24px color-mix(in srgb, var(--music-c1, var(--c-accent)) 35%, transparent);
transition: transform 0.25s var(--ease), box-shadow 0.25s var(--ease), border-color 1.1s ease;
}
.music-toggle::before {
content: "";
position: absolute;
inset: -50%;
z-index: 0;
background:
radial-gradient(circle at 25% 30%, color-mix(in srgb, var(--music-c1, var(--c-accent)) 92%, transparent), transparent 58%),
radial-gradient(circle at 75% 72%, color-mix(in srgb, var(--music-c2, var(--c-purple-glow)) 92%, transparent), transparent 58%);
filter: blur(7px) saturate(1.3);
animation: music-aurora 10s ease-in-out infinite alternate;
transition: background 1.1s ease;
}
.music-toggle::after {
content: "";
position: absolute;
inset: 0;
z-index: 1;
border-radius: 50%;
background: linear-gradient(115deg, transparent 35%, rgba(255, 255, 255, 0.35) 49%, transparent 63%);
background-size: 240% 100%;
mix-blend-mode: overlay;
animation: music-shimmer 5s linear infinite;
pointer-events: none;
}
.music-toggle span { position: relative; z-index: 2; }
.music-toggle:hover, .music-toggle:focus-visible {
transform: translateY(-2px) scale(1.06);
box-shadow: 0 16px 38px rgba(0, 0, 0, 0.5), 0 0 34px color-mix(in srgb, var(--music-c1, var(--c-accent)) 55%, transparent);
}
#vandiy-music-widget.open .music-toggle { transform: rotate(12deg); }
@keyframes music-aurora {
0% { transform: translate(-4%, -3%) rotate(0deg); }
100% { transform: translate(4%, 3%) rotate(8deg); }
}
@keyframes music-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -60% 0; }
}
@media (prefers-reduced-motion: reduce) { .music-toggle::before, .music-toggle::after { animation: none; } }
.music-panel {
width: min(320px, calc(100vw - 2.4rem));
padding: 1rem 1.1rem 1.2rem;
border-radius: var(--radius-l);
border: 1px solid rgba(244, 241, 247, 0.14);
background: var(--c-anthracite-light);
box-shadow: var(--shadow-card);
animation: music-panel-in 0.22s ease-out;
}
.music-panel[hidden] { display: none; }
@keyframes music-panel-in { from { opacity: 0; transform: translateY(10px) scale(0.97); } to { opacity: 1; transform: none; } }
.music-panel-head { display: flex; align-items: center; justify-content: space-between; gap: 0.6rem; }
.music-panel-intro { margin: 0.4rem 0 0.7rem; color: var(--c-text-muted); }
.music-close { background: none; border: 0; color: var(--c-text-muted); font-size: 1rem; line-height: 1; padding: 0.2rem 0.3rem; cursor: pointer; border-radius: 6px; }
.music-close:hover { color: var(--c-accent); }
.music-panel iframe { display: block; width: 100%; border: 0; border-radius: 12px; }
@media (max-width: 500px) { #vandiy-music-widget { right: 0.7rem; bottom: 0.7rem; } }
@media print { #vandiy-music-widget { display: none !important; } }
/* Interne Bestellverwaltung (/verwaltung/) — Status-Kacheln, Filterleiste, Auswertungs-Balken. */
.verwaltung-stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 0.9rem; margin: 1.2rem 0; }