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).
This commit is contained in:
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -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<HTMLImageElement> {
|
||||||
|
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<Accent | null> {
|
||||||
|
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}))`
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,9 +1,150 @@
|
|||||||
@import 'tailwindcss';
|
@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 {
|
:root {
|
||||||
color-scheme: dark;
|
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 {
|
html {
|
||||||
scroll-behavior: smooth;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user