Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions public/theme.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/*
* ConvertX Theme Toggle
*
* - Stores explicit user choice in localStorage under KEY.
* - If no explicit choice exists, the UI follows the OS preference
* (prefers-color-scheme) and does not set data-theme.
*/

(() => {
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 getEffectiveTheme = () => getStoredTheme() ?? getSystemTheme();

const setTheme = (theme, { persist } = { persist: true }) => {
if (theme !== "dark" && theme !== "light") return;

root.setAttribute("data-theme", theme);
// Hint to the browser for built-in UI (form controls, scrollbars, etc.)
root.style.colorScheme = theme;
Comment thread
Kosztyk marked this conversation as resolved.
Outdated

if (persist) {
try {
localStorage.setItem(KEY, theme);
} catch {
// ignore
}
}
};

const clearThemeOverride = () => {
root.removeAttribute("data-theme");
root.style.colorScheme = "";
try {
localStorage.removeItem(KEY);
} catch {
// ignore
}
};

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 ---
// If the user chose a theme before, enforce it.
const stored = getStoredTheme();
if (stored) {
setTheme(stored, { persist: false });
}

// Keep the toggle in sync once the DOM is available.
document.addEventListener("DOMContentLoaded", () => {
syncUI();

const checkbox = document.getElementById("cx-theme-switch");
if (!checkbox) return;

checkbox.addEventListener("change", () => {
// Explicit user choice always overrides system.
setTheme(checkbox.checked ? "dark" : "light", { persist: true });
syncUI();
});
});

// If there's no explicit override, reflect OS changes in the UI.
if (mql) {
const onChange = () => {
if (getStoredTheme() == null) {
clearThemeOverride();
syncUI();
}
};
// Safari uses addListener/removeListener
if (typeof mql.addEventListener === "function") {
mql.addEventListener("change", onChange);
} else if (typeof mql.addListener === "function") {
mql.addListener(onChange);
}
}
})();
26 changes: 26 additions & 0 deletions src/components/base.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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`} />
Expand Down
35 changes: 33 additions & 2 deletions src/components/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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={`
Expand Down
73 changes: 66 additions & 7 deletions src/theme/theme.css
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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 */
Expand All @@ -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);
Expand All @@ -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);
}
}