238 lines
6.5 KiB
TypeScript
238 lines
6.5 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
|
|
/**
|
|
* Dev-only accent color switcher for the marketing site.
|
|
*
|
|
* Flips the homepage accent live by overriding the `--accent-h` (hue) and
|
|
* `--accent-cm` (chroma) CSS variables on `.new-site-wrapper`. Choice persists
|
|
* in localStorage so it survives reloads while you experiment.
|
|
*
|
|
* Visibility: shows only in development, or in any environment when the URL has
|
|
* `?theme`. So it's hidden in production by default — nothing to do to hide it.
|
|
* To remove entirely later, delete this file and its <AccentSwitcher /> usage
|
|
* in app/(marketing)/page.tsx.
|
|
*/
|
|
|
|
const PRESETS: { label: string; h: number; cm: number }[] = [
|
|
{ label: "Coral", h: 35, cm: 1 },
|
|
{ label: "Silver", h: 35, cm: 0 },
|
|
{ label: "Crimson", h: 25, cm: 1 },
|
|
{ label: "Amber", h: 75, cm: 1 },
|
|
{ label: "Emerald", h: 150, cm: 1 },
|
|
{ label: "Teal", h: 195, cm: 1 },
|
|
{ label: "Sky", h: 235, cm: 1 },
|
|
{ label: "Royal", h: 255, cm: 1 },
|
|
{ label: "Indigo", h: 270, cm: 1 },
|
|
{ label: "Violet", h: 300, cm: 1 },
|
|
{ label: "Pink", h: 350, cm: 1 },
|
|
];
|
|
|
|
const swatchColor = (h: number, cm: number) =>
|
|
`oklch(0.74 ${(0.175 * cm).toFixed(3)} ${h})`;
|
|
|
|
export default function AccentSwitcher() {
|
|
const [visible, setVisible] = useState(false);
|
|
const [open, setOpen] = useState(true);
|
|
const [h, setH] = useState(35);
|
|
const [cm, setCm] = useState(1);
|
|
|
|
useEffect(() => {
|
|
const isDev = process.env.NODE_ENV !== "production";
|
|
const forced =
|
|
typeof window !== "undefined" &&
|
|
new URLSearchParams(window.location.search).has("theme");
|
|
setVisible(isDev || forced);
|
|
try {
|
|
const saved = JSON.parse(localStorage.getItem("vibn:accent") || "null");
|
|
if (saved && typeof saved.h === "number") {
|
|
setH(saved.h);
|
|
setCm(typeof saved.cm === "number" ? saved.cm : 1);
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!visible) return;
|
|
const el = document.querySelector(".new-site-wrapper") as HTMLElement | null;
|
|
if (el) {
|
|
el.style.setProperty("--accent-h", String(h));
|
|
el.style.setProperty("--accent-cm", String(cm));
|
|
}
|
|
try {
|
|
localStorage.setItem("vibn:accent", JSON.stringify({ h, cm }));
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}, [visible, h, cm]);
|
|
|
|
if (!visible) return null;
|
|
|
|
const panel: React.CSSProperties = {
|
|
position: "fixed",
|
|
left: 16,
|
|
bottom: 16,
|
|
zIndex: 2147483647,
|
|
width: 232,
|
|
padding: 14,
|
|
borderRadius: 14,
|
|
background: "rgba(18,17,15,0.92)",
|
|
border: "1px solid rgba(255,255,255,0.12)",
|
|
backdropFilter: "blur(16px)",
|
|
boxShadow: "0 18px 50px rgba(0,0,0,0.5)",
|
|
color: "#f5f4f2",
|
|
fontFamily: "ui-monospace, 'SF Mono', Menlo, monospace",
|
|
fontSize: 11,
|
|
userSelect: "none",
|
|
};
|
|
|
|
if (!open) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen(true)}
|
|
title="Theme"
|
|
style={{
|
|
position: "fixed",
|
|
left: 16,
|
|
bottom: 16,
|
|
zIndex: 2147483647,
|
|
width: 38,
|
|
height: 38,
|
|
borderRadius: "50%",
|
|
border: "1px solid rgba(255,255,255,0.18)",
|
|
background: swatchColor(h, cm),
|
|
boxShadow: "0 8px 24px rgba(0,0,0,0.45)",
|
|
cursor: "pointer",
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div style={panel}>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
marginBottom: 10,
|
|
}}
|
|
>
|
|
<span style={{ letterSpacing: "0.1em", textTransform: "uppercase", opacity: 0.6 }}>
|
|
Accent · dev
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen(false)}
|
|
title="Collapse"
|
|
style={{
|
|
border: "none",
|
|
background: "transparent",
|
|
color: "#f5f4f2",
|
|
cursor: "pointer",
|
|
fontSize: 14,
|
|
lineHeight: 1,
|
|
opacity: 0.7,
|
|
}}
|
|
>
|
|
▾
|
|
</button>
|
|
</div>
|
|
|
|
<div
|
|
style={{
|
|
display: "grid",
|
|
gridTemplateColumns: "repeat(6, 1fr)",
|
|
gap: 6,
|
|
marginBottom: 12,
|
|
}}
|
|
>
|
|
{PRESETS.map((p) => {
|
|
const active = p.h === h && p.cm === cm;
|
|
return (
|
|
<button
|
|
key={p.label}
|
|
type="button"
|
|
title={p.label}
|
|
onClick={() => {
|
|
setH(p.h);
|
|
setCm(p.cm);
|
|
}}
|
|
style={{
|
|
width: "100%",
|
|
aspectRatio: "1 / 1",
|
|
borderRadius: 7,
|
|
border: active
|
|
? "2px solid #fff"
|
|
: "1px solid rgba(255,255,255,0.15)",
|
|
background: swatchColor(p.h, p.cm),
|
|
cursor: "pointer",
|
|
padding: 0,
|
|
}}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<label style={{ display: "block", marginBottom: 8 }}>
|
|
<span style={{ display: "flex", justifyContent: "space-between", opacity: 0.7 }}>
|
|
<span>Hue</span>
|
|
<span>{h}</span>
|
|
</span>
|
|
<input
|
|
type="range"
|
|
min={0}
|
|
max={360}
|
|
value={h}
|
|
onChange={(e) => setH(Number(e.target.value))}
|
|
style={{ width: "100%", accentColor: swatchColor(h, cm) }}
|
|
/>
|
|
</label>
|
|
|
|
<label style={{ display: "block", marginBottom: 10 }}>
|
|
<span style={{ display: "flex", justifyContent: "space-between", opacity: 0.7 }}>
|
|
<span>Vivid</span>
|
|
<span>{cm.toFixed(2)}</span>
|
|
</span>
|
|
<input
|
|
type="range"
|
|
min={0}
|
|
max={1}
|
|
step={0.05}
|
|
value={cm}
|
|
onChange={(e) => setCm(Number(e.target.value))}
|
|
style={{ width: "100%", accentColor: swatchColor(h, cm) }}
|
|
/>
|
|
</label>
|
|
|
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
|
<code style={{ opacity: 0.55, fontSize: 10 }}>
|
|
h:{h} cm:{cm}
|
|
</code>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setH(35);
|
|
setCm(1);
|
|
}}
|
|
style={{
|
|
border: "1px solid rgba(255,255,255,0.18)",
|
|
background: "transparent",
|
|
color: "#f5f4f2",
|
|
borderRadius: 6,
|
|
padding: "4px 8px",
|
|
cursor: "pointer",
|
|
fontSize: 10,
|
|
}}
|
|
>
|
|
Reset
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|