Baseline: full tree snapshot after git repository loss

The previous .git directory was found gutted (only info/ remained), so
history is unrecoverable locally. This commit restores version control
with the tree exactly as it stands: the working app, the preview-tracks
launcher on the artist hero, the scrollable-track-previews design bundle
(docs + approved mockups), and the Phase 0 validation artifacts.
This commit is contained in:
KeelrLeek committed 2026-07-22 17:57:00 -05:00
commit a667b4b28e
161 files changed
+53170

No files matched your search

+130
View File
@@ -0,0 +1,130 @@
// Dominant-color extraction from album art, tuned to produce a usable
// accent on a dark UI: prefers saturated mid-lightness pixels, then clamps
// the result into a safe saturation/lightness band.
export function hexToRgb(hex) {
const n = parseInt(hex.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
export function rgbToHex([r, g, b]) {
const c = (v) => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, "0");
return `#${c(r)}${c(g)}${c(b)}`;
}
export function lerpRgb(a, b, t) {
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
}
function rgbToHsl(r, g, b) {
r /= 255; g /= 255; b /= 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
const l = (max + min) / 2;
if (max === min) return [0, 0, l];
const d = max - min;
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
let h;
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
else if (max === g) h = ((b - r) / d + 2) / 6;
else h = ((r - g) / d + 4) / 6;
return [h, s, l];
}
function hslToRgb(h, s, l) {
if (s === 0) return [l * 255, l * 255, l * 255];
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
const f = (t) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
return [f(h + 1 / 3) * 255, f(h) * 255, f(h - 1 / 3) * 255];
}
/**
* Extract an accent color from an image data URL.
* Returns { accent, hover } hex strings, or null if nothing usable.
*/
export async function accentFromImage(dataUrl) {
const img = new Image();
img.src = dataUrl;
try {
await img.decode();
} catch {
return null;
}
const S = 48;
const canvas = document.createElement("canvas");
canvas.width = S;
canvas.height = S;
const ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0, S, S);
let data;
try {
data = ctx.getImageData(0, 0, S, S).data;
} catch {
return null;
}
// "True tone": the color FAMILY that covers the most of the artwork wins
// (chroma-weighted hue histogram), and the result keeps that family's
// real saturation/lightness character — a mossy cover yields moss, not
// neon. Legibility clamps are gentle floors, not vibrancy boosts.
const BINS = 24;
const binWeight = new Array(BINS).fill(0);
const samples = [];
const pixelCount = data.length / 4;
for (let i = 0; i < data.length; i += 4) {
const [h, s, l] = rgbToHsl(data[i], data[i + 1], data[i + 2]);
if (l < 0.08 || l > 0.95 || s < 0.1) continue;
const w = s * (1 - Math.abs(2 * l - 1)); // ~chroma
if (w <= 0) continue;
const bin = Math.floor(h * BINS) % BINS;
binWeight[bin] += w;
samples.push({ h, s, l, w, bin });
}
// Neutral guard: effectively grayscale art (or art with only a faint
// color cast) stays a deliberate silver, not an invented hue.
const totalWeight = samples.reduce((sum, p) => sum + p.w, 0);
if (!samples.length || totalWeight / pixelCount < 0.03) {
return {
accent: rgbToHex(hslToRgb(0, 0.05, 0.72)),
hover: rgbToHex(hslToRgb(0, 0.05, 0.8)),
};
}
// Smooth adjacent bins so a family split across a boundary still wins.
let bestBin = 0;
let bestScore = -1;
for (let i = 0; i < BINS; i++) {
const score = binWeight[i]
+ 0.5 * (binWeight[(i + 1) % BINS] + binWeight[(i + BINS - 1) % BINS]);
if (score > bestScore) { bestScore = score; bestBin = i; }
}
// Weighted circular mean over the winning family (bin ± 1).
let x = 0, y = 0, ws = 0, sSum = 0, lSum = 0;
for (const p of samples) {
const d = Math.min((p.bin - bestBin + BINS) % BINS, (bestBin - p.bin + BINS) % BINS);
if (d > 1) continue;
x += Math.cos(p.h * 2 * Math.PI) * p.w;
y += Math.sin(p.h * 2 * Math.PI) * p.w;
sSum += p.s * p.w;
lSum += p.l * p.w;
ws += p.w;
}
if (!ws) return null;
let h = Math.atan2(y, x) / (2 * Math.PI);
if (h < 0) h += 1;
const s = Math.min(0.88, Math.max(0.3, (sSum / ws) * 1.05));
const l = Math.min(0.66, Math.max(0.46, lSum / ws));
const accent = rgbToHex(hslToRgb(h, s, l));
const hover = rgbToHex(hslToRgb(h, s, Math.min(0.74, l + 0.08)));
return { accent, hover };
}