Merge e56020d086 into 393441faa1
This commit is contained in:
commit
a9f5b62c95
4 changed files with 233 additions and 9 deletions
108
public/theme.js
Normal file
108
public/theme.js
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
|
||||
(() => {
|
||||
const KEY = "convertx-theme";
|
||||
const root = document.documentElement;
|
||||
const mql = window.matchMedia?.("(prefers-color-scheme: dark)");
|
||||
|
||||
const getStoredTheme = () => {
|
||||
try {
|
||||
const v = localStorage.getItem(KEY);
|
||||
return v === "dark" || v === "light" ? v : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getSystemTheme = () => (mql && mql.matches ? "dark" : "light");
|
||||
|
||||
const applyColorSchemeOnly = (theme) => {
|
||||
// Hint to the browser for built-in UI (form controls, scrollbars, etc.)
|
||||
root.style.colorScheme = theme === "dark" ? "dark" : "light";
|
||||
};
|
||||
|
||||
const setTheme = (theme, { persist } = { persist: true }) => {
|
||||
if (theme !== "dark" && theme !== "light") return;
|
||||
|
||||
root.setAttribute("data-theme", theme);
|
||||
applyColorSchemeOnly(theme);
|
||||
|
||||
if (persist) {
|
||||
try {
|
||||
localStorage.setItem(KEY, theme);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const clearThemeOverride = () => {
|
||||
root.removeAttribute("data-theme");
|
||||
try {
|
||||
localStorage.removeItem(KEY);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
// Important: even in auto-mode we still set color-scheme to system theme.
|
||||
applyColorSchemeOnly(getSystemTheme());
|
||||
};
|
||||
|
||||
const getEffectiveTheme = () => getStoredTheme() ?? getSystemTheme();
|
||||
|
||||
const syncUI = () => {
|
||||
const checkbox = document.getElementById("cx-theme-switch");
|
||||
const label = document.getElementById("cx-theme-label");
|
||||
if (!checkbox && !label) return;
|
||||
|
||||
const theme = getEffectiveTheme();
|
||||
|
||||
if (checkbox) {
|
||||
checkbox.checked = theme === "dark";
|
||||
checkbox.setAttribute("aria-checked", checkbox.checked ? "true" : "false");
|
||||
}
|
||||
|
||||
if (label) {
|
||||
label.textContent = theme === "dark" ? "Dark" : "Light";
|
||||
}
|
||||
};
|
||||
|
||||
// --- Initial state ---
|
||||
const stored = getStoredTheme();
|
||||
|
||||
if (stored) {
|
||||
// Explicit override: lock both tokens + native controls.
|
||||
setTheme(stored, { persist: false });
|
||||
} else {
|
||||
// Auto mode: follow OS, but ensure native controls match.
|
||||
applyColorSchemeOnly(getSystemTheme());
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
syncUI();
|
||||
|
||||
const checkbox = document.getElementById("cx-theme-switch");
|
||||
if (!checkbox) return;
|
||||
|
||||
checkbox.addEventListener("change", () => {
|
||||
setTheme(checkbox.checked ? "dark" : "light", { persist: true });
|
||||
syncUI();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
if (mql) {
|
||||
const onChange = () => {
|
||||
if (getStoredTheme() == null) {
|
||||
// Keep auto: no data-theme, but update color-scheme + UI.
|
||||
root.removeAttribute("data-theme");
|
||||
applyColorSchemeOnly(getSystemTheme());
|
||||
syncUI();
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof mql.addEventListener === "function") {
|
||||
mql.addEventListener("change", onChange);
|
||||
} else if (typeof mql.addListener === "function") {
|
||||
mql.addListener(onChange);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
|
@ -15,7 +15,33 @@ export const BaseHtml = ({
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="webroot" content={webroot} />
|
||||
<title safe>{title}</title>
|
||||
|
||||
{/*
|
||||
Apply the persisted theme as early as possible to avoid a flash.
|
||||
- If no preference is stored, tokens fall back to OS preference via CSS.
|
||||
*/}
|
||||
<script>
|
||||
{`
|
||||
(() => {
|
||||
const KEY = 'convertx-theme';
|
||||
try {
|
||||
const v = localStorage.getItem(KEY);
|
||||
if (v === 'dark' || v === 'light') {
|
||||
document.documentElement.setAttribute('data-theme', v);
|
||||
document.documentElement.style.colorScheme = v;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})();
|
||||
`}
|
||||
</script>
|
||||
|
||||
<link rel="stylesheet" href={`${webroot}/generated.css`} />
|
||||
|
||||
{/* Theme toggle behavior (syncs the header switch + persists choice). */}
|
||||
<script src={`${webroot}/theme.js`} defer />
|
||||
|
||||
<link rel="apple-touch-icon" sizes="180x180" href={`${webroot}/apple-touch-icon.png`} />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href={`${webroot}/favicon-32x32.png`} />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href={`${webroot}/favicon-16x16.png`} />
|
||||
|
|
|
|||
|
|
@ -11,10 +11,40 @@ export const Header = ({
|
|||
hideHistory?: boolean;
|
||||
webroot?: string;
|
||||
}) => {
|
||||
const themeToggle = (
|
||||
<li class="flex items-center gap-2">
|
||||
<span id="cx-theme-label" class="text-sm font-medium text-neutral-200">
|
||||
Dark
|
||||
</span>
|
||||
<label class="relative inline-flex cursor-pointer items-center" title="Toggle theme">
|
||||
<input
|
||||
id="cx-theme-switch"
|
||||
type="checkbox"
|
||||
class="sr-only peer"
|
||||
aria-label="Toggle dark mode"
|
||||
/>
|
||||
<div
|
||||
class={
|
||||
`
|
||||
relative h-6 w-11 rounded-full bg-neutral-700
|
||||
transition-colors
|
||||
peer-checked:bg-blue-600
|
||||
peer-focus-visible:outline-none peer-focus-visible:ring-2 peer-focus-visible:ring-blue-500
|
||||
after:content-[''] after:absolute after:top-[2px] after:left-[2px]
|
||||
after:h-5 after:w-5 after:rounded-full after:bg-white after:border after:border-neutral-500 after:shadow-sm after:transition-transform
|
||||
peer-checked:after:translate-x-5
|
||||
`
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</li>
|
||||
);
|
||||
|
||||
let rightNav: JSX.Element;
|
||||
if (loggedIn) {
|
||||
rightNav = (
|
||||
<ul class="flex gap-4">
|
||||
<ul class="flex items-center gap-6">
|
||||
{themeToggle}
|
||||
{!hideHistory && (
|
||||
<li>
|
||||
<a
|
||||
|
|
@ -58,7 +88,8 @@ export const Header = ({
|
|||
);
|
||||
} else {
|
||||
rightNav = (
|
||||
<ul class="flex gap-4">
|
||||
<ul class="flex items-center gap-6">
|
||||
{themeToggle}
|
||||
<li>
|
||||
<a
|
||||
class={`
|
||||
|
|
|
|||
|
|
@ -1,6 +1,16 @@
|
|||
/*
|
||||
ConvertX theme tokens
|
||||
|
||||
Strategy:
|
||||
- Default (no explicit override): follow OS preference via prefers-color-scheme.
|
||||
- Manual override: set <html data-theme="light|dark">.
|
||||
- JS sets/clears data-theme based on localStorage (see /public/theme.js).
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* Light mode */
|
||||
/* Light mode (default tokens) */
|
||||
--contrast: oklch(100% 0 0);
|
||||
|
||||
/* Neutral colors - Gray */
|
||||
--neutral-950: oklch(98.5% 0.002 247.839);
|
||||
--neutral-900: oklch(96.7% 0.003 264.542);
|
||||
|
|
@ -13,6 +23,8 @@
|
|||
--neutral-200: oklch(26.9% 0 0);
|
||||
--neutral-100: oklch(21% 0.034 264.665);
|
||||
--neutral-50: oklch(13% 0.028 261.692);
|
||||
|
||||
/* Accent (lime) */
|
||||
/* lime-700 */
|
||||
--accent-600: oklch(53.2% 0.157 131.589);
|
||||
/* lime-600 */
|
||||
|
|
@ -21,11 +33,60 @@
|
|||
--accent-400: oklch(76.8% 0.233 130.85);
|
||||
}
|
||||
|
||||
/* Manual override: dark */
|
||||
:root[data-theme="dark"] {
|
||||
--contrast: oklch(0% 0 0);
|
||||
|
||||
/* Neutral colors - Gray */
|
||||
--neutral-950: oklch(13% 0.028 261.692);
|
||||
--neutral-900: oklch(21% 0.034 264.665);
|
||||
--neutral-800: oklch(27.8% 0.033 256.848);
|
||||
--neutral-700: oklch(37.3% 0.034 259.733);
|
||||
--neutral-600: oklch(44.6% 0.03 256.802);
|
||||
--neutral-500: oklch(55.1% 0.027 264.364);
|
||||
--neutral-400: oklch(70.7% 0.022 261.325);
|
||||
--neutral-300: oklch(87.2% 0.01 258.338);
|
||||
--neutral-200: oklch(92.8% 0.006 264.531);
|
||||
--neutral-100: oklch(96.7% 0.003 264.542);
|
||||
--neutral-50: oklch(98.5% 0.002 247.839);
|
||||
|
||||
/* Accent (lime) */
|
||||
/* lime-600 */
|
||||
--accent-600: oklch(64.8% 0.2 131.684);
|
||||
/* lime-500 */
|
||||
--accent-500: oklch(76.8% 0.233 130.85);
|
||||
/* lime-400 */
|
||||
--accent-400: oklch(84.1% 0.238 128.85);
|
||||
}
|
||||
|
||||
/* Manual override: light (kept explicit for completeness) */
|
||||
:root[data-theme="light"] {
|
||||
--contrast: oklch(100% 0 0);
|
||||
|
||||
/* Neutral colors - Gray */
|
||||
--neutral-950: oklch(98.5% 0.002 247.839);
|
||||
--neutral-900: oklch(96.7% 0.003 264.542);
|
||||
--neutral-800: oklch(92.8% 0.006 264.531);
|
||||
--neutral-700: oklch(87.2% 0.01 258.338);
|
||||
--neutral-600: oklch(70.7% 0.022 261.325);
|
||||
--neutral-500: oklch(55.1% 0.027 264.364);
|
||||
--neutral-400: oklch(44.6% 0.03 256.802);
|
||||
--neutral-300: oklch(37.3% 0.034 259.733);
|
||||
--neutral-200: oklch(26.9% 0 0);
|
||||
--neutral-100: oklch(21% 0.034 264.665);
|
||||
--neutral-50: oklch(13% 0.028 261.692);
|
||||
|
||||
/* Accent (lime) */
|
||||
--accent-600: oklch(53.2% 0.157 131.589);
|
||||
--accent-500: oklch(64.8% 0.2 131.684);
|
||||
--accent-400: oklch(76.8% 0.233 130.85);
|
||||
}
|
||||
|
||||
/* Default behavior (no manual override): follow OS preference */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
/* Dark mode */
|
||||
:root {
|
||||
:root:not([data-theme]) {
|
||||
--contrast: oklch(0% 0 0);
|
||||
/* Neutral colors - Gray */
|
||||
|
||||
--neutral-950: oklch(13% 0.028 261.692);
|
||||
--neutral-900: oklch(21% 0.034 264.665);
|
||||
--neutral-800: oklch(27.8% 0.033 256.848);
|
||||
|
|
@ -37,11 +98,9 @@
|
|||
--neutral-200: oklch(92.8% 0.006 264.531);
|
||||
--neutral-100: oklch(96.7% 0.003 264.542);
|
||||
--neutral-50: oklch(98.5% 0.002 247.839);
|
||||
/* lime-600 */
|
||||
|
||||
--accent-600: oklch(64.8% 0.2 131.684);
|
||||
/* lime-500 */
|
||||
--accent-500: oklch(76.8% 0.233 130.85);
|
||||
/* lime-400 */
|
||||
--accent-400: oklch(84.1% 0.238 128.85);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue