Skip to content
Merged
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
151 changes: 143 additions & 8 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,37 @@
"Helvetica Neue", Arial, sans-serif;
}

@theme inline {
--color-page: var(--surface-page);
--color-surface: var(--surface-panel);
--color-surface-muted: var(--surface-muted);
--color-foreground: var(--text-primary);
--color-muted: var(--text-muted);
--color-subtle: var(--border-subtle);
}

:root {
color-scheme: light;
--surface-page: #f8fafc;
--surface-panel: #ffffff;
--surface-muted: #f1f5f9;
--text-primary: #0f172a;
--text-muted: #64748b;
--border-subtle: #e2e8f0;
--focus-ring: color-mix(in srgb, var(--color-brand-500) 30%, transparent);
}

:root[data-color-mode="dark"] {
color-scheme: dark;
--surface-page: #070d19;
--surface-panel: #0f172a;
--surface-muted: #172033;
--text-primary: #f8fafc;
--text-muted: #94a3b8;
--border-subtle: #263449;
--focus-ring: color-mix(in srgb, var(--color-brand-400) 45%, transparent);
}

/* ── Themes ──────────────────────────────────────────────────────────────────
Each block overrides the brand scale; selected via data-theme on <html>
(Admin → Settings → Theme). Register new themes in src/lib/themes.ts. */
Expand Down Expand Up @@ -85,12 +116,15 @@
}

body {
@apply bg-slate-50 text-slate-900 antialiased;
@apply bg-page text-foreground antialiased;
transition:
background-color 150ms ease,
color 150ms ease;
}

@layer components {
.card {
@apply rounded-xl border border-slate-200 bg-white shadow-sm;
@apply bg-surface border-subtle rounded-xl border shadow-sm;
}

.btn-primary,
Expand All @@ -104,19 +138,20 @@ body {
}

.btn-secondary {
@apply border border-slate-300 bg-white text-slate-700 hover:bg-slate-50;
@apply border-subtle bg-surface text-foreground hover:bg-surface-muted border;
}

.btn-danger {
@apply bg-red-600 text-white hover:bg-red-700;
}

.input {
@apply block w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:border-brand-500 focus:outline-2 focus:outline-brand-500/30;
@apply border-subtle bg-surface text-foreground placeholder:text-muted focus:border-brand-500 block w-full rounded-lg border px-3 py-2 text-sm focus:outline-2;
outline-color: var(--focus-ring);
}

.label {
@apply mb-1.5 block text-sm font-medium text-slate-700;
@apply text-foreground mb-1.5 block text-sm font-medium;
}

.badge {
Expand All @@ -128,14 +163,114 @@ body {
}

.table-base thead th {
@apply border-b border-slate-200 px-4 py-3 text-xs font-semibold tracking-wide text-slate-500 uppercase;
@apply border-subtle text-muted border-b px-4 py-3 text-xs font-semibold tracking-wide uppercase;
}

.table-base tbody td {
@apply border-b border-slate-100 px-4 py-3 text-slate-700;
@apply border-subtle text-foreground border-b px-4 py-3;
}

.table-base tbody tr:hover {
@apply bg-slate-50;
@apply bg-surface-muted;
}
}

/* Compatibility while page-level markup migrates to the semantic tokens. */
:root[data-color-mode="dark"] .bg-white {
background-color: var(--surface-panel);
}

:root[data-color-mode="dark"] .bg-slate-50 {
background-color: var(--surface-page);
}

:root[data-color-mode="dark"] .bg-slate-100,
:root[data-color-mode="dark"] [class~="hover:bg-slate-50"]:hover,
:root[data-color-mode="dark"] [class~="hover:bg-slate-100"]:hover {
background-color: var(--surface-muted);
}

:root[data-color-mode="dark"] .text-slate-900,
:root[data-color-mode="dark"] .text-slate-800,
:root[data-color-mode="dark"] .text-slate-700,
:root[data-color-mode="dark"] .text-slate-600 {
color: var(--text-primary);
}

:root[data-color-mode="dark"] .text-slate-500,
:root[data-color-mode="dark"] .text-slate-400 {
color: var(--text-muted);
}

:root[data-color-mode="dark"] .border-slate-100,
:root[data-color-mode="dark"] .border-slate-200,
:root[data-color-mode="dark"] .border-slate-300,
:root[data-color-mode="dark"] .divide-slate-100 > :not(:last-child),
:root[data-color-mode="dark"] .divide-slate-200 > :not(:last-child) {
border-color: var(--border-subtle);
}

:root[data-color-mode="dark"] [class~="bg-brand-50"],
:root[data-color-mode="dark"] [class~="bg-brand-50/50"],
:root[data-color-mode="dark"] [class~="bg-brand-100"] {
background-color: color-mix(
in srgb,
var(--color-brand-500) 18%,
var(--surface-panel)
);
}

:root[data-color-mode="dark"] [class~="bg-green-50"],
:root[data-color-mode="dark"] [class~="bg-green-100"] {
background-color: color-mix(in srgb, #22c55e 16%, var(--surface-panel));
}

:root[data-color-mode="dark"] [class~="bg-red-50"],
:root[data-color-mode="dark"] [class~="bg-red-100"] {
background-color: color-mix(in srgb, #ef4444 16%, var(--surface-panel));
}

:root[data-color-mode="dark"] [class~="bg-amber-50"],
:root[data-color-mode="dark"] [class~="bg-amber-100"] {
background-color: color-mix(in srgb, #f59e0b 16%, var(--surface-panel));
}

:root[data-color-mode="dark"] [class~="bg-blue-50"],
:root[data-color-mode="dark"] [class~="bg-blue-100"] {
background-color: color-mix(in srgb, #3b82f6 16%, var(--surface-panel));
}

:root[data-color-mode="dark"] .text-green-600,
:root[data-color-mode="dark"] .text-green-700 {
color: #86efac;
}

:root[data-color-mode="dark"] .text-red-600,
:root[data-color-mode="dark"] .text-red-700 {
color: #fca5a5;
}

:root[data-color-mode="dark"] .text-amber-700,
:root[data-color-mode="dark"] .text-amber-800 {
color: #fcd34d;
}

:root[data-color-mode="dark"] .text-blue-700,
:root[data-color-mode="dark"] .text-blue-800 {
color: #93c5fd;
}

@media print {
:root[data-color-mode="dark"] {
--surface-page: #ffffff;
--surface-panel: #ffffff;
--surface-muted: #f1f5f9;
--text-primary: #0f172a;
--text-muted: #64748b;
--border-subtle: #e2e8f0;
}

.color-mode-toggle {
display: none;
}
}
19 changes: 17 additions & 2 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import "./globals.css";
import { getSetting } from "@/lib/settings";
import { getLocale } from "@/lib/i18n";
import { isTheme } from "@/lib/themes";
import { ColorModeToggle } from "@/components/color-mode-toggle";

// Every page reads live data (settings, catalog, session), so nothing is
// statically prerendered — this keeps `next build` from needing a database.
Expand All @@ -25,8 +26,22 @@ export default async function RootLayout({
getLocale(),
]);
return (
<html lang={locale} data-theme={isTheme(theme) ? theme : "indigo"}>
<body>{children}</body>
<html
lang={locale}
data-theme={isTheme(theme) ? theme : "indigo"}
suppressHydrationWarning
>
<head>
<script
dangerouslySetInnerHTML={{
__html: `(function(){try{var p=localStorage.getItem("openhosting-color-mode");if(p!=="light"&&p!=="dark")p="system";var m=p==="system"?(matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"):p;document.documentElement.dataset.colorMode=m;document.documentElement.dataset.colorModePreference=p;document.documentElement.style.colorScheme=m}catch(e){}})();`,
}}
/>
</head>
<body>
{children}
<ColorModeToggle />
</body>
</html>
);
}
75 changes: 75 additions & 0 deletions src/components/color-mode-toggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"use client";

import { useEffect, useState } from "react";
import {
COLOR_MODE_STORAGE_KEY,
isColorModePreference,
resolveColorMode,
type ColorModePreference,
} from "@/lib/color-mode";

const LABELS: Record<ColorModePreference, string> = {
system: "System",
light: "Light",
dark: "Dark",
};

function applyPreference(preference: ColorModePreference): void {
const systemPrefersDark = window.matchMedia(
"(prefers-color-scheme: dark)",
).matches;
const resolved = resolveColorMode(preference, systemPrefersDark);
document.documentElement.dataset.colorMode = resolved;
document.documentElement.dataset.colorModePreference = preference;
document.documentElement.style.colorScheme = resolved;
}

export function ColorModeToggle() {
const [preference, setPreference] =
useState<ColorModePreference>("system");

useEffect(() => {
const saved = window.localStorage.getItem(COLOR_MODE_STORAGE_KEY);
const initial = isColorModePreference(saved) ? saved : "system";
setPreference(initial);
applyPreference(initial);

const media = window.matchMedia("(prefers-color-scheme: dark)");
const onSystemChange = () => {
if (
document.documentElement.dataset.colorModePreference === "system"
) {
applyPreference("system");
}
};
media.addEventListener("change", onSystemChange);
return () => media.removeEventListener("change", onSystemChange);
}, []);

function updatePreference(next: ColorModePreference): void {
setPreference(next);
window.localStorage.setItem(COLOR_MODE_STORAGE_KEY, next);
applyPreference(next);
}

return (
<label className="color-mode-toggle border-subtle bg-surface text-muted fixed right-4 bottom-4 z-50 flex items-center gap-2 rounded-full border px-3 py-2 text-xs shadow-lg">
<span aria-hidden="true">◐</span>
<span className="sr-only">Color mode</span>
<select
aria-label="Color mode"
className="text-foreground cursor-pointer bg-transparent font-medium outline-none"
value={preference}
onChange={(event) =>
updatePreference(event.target.value as ColorModePreference)
}
>
{Object.entries(LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</label>
);
}
23 changes: 23 additions & 0 deletions src/lib/color-mode.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { describe, expect, test } from "vitest";
import {
isColorModePreference,
resolveColorMode,
} from "@/lib/color-mode";

describe("color mode", () => {
test("uses the operating-system preference in system mode", () => {
expect(resolveColorMode("system", true)).toBe("dark");
expect(resolveColorMode("system", false)).toBe("light");
});

test("keeps an explicit user override", () => {
expect(resolveColorMode("light", true)).toBe("light");
expect(resolveColorMode("dark", false)).toBe("dark");
});

test("rejects invalid persisted values", () => {
expect(isColorModePreference("system")).toBe(true);
expect(isColorModePreference("sepia")).toBe(false);
expect(isColorModePreference(null)).toBe(false);
});
});
23 changes: 23 additions & 0 deletions src/lib/color-mode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
export const COLOR_MODE_STORAGE_KEY = "openhosting-color-mode";

export const COLOR_MODE_PREFERENCES = ["system", "light", "dark"] as const;

export type ColorModePreference = (typeof COLOR_MODE_PREFERENCES)[number];
export type ResolvedColorMode = Exclude<ColorModePreference, "system">;

export function isColorModePreference(
value: string | null,
): value is ColorModePreference {
return COLOR_MODE_PREFERENCES.some((preference) => preference === value);
}

export function resolveColorMode(
preference: ColorModePreference,
systemPrefersDark: boolean,
): ResolvedColorMode {
return preference === "system"
? systemPrefersDark
? "dark"
: "light"
: preference;
}
Loading