From 458c20335ea06ba5d6be91b18f1726e9614bf155 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Tue, 14 Jul 2026 04:54:45 +0200 Subject: [PATCH] feat(frontend): add theme tokens and dynamic accent palette Introduce theme.css design tokens, color/palette helpers, and global layout styles (scrollbars, animations, home gradient). --- frontend/src/lib/theme/color.ts | 20 +++++ frontend/src/lib/theme/palette.ts | 113 ++++++++++++++++++++++++ frontend/src/lib/theme/theme.css | 22 +++++ frontend/src/routes/layout.css | 141 ++++++++++++++++++++++++++++++ 4 files changed, 296 insertions(+) create mode 100644 frontend/src/lib/theme/color.ts create mode 100644 frontend/src/lib/theme/palette.ts create mode 100644 frontend/src/lib/theme/theme.css diff --git a/frontend/src/lib/theme/color.ts b/frontend/src/lib/theme/color.ts new file mode 100644 index 0000000..b7c33ac --- /dev/null +++ b/frontend/src/lib/theme/color.ts @@ -0,0 +1,20 @@ +export const HUES = [145, 330, 40, 152, 300, 200, 10, 120, 280]; + +export function accentForHue(hue: number): string { + return `oklch(72% 0.17 ${hue})`; +} + +export function accentSoft(hue: number, pct: number): string { + return `color-mix(in srgb, ${accentForHue(hue)} ${pct}%, transparent)`; +} + +export function gradientForHue(hue: number): string { + return `linear-gradient(135deg, oklch(70% 0.15 ${hue}), oklch(36% 0.13 ${hue + 28}))`; +} + +export function fmtTime(seconds: number): string { + const total = Math.max(0, Math.floor(seconds || 0)); + const minutes = Math.floor(total / 60); + const rest = total % 60; + return `${minutes}:${rest < 10 ? '0' : ''}${rest}`; +} diff --git a/frontend/src/lib/theme/palette.ts b/frontend/src/lib/theme/palette.ts new file mode 100644 index 0000000..75adb81 --- /dev/null +++ b/frontend/src/lib/theme/palette.ts @@ -0,0 +1,113 @@ +import { browser } from '$app/environment'; + +export interface Accent { + accent: string; + gradient: string; +} + +function srgbToLinear(channel: number): number { + const c = channel / 255; + return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); +} + +function rgbToOklch(r: number, g: number, b: number): [number, number, number] { + const lr = srgbToLinear(r); + const lg = srgbToLinear(g); + const lb = srgbToLinear(b); + + const l = 0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb; + const m = 0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb; + const s = 0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb; + + const l_ = Math.cbrt(l); + const m_ = Math.cbrt(m); + const s_ = Math.cbrt(s); + + const L = 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_; + const a = 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_; + const bb = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_; + + const C = Math.sqrt(a * a + bb * bb); + const H = (Math.atan2(bb, a) * 180) / Math.PI; + return [L, C, (H + 360) % 360]; +} + +function loadImage(url: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.onload = () => resolve(img); + img.onerror = reject; + img.src = url; + }); +} + +const BUCKETS = 36; + +export async function extractAccent(url: string): Promise { + if (!browser) return null; + + let img: HTMLImageElement; + try { + img = await loadImage(url); + } catch { + return null; + } + + const size = 40; + const canvas = document.createElement('canvas'); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + if (!ctx) return null; + + ctx.drawImage(img, 0, 0, size, size); + let pixels: Uint8ClampedArray; + try { + pixels = ctx.getImageData(0, 0, size, size).data; + } catch { + return null; + } + + const weight = new Array(BUCKETS).fill(0); + const hueSin = new Array(BUCKETS).fill(0); + const hueCos = new Array(BUCKETS).fill(0); + const chromaSum = new Array(BUCKETS).fill(0); + + for (let i = 0; i < pixels.length; i += 4) { + if (pixels[i + 3] < 128) continue; + const [L, C, H] = rgbToOklch(pixels[i], pixels[i + 1], pixels[i + 2]); + if (L < 0.2 || L > 0.92 || C < 0.03) continue; + + const bucket = Math.floor((H / 360) * BUCKETS) % BUCKETS; + const w = C * C; + const rad = (H * Math.PI) / 180; + weight[bucket] += w; + hueSin[bucket] += Math.sin(rad) * w; + hueCos[bucket] += Math.cos(rad) * w; + chromaSum[bucket] += C * w; + } + + let best = -1; + let bestWeight = 0; + for (let b = 0; b < BUCKETS; b++) { + if (weight[b] > bestWeight) { + bestWeight = weight[b]; + best = b; + } + } + if (best < 0) return null; + + const hue = ((Math.atan2(hueSin[best], hueCos[best]) * 180) / Math.PI + 360) % 360; + const chroma = Math.min(0.19, Math.max(0.1, chromaSum[best] / weight[best])); + + const h = hue.toFixed(1); + const c = chroma.toFixed(3); + const c2 = (chroma * 0.85).toFixed(3); + const h2 = ((hue + 25) % 360).toFixed(1); + + return { + accent: `oklch(72% ${c} ${h})`, + gradient: `linear-gradient(135deg, oklch(74% ${c} ${h}), oklch(56% ${c2} ${h2}))` + }; +} diff --git a/frontend/src/lib/theme/theme.css b/frontend/src/lib/theme/theme.css new file mode 100644 index 0000000..b237870 --- /dev/null +++ b/frontend/src/lib/theme/theme.css @@ -0,0 +1,22 @@ +/* Paleta de Musify — cambia los colores aquí y afecta a toda la app. + El acento (--mf-accent) es dinámico: lo fija el player según la pista sonando. */ +:root { + --mf-bg: #08080a; + --mf-elevated: #0b0b0d; + + --mf-text: #f5f4f2; + --mf-text-2: rgba(245, 244, 242, 0.7); + --mf-text-3: rgba(245, 244, 242, 0.5); + --mf-text-4: rgba(245, 244, 242, 0.4); + + --mf-surface: rgba(255, 255, 255, 0.045); + --mf-surface-2: rgba(255, 255, 255, 0.06); + --mf-surface-hover: rgba(255, 255, 255, 0.09); + + --mf-border: rgba(255, 255, 255, 0.08); + --mf-border-strong: rgba(255, 255, 255, 0.12); + --mf-track: rgba(255, 255, 255, 0.12); + + --mf-accent: oklch(72% 0.17 145); + --mf-radius: 12px; +} diff --git a/frontend/src/routes/layout.css b/frontend/src/routes/layout.css index ad122fe..c660c65 100644 --- a/frontend/src/routes/layout.css +++ b/frontend/src/routes/layout.css @@ -1,9 +1,150 @@ @import 'tailwindcss'; +@theme { + --font-sans: 'Manrope Variable', ui-sans-serif, system-ui, sans-serif; + --font-display: 'Sora Variable', ui-sans-serif, system-ui, sans-serif; + --color-neutral-950: #08080a; +} + +@utility scrollbar-stable { + scrollbar-gutter: stable; +} + :root { color-scheme: dark; } +button:not(:disabled), +[role='button'], +label:has(input[type='file']), +summary { + cursor: pointer; +} + +button:disabled { + cursor: not-allowed; +} + +@keyframes fade-up { + from { + opacity: 0; + transform: translateY(0.75rem); + } + to { + opacity: 1; + transform: none; + } +} + +@keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes pop-in { + from { + opacity: 0; + transform: scale(0.96) translateY(0.5rem); + } + to { + opacity: 1; + transform: none; + } +} + +@keyframes slide-in-left { + from { + transform: translateX(-100%); + } + to { + transform: translateX(0); + } +} + +@keyframes equalize { + 0%, + 100% { + transform: scaleY(0.35); + } + 50% { + transform: scaleY(1); + } +} + +@keyframes breathe { + 0%, + 100% { + opacity: 0.55; + transform: scale(1.2); + } + 50% { + opacity: 0.78; + transform: scale(1.32); + } +} + +@keyframes sheen { + from { + background-position: 200% 0; + } + to { + background-position: -200% 0; + } +} + +.animate-enter { + animation: fade-up 0.35s cubic-bezier(0.16, 1, 0.3, 1) both; +} + +.animate-pop { + animation: pop-in 0.3s cubic-bezier(0.16, 1, 0.3, 1) both; +} + +.animate-fade { + animation: fade-in 0.4s ease both; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.001ms !important; + animation-delay: 0ms !important; + transition-duration: 0.001ms !important; + scroll-behavior: auto !important; + } +} + html { scroll-behavior: smooth; + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.18) transparent; + background-color: var(--mf-bg); +} + +html.home { + background-image: linear-gradient(to bottom, var(--mf-accent), transparent 340px); + background-repeat: no-repeat; +} + +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-thumb { + background-color: rgba(255, 255, 255, 0.18); + border-radius: 9999px; +} + +::-webkit-scrollbar-thumb:hover { + background-color: rgba(255, 255, 255, 0.32); +} + +::-webkit-scrollbar-track { + background: transparent; }