diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4f0892a..4d178b4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -50,6 +50,7 @@ import Header from "./components/Header"; import NavSidebar from "./components/NavSidebar"; import Sidebar from "./components/Sidebar"; import BackToTop from "./components/BackToTop"; +import GlassTuner from "./components/GlassTuner"; import ChatDock from "./components/ChatDock"; import Toaster from "./components/Toaster"; import ErrorDialog from "./components/ErrorDialog"; @@ -903,6 +904,9 @@ export default function App() { {/* Re-binds to the active page's scroll container on navigation (page or channel change). */} + {/* DEV-ONLY live appearance tuner (localhost-gated; self-returns null on prod). Delete when + the glass-consistency epic bakes the final values into index.css. */} + ); } diff --git a/frontend/src/components/GlassTuner.tsx b/frontend/src/components/GlassTuner.tsx new file mode 100644 index 0000000..a51a99e --- /dev/null +++ b/frontend/src/components/GlassTuner.tsx @@ -0,0 +1,374 @@ +import { useEffect, useMemo, useState } from "react"; +import MosaicBackdrop from "./MosaicBackdrop"; + +/** + * DEV-ONLY live appearance tuner. Mounted at the App root so it stays reachable on every page. + * It writes the glass CSS custom properties (index.css `:root`) inline on for instant, + * build-free feedback, and persists to localStorage so tweaks survive an F5 during UAT. + * + * Gated to localhost/127.0.0.1 so it renders on the :8080 UAT build and :5173 HMR but NEVER on + * prod (siftlode.b1fr0st.eu). It is a scaffold for the glass-consistency epic: dial in values + * here, hit "Copy CSS", hand them over to bake into index.css — then this component is deleted. + */ +const DEV_TUNER = + typeof window !== "undefined" && /^(localhost|127\.0\.0\.1)$/.test(window.location.hostname); + +const LS_KEY = "siftlode.devGlassTuner"; + +type Slider = { + k: string; + label: string; + min: number; + max: number; + step: number; + unit: string; + def: number; +}; + +const SLIDERS: Slider[] = [ + { k: "--glass-blur", label: "Blur radius", min: 0, max: 40, step: 1, unit: "px", def: 30 }, + { k: "--glass-saturate", label: "Saturation", min: 1, max: 2.5, step: 0.05, unit: "", def: 1.8 }, + { k: "--glass-dark-bright", label: "Dark brightness", min: 1, max: 2, step: 0.05, unit: "", def: 1.4 }, + { k: "--glass-surface-alpha", label: "Panel opacity", min: 40, max: 100, step: 1, unit: "%", def: 78 }, + { k: "--glass-card-alpha", label: "Card opacity", min: 40, max: 100, step: 1, unit: "%", def: 84 }, + { k: "--glass-menu-alpha", label: "Menu opacity", min: 60, max: 100, step: 1, unit: "%", def: 92 }, + { k: "--glass-border-alpha", label: "Border opacity", min: 20, max: 100, step: 1, unit: "%", def: 78 }, + { k: "--glass-edge", label: "Edge-light rim", min: 0, max: 60, step: 1, unit: "%", def: 0 }, + { k: "--glass-inset", label: "Top highlight", min: 0, max: 40, step: 1, unit: "%", def: 16 }, + { k: "--glass-scrim", label: "Under-text scrim", min: 0, max: 85, step: 1, unit: "%", def: 0 }, + { k: "--ambient", label: "Ambient intensity", min: 0, max: 2, step: 0.05, unit: "", def: 1 }, +]; + +const MOSAIC_SLIDERS: Slider[] = [ + { k: "--mosaic-blur", label: "Mosaic blur", min: 20, max: 100, step: 1, unit: "px", def: 64 }, + { k: "--mosaic-sat", label: "Mosaic saturation", min: 0, max: 1.5, step: 0.05, unit: "", def: 0.7 }, + { k: "--mosaic-bright", label: "Mosaic brightness", min: 0.2, max: 1, step: 0.05, unit: "", def: 0.5 }, + { k: "--mosaic-opacity", label: "Mosaic opacity", min: 0, max: 1, step: 0.05, unit: "", def: 0.5 }, +]; + +const PALETTE: { k: string; label: string }[] = [ + { k: "--bg", label: "Background" }, + { k: "--surface", label: "Surface" }, + { k: "--card", label: "Card" }, + { k: "--border", label: "Border" }, + { k: "--fg", label: "Text" }, + { k: "--muted", label: "Muted" }, + { k: "--accent", label: "Accent" }, +]; + +// Each preset resets every slider to its default, then applies these overrides. +const PRESETS: { id: string; name: string; hint: string; over: Record }[] = [ + { id: "current", name: "Current", hint: "The shipped defaults", over: {} }, + { + id: "adaptive", + name: "Adaptive (rec.)", + hint: "Solid-enough dark glass", + over: { + "--glass-surface-alpha": 94, + "--glass-card-alpha": 94, + "--glass-menu-alpha": 98, + "--glass-scrim": 14, + "--glass-edge": 10, + "--ambient": 1.1, + "--glass-dark-bright": 1.25, + "--glass-blur": 20, + }, + }, + { id: "gradient", name: "Gradient+", hint: "Stronger accent pools", over: { "--ambient": 1.6, "--glass-scrim": 6 } }, + { + id: "wcag", + name: "Edge + scrim", + hint: "Max readability", + over: { "--glass-edge": 42, "--glass-scrim": 45, "--glass-surface-alpha": 84, "--glass-card-alpha": 90 }, + }, +]; + +const ALL = [...SLIDERS, ...MOSAIC_SLIDERS]; +const root = () => document.documentElement; +const readVar = (k: string) => getComputedStyle(root()).getPropertyValue(k).trim(); + +type Saved = { + vars?: Record; + palette?: Record; + mosaic?: boolean; + open?: boolean; +}; + +function loadSaved(): Saved { + try { + return JSON.parse(localStorage.getItem(LS_KEY) || "{}") as Saved; + } catch { + return {}; + } +} + +export default function GlassTuner() { + const initial = useMemo(loadSaved, []); + const [vars, setVars] = useState>(() => { + const base: Record = {}; + ALL.forEach((s) => (base[s.k] = s.def)); + return { ...base, ...(initial.vars || {}) }; + }); + const [palette, setPalette] = useState>(() => initial.palette || {}); + const [mosaic, setMosaic] = useState(() => Boolean(initial.mosaic)); + const [open, setOpen] = useState(() => initial.open ?? true); + const [tab, setTab] = useState<"glass" | "palette">("glass"); + const [copied, setCopied] = useState(false); + // Seed the colour pickers from the live scheme for display (without forcing them onto ). + const [swatches, setSwatches] = useState>({}); + + // Apply saved var overrides (and any saved palette edits) once on mount. + useEffect(() => { + Object.entries(initial.vars || {}).forEach(([k, v]) => { + const unit = ALL.find((s) => s.k === k)?.unit ?? ""; + root().style.setProperty(k, v + unit); + }); + Object.entries(initial.palette || {}).forEach(([k, v]) => root().style.setProperty(k, v)); + const sw: Record = {}; + PALETTE.forEach((p) => { + const v = (initial.palette?.[p.k] || readVar(p.k)).trim(); + sw[p.k] = /^#[0-9a-fA-F]{6}$/.test(v) ? v : "#000000"; + }); + setSwatches(sw); + }, [initial]); + + function persist(next: Partial) { + const cur = loadSaved(); + localStorage.setItem(LS_KEY, JSON.stringify({ ...cur, ...next })); + } + + function setVar(k: string, value: number) { + const unit = ALL.find((s) => s.k === k)?.unit ?? ""; + root().style.setProperty(k, value + unit); + setVars((prev) => { + const next = { ...prev, [k]: value }; + persist({ vars: next }); + return next; + }); + } + + function setColor(k: string, hex: string) { + root().style.setProperty(k, hex); + setSwatches((s) => ({ ...s, [k]: hex })); + setPalette((prev) => { + const next = { ...prev, [k]: hex }; + persist({ palette: next }); + return next; + }); + } + + function applyPreset(over: Record) { + const next: Record = { ...vars }; + SLIDERS.forEach((s) => { + const v = over[s.k] ?? s.def; + next[s.k] = v; + root().style.setProperty(s.k, v + s.unit); + }); + setVars(next); + persist({ vars: next }); + } + + function toggleMosaic(v: boolean) { + setMosaic(v); + persist({ mosaic: v }); + } + + function reset() { + [...ALL, ...PALETTE].forEach((s) => root().style.removeProperty(s.k)); + const base: Record = {}; + ALL.forEach((s) => (base[s.k] = s.def)); + setVars(base); + setPalette({}); + toggleMosaic(false); + localStorage.removeItem(LS_KEY); + const sw: Record = {}; + PALETTE.forEach((p) => { + const v = readVar(p.k); + sw[p.k] = /^#[0-9a-fA-F]{6}$/.test(v) ? v : "#000000"; + }); + setSwatches(sw); + } + + // Export only what differs from the shipped defaults, ready to paste into index.css. + const cssOut = useMemo(() => { + const lines: string[] = []; + ALL.forEach((s) => { + if (vars[s.k] !== s.def) lines.push(` ${s.k}: ${vars[s.k]}${s.unit};`); + }); + PALETTE.forEach((p) => { + if (palette[p.k]) lines.push(` ${p.k}: ${palette[p.k]};`); + }); + return lines.length ? `:root {\n${lines.join("\n")}\n}` : "/* all values at defaults */"; + }, [vars, palette]); + + function copy() { + navigator.clipboard?.writeText(cssOut).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1200); + }); + } + + function persistOpen(v: boolean) { + setOpen(v); + persist({ open: v }); + } + + if (!DEV_TUNER) return null; + + return ( + <> + {mosaic && } +
+ {!open && ( + + )} + {open && ( +
+
+ ◐ Glass Tuner + dev + +
+ +
+ {/* Treatment presets */} +
+
Dark treatment
+
+ {PRESETS.map((p) => ( + + ))} +
+
+ + {/* Mosaic toggle */} + + + {/* Tab switch */} +
+ {(["glass", "palette"] as const).map((t) => ( + + ))} +
+ + {tab === "glass" && ( +
+ {SLIDERS.map((s) => ( + setVar(s.k, v)} /> + ))} + {mosaic && ( +
+ {MOSAIC_SLIDERS.map((s) => ( + setVar(s.k, v)} /> + ))} +
+ )} +
+ )} + + {tab === "palette" && ( +
+

+ Overrides the live scheme (inline on <html>). Reset clears these back to the scheme. +

+ {PALETTE.map((p) => ( + + ))} +
+ )} + + {/* Export */} +
+
Export → bake in
+
+ + +
+
+                  {cssOut}
+                
+
+
+
+ )} +
+ + ); +} + +function Ctl({ s, value, onChange }: { s: Slider; value: number; onChange: (v: number) => void }) { + return ( +
+ + onChange(Number(e.target.value))} + className="w-16 text-right text-[11px] tabular-nums rounded bg-card border border-border px-1.5 py-0.5 text-accent" + /> + onChange(Number(e.target.value))} + className="col-span-2 w-full accent-accent" + /> +
+ ); +} diff --git a/frontend/src/components/MosaicBackdrop.tsx b/frontend/src/components/MosaicBackdrop.tsx new file mode 100644 index 0000000..05b8c1e --- /dev/null +++ b/frontend/src/components/MosaicBackdrop.tsx @@ -0,0 +1,58 @@ +import { useEffect, useMemo, useState } from "react"; + +/** + * DEV-ONLY, OPT-IN backdrop: a heavily blurred + darkened + desaturated mosaic of the real + * thumbnails currently on screen, so dark "glass" surfaces have actual content to refract. + * Deliberately muted (see the --mosaic-* vars in index.css) — a soft texture, never a collage. + * + * Thumbnails are sampled straight from the rendered DOM (the feed/channel image tags), so it + * needs no data wiring and works on whatever page mounts it. Looks richest on the feed; on a + * thumbnail-less page it simply falls back to the plain background. + * + * Toggled by the GlassTuner. Not shipped to prod (the tuner that mounts it is localhost-gated). + */ +export default function MosaicBackdrop({ cols = 8, rows = 6 }: { cols?: number; rows?: number }) { + const [srcs, setSrcs] = useState([]); + + // Snapshot the on-screen thumbnails once per mount (toggling the tuner off/on re-samples on a + // content-rich page). Looks richest on the feed; elsewhere it just falls back to the plain bg. + useEffect(() => { + const found = new Set(); + document.querySelectorAll("img").forEach((img) => { + const s = img.currentSrc || img.src; + if (!s) return; + // YouTube thumbnails / channel avatars — skip our own tiny UI icons/data URIs. + if (/ytimg\.com|ggpht\.com|googleusercontent\.com/.test(s)) found.add(s); + }); + setSrcs([...found]); + }, []); + + useEffect(() => { + document.documentElement.dataset.mosaic = "1"; + return () => { + delete document.documentElement.dataset.mosaic; + }; + }, []); + + const tiles = useMemo(() => { + const n = cols * rows; + if (srcs.length === 0) return []; + return Array.from({ length: n }, (_, i) => srcs[i % srcs.length]); + }, [srcs, cols, rows]); + + return ( + + ); +} diff --git a/frontend/src/index.css b/frontend/src/index.css index d66cc3e..e735122 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -4,6 +4,29 @@ :root { --font-scale: 1.06; + + /* ===== Glass tunables — every value that materially drives the look lives here so it can be + tuned in ONE place (and live-driven by the dev GlassTuner during UAT). Defaults below equal + the historical hard-coded values, so nothing changes until a value is bumped. When the user + signs off on tuned values, bake the finals right here. ===== */ + --glass-blur: 30px; /* backdrop blur radius (2026 sweet spot is ~8–16px; 30 is heavy) */ + --glass-saturate: 1.8; /* backdrop saturation */ + --glass-dark-bright: 1.4; /* extra brightness lift applied to .glass/.glass-menu in dark mode only */ + --glass-surface-alpha: 78%; /* .glass panel translucency (higher = more solid = "adaptive") */ + --glass-card-alpha: 84%; /* .glass-card translucency */ + --glass-menu-alpha: 92%; /* .glass-menu translucency */ + --glass-border-alpha: 78%; /* .glass border opacity */ + --glass-inset: 16%; /* top specular highlight strength (#fff N%) */ + --glass-edge: 0%; /* extra edge-light mixed into the TOP border (#fff N%) — 0 = off */ + --glass-scrim: 0%; /* under-content scrim: darkens surfaces toward --bg for text contrast — 0 = off */ + --ambient: 1; /* multiplier on the ambient accent backdrop pools */ + + /* Optional muted thumbnail-mosaic backdrop (off unless mounts). Conservative + defaults: heavy blur + low saturation + darkened so it reads as subtle texture, never a rainbow. */ + --mosaic-blur: 64px; + --mosaic-sat: 0.7; + --mosaic-bright: 0.5; + --mosaic-opacity: 0.5; } /* Make native controls (date picker, scrollbars) follow the theme. */ @@ -26,9 +49,9 @@ body { subtle but a touch richer (three soft pools, incl. a bottom glow) now that the nav, header and dialogs are all frosted glass and refract it. */ background: - radial-gradient(1100px 620px at 10% -10%, color-mix(in srgb, var(--accent) 20%, transparent), transparent 60%), - radial-gradient(1000px 720px at 115% 10%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 55%), - radial-gradient(900px 640px at 50% 118%, color-mix(in srgb, var(--accent) 11%, transparent), transparent 60%), + radial-gradient(1100px 620px at 10% -10%, color-mix(in srgb, var(--accent) calc(20% * var(--ambient)), transparent), transparent 60%), + radial-gradient(1000px 720px at 115% 10%, color-mix(in srgb, var(--accent) calc(14% * var(--ambient)), transparent), transparent 55%), + radial-gradient(900px 640px at 50% 118%, color-mix(in srgb, var(--accent) calc(11% * var(--ambient)), transparent), transparent 60%), var(--bg); background-attachment: fixed; } @@ -37,34 +60,44 @@ body { .glass { /* Frosted overlay surface: translucent enough that the blurred backdrop shows through (the glassy look), opaque enough that the background isn't distracting. - The strong blur turns whatever is behind into soft colour, not a sharp image. */ - background: color-mix(in srgb, var(--surface) 78%, transparent); - backdrop-filter: blur(30px) saturate(1.8); - -webkit-backdrop-filter: blur(30px) saturate(1.8); - border: 1px solid color-mix(in srgb, var(--border) 78%, transparent); + The strong blur turns whatever is behind into soft colour, not a sharp image. + Layer 1 (top) = the optional scrim; layer 2 = the translucent surface tint. */ + background: + linear-gradient(color-mix(in srgb, var(--bg) var(--glass-scrim), transparent), color-mix(in srgb, var(--bg) var(--glass-scrim), transparent)), + color-mix(in srgb, var(--surface) var(--glass-surface-alpha), transparent); + backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); + -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); + border: 1px solid color-mix(in srgb, var(--border) var(--glass-border-alpha), transparent); + border-top-color: color-mix(in srgb, #fff var(--glass-edge), color-mix(in srgb, var(--border) var(--glass-border-alpha), transparent)); box-shadow: - inset 0 1px 0 color-mix(in srgb, #fff 16%, transparent), + inset 0 1px 0 color-mix(in srgb, #fff var(--glass-inset), transparent), 0 18px 44px -16px rgba(0, 0, 0, 0.6); } .glass-card { /* No backdrop-filter here: cards render in bulk (feed grid) over a solid background where blur adds almost nothing visually but is GPU-expensive and triggers reflow. Translucency + border + depth keep the look; blur is reserved for .glass overlays. */ - background: color-mix(in srgb, var(--card) 84%, transparent); + background: + linear-gradient(color-mix(in srgb, var(--bg) var(--glass-scrim), transparent), color-mix(in srgb, var(--bg) var(--glass-scrim), transparent)), + color-mix(in srgb, var(--card) var(--glass-card-alpha), transparent); border: 1px solid color-mix(in srgb, var(--border) 65%, transparent); + border-top-color: color-mix(in srgb, #fff var(--glass-edge), color-mix(in srgb, var(--border) 65%, transparent)); box-shadow: - inset 0 1px 0 color-mix(in srgb, #fff 8%, transparent), + inset 0 1px 0 color-mix(in srgb, #fff calc(var(--glass-inset) / 2), transparent), 0 8px 22px -14px rgba(0, 0, 0, 0.45); } .glass-menu { /* Floating menus/popovers hover over undimmed content (no backdrop scrim like dialogs), so they must be near-opaque to stay readable — only a hint of translucency + the blur. */ - background: color-mix(in srgb, var(--surface) 92%, transparent); - backdrop-filter: blur(30px) saturate(1.8); - -webkit-backdrop-filter: blur(30px) saturate(1.8); + background: + linear-gradient(color-mix(in srgb, var(--bg) var(--glass-scrim), transparent), color-mix(in srgb, var(--bg) var(--glass-scrim), transparent)), + color-mix(in srgb, var(--surface) var(--glass-menu-alpha), transparent); + backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); + -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); border: 1px solid color-mix(in srgb, var(--border) 80%, transparent); + border-top-color: color-mix(in srgb, #fff var(--glass-edge), color-mix(in srgb, var(--border) 80%, transparent)); box-shadow: - inset 0 1px 0 color-mix(in srgb, #fff 14%, transparent), + inset 0 1px 0 color-mix(in srgb, #fff calc(var(--glass-inset) - 2%), transparent), 0 18px 44px -16px rgba(0, 0, 0, 0.6); } .glass-hover:hover { @@ -80,9 +113,9 @@ body { (The full "videos behind glass everywhere" effect is Phase 2 / end-of-project polish.) */ html[data-theme="dark"] body { background: - radial-gradient(1100px 620px at 10% -10%, color-mix(in srgb, var(--accent) 34%, transparent), transparent 60%), - radial-gradient(1000px 720px at 115% 10%, color-mix(in srgb, var(--accent) 24%, transparent), transparent 55%), - radial-gradient(900px 640px at 50% 118%, color-mix(in srgb, var(--accent) 18%, transparent), transparent 60%), + radial-gradient(1100px 620px at 10% -10%, color-mix(in srgb, var(--accent) calc(34% * var(--ambient)), transparent), transparent 60%), + radial-gradient(1000px 720px at 115% 10%, color-mix(in srgb, var(--accent) calc(24% * var(--ambient)), transparent), transparent 55%), + radial-gradient(900px 640px at 50% 118%, color-mix(in srgb, var(--accent) calc(18% * var(--ambient)), transparent), transparent 60%), var(--bg); background-attachment: fixed; } @@ -90,8 +123,8 @@ html[data-theme="dark"] body { the frosted sheen shows even over dark UI (over colourful content it just looks richer). */ html[data-theme="dark"] .glass, html[data-theme="dark"] .glass-menu { - backdrop-filter: blur(30px) saturate(1.7) brightness(1.4); - -webkit-backdrop-filter: blur(30px) saturate(1.7) brightness(1.4); + backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)) brightness(var(--glass-dark-bright)); + -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)) brightness(var(--glass-dark-bright)); } /* Toasts surface bottom-left (by the nav's notification bell), off the conventional top-right; a brighter rim draws the eye there. Most needed in dark mode, where the @@ -100,6 +133,37 @@ html[data-theme="dark"] .toast-card { border-color: color-mix(in srgb, #fff 50%, transparent); } +/* ===== Optional muted thumbnail-mosaic backdrop (mounted by ) ===== + Gives dark glass real content to refract. Kept conservative: heavy blur + low saturation + + darkened, so it reads as a soft texture behind everything, never a colourful collage. When it + is present, the ambient accent pools on are dimmed so the two don't fight. */ +.mosaic-backdrop { + position: fixed; + inset: 0; + z-index: -1; + overflow: hidden; + pointer-events: none; +} +.mosaic-backdrop .mosaic-tiles { + position: absolute; + inset: -10%; + display: grid; + gap: 0; + filter: blur(var(--mosaic-blur)) saturate(var(--mosaic-sat)) brightness(var(--mosaic-bright)); + opacity: var(--mosaic-opacity); +} +.mosaic-backdrop .mosaic-tiles > span { + display: block; + background-size: cover; + background-position: center; +} +html[data-mosaic="1"] body { + background: var(--bg); +} +html[data-perf="1"] .mosaic-backdrop { + display: none; +} + /* Performance mode (Settings → Appearance) drops the expensive blur + soft shadows. */ html[data-perf="1"] .glass, html[data-perf="1"] .glass-menu,