Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
108 changes: 108 additions & 0 deletions public/theme.js
Original file line number Diff line number Diff line change
@@ -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);
}
}
})();
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);
}
}