diff --git a/src/app/admin/theme/page.tsx b/src/app/admin/theme/page.tsx
index e0febe18..4d9f73b0 100644
--- a/src/app/admin/theme/page.tsx
+++ b/src/app/admin/theme/page.tsx
@@ -223,41 +223,41 @@ const ADMIN_FIELDS: {
}[] = [
{
key: "admin_canvas",
- label: "Page background",
+ label: "Page background (dark)",
def: "#0f172a",
- desc: "Main admin page background",
+ desc: "Dark-mode admin canvas. Light mode follows the public light palette.",
},
{
key: "admin_surface",
- label: "Card / panel",
+ label: "Card / panel (dark)",
def: "#1e293b",
- desc: "Admin cards, tables, and panel backgrounds",
+ desc: "Dark-mode cards, tables, and panels",
},
{
key: "admin_text",
- label: "Body text",
+ label: "Body text (dark)",
def: "#f1f5f9",
- desc: "Main admin text — must contrast with canvas/surface",
+ desc: "Dark-mode text — must contrast with canvas/surface",
bgKey: "admin_canvas",
},
{
key: "admin_text_muted",
- label: "Muted text",
+ label: "Muted text (dark)",
def: "#94a3b8",
- desc: "Secondary labels, hints, descriptions",
+ desc: "Dark-mode secondary labels and hints",
bgKey: "admin_canvas",
},
{
key: "admin_border",
- label: "Border",
+ label: "Border (dark)",
def: "#334155",
- desc: "Table borders, dividers, input outlines",
+ desc: "Dark-mode table borders, dividers, input outlines",
},
{
key: "admin_sidebar_bg",
- label: "Sidebar",
+ label: "Sidebar (dark)",
def: "#0f172a",
- desc: "Left navigation sidebar background",
+ desc: "Dark-mode left navigation background",
},
];
diff --git a/src/app/globals.css b/src/app/globals.css
index a6e98b12..5a8cd151 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -95,7 +95,7 @@ html.dark textarea {
}
html.dark .admin-page table,
html.dark .admin-card {
- border-color: rgba(255, 255, 255, 0.06);
+ border-color: var(--admin-border);
}
html.dark .currency,
html.dark .article-img {
@@ -262,6 +262,14 @@ html.dark .site-bg {
background-blend-mode: overlay;
}
+/* Admin must not inherit the public wallpaper — solid HK canvas only. */
+body.site-bg:has([data-admin]),
+html.dark body.site-bg:has([data-admin]) {
+ background: var(--admin-canvas);
+ background-image: none;
+ background-blend-mode: normal;
+}
+
.article-body {
line-height: 1.7;
@@ -1156,8 +1164,59 @@ body:has([data-admin]) {
--color-surface-text-readable: var(--admin-text);
--color-dropdown: var(--admin-surface);
--color-dropdown-text-readable: var(--admin-text);
+ --input-border-color: var(--admin-border);
+ --input-focus-color: var(--admin-accent);
+ --muted: var(--admin-text-muted);
+ --border-subtle: color-mix(in srgb, var(--admin-text-muted) 22%, transparent);
color: var(--admin-text);
background-color: var(--admin-canvas);
+ font-family: var(--font-family);
+}
+
+/* Admin forms / boxes follow admin tokens in both light and dark. */
+body:has([data-admin]) input:not([type="checkbox"]):not([type="radio"]):not(
+ [type="range"]
+ ):not([type="file"]):not([type="color"]),
+body:has([data-admin]) select,
+body:has([data-admin]) textarea {
+ background-color: var(--admin-surface-elevated);
+ color: var(--admin-text);
+ border-color: var(--admin-border);
+ caret-color: var(--admin-text);
+}
+body:has([data-admin]) input::placeholder,
+body:has([data-admin]) textarea::placeholder {
+ color: var(--admin-text-muted);
+ opacity: 0.85;
+}
+body:has([data-admin]) .muted,
+body:has([data-admin]) .admin-empty,
+body:has([data-admin]) .text-muted-foreground {
+ color: var(--admin-text-muted);
+}
+body:has([data-admin]) .admin-card,
+body:has([data-admin]) .admin-page table {
+ background: var(--admin-surface);
+ color: var(--admin-text);
+ border-color: var(--admin-border);
+}
+body:has([data-admin]) .btn-outline {
+ color: var(--admin-text);
+ border-color: var(--admin-border);
+ background: transparent;
+}
+body:has([data-admin]) .btn-outline:hover {
+ background: color-mix(in srgb, var(--admin-accent) 12%, transparent);
+ color: var(--admin-text);
+}
+/* Override public html.dark input hardcodes inside admin. */
+html.dark body:has([data-admin]) input:not([type="checkbox"]):not(
+ [type="radio"]
+ ):not([type="range"]):not([type="file"]):not([type="color"]),
+html.dark body:has([data-admin]) select,
+html.dark body:has([data-admin]) textarea {
+ background: var(--admin-surface-elevated);
+ color: var(--admin-text);
}
/* ── Details/summary open states for nav ──────────────────── */
diff --git a/src/components/language-switcher.tsx b/src/components/language-switcher.tsx
index a728385d..a1f551c4 100644
--- a/src/components/language-switcher.tsx
+++ b/src/components/language-switcher.tsx
@@ -90,7 +90,7 @@ export function LanguageSwitcher({
aria-expanded={open}
className={
isAdmin
- ? "flex items-center gap-1.5 h-9 rounded-xl border border-[var(--admin-border)] px-2 text-xs font-bold uppercase tracking-wide text-[var(--admin-sidebar-text-readable)] hover:bg-[var(--admin-accent)]/15 hover:text-[var(--admin-accent)] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)] disabled:opacity-60"
+ ? "flex items-center gap-1.5 h-9 rounded-xl border border-[var(--admin-border)] bg-[var(--admin-surface-elevated)] px-2 text-xs font-bold uppercase tracking-wide text-[var(--admin-text)] hover:bg-[var(--admin-accent)]/15 hover:text-[var(--admin-accent)] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)] disabled:opacity-60"
: "nav-item flex items-center gap-1.5 bg-transparent text-[13px] border-none cursor-pointer px-1"
}
>
diff --git a/src/components/theme-switcher.tsx b/src/components/theme-switcher.tsx
index 7d335ae7..2a1cb123 100644
--- a/src/components/theme-switcher.tsx
+++ b/src/components/theme-switcher.tsx
@@ -43,7 +43,7 @@ export function ThemeSwitcher({
aria-label={label}
title={title}
suppressHydrationWarning
- className="flex-none grid place-items-center w-9 h-9 rounded-xl border border-[var(--admin-border)] text-[var(--admin-sidebar-text-readable)] hover:bg-[var(--admin-accent)]/15 hover:text-[var(--admin-accent)] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)]"
+ className="flex-none grid place-items-center w-9 h-9 rounded-xl border border-[var(--admin-border)] bg-[var(--admin-surface-elevated)] text-[var(--admin-text)] hover:bg-[var(--admin-accent)]/15 hover:text-[var(--admin-accent)] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)]"
style={{ visibility: ready ? "visible" : "hidden" }}
>
{dark ? : }
diff --git a/src/lib/theme-contrast.test.ts b/src/lib/theme-contrast.test.ts
index 62fdc481..ed8e3ee6 100644
--- a/src/lib/theme-contrast.test.ts
+++ b/src/lib/theme-contrast.test.ts
@@ -131,6 +131,48 @@ describe("deriveAdminPalette", () => {
expect(admin.surface).not.toBe("#00ff00");
});
+ it("in light mode mirrors readable public surfaces", () => {
+ const admin = deriveAdminPalette(
+ {
+ color_background: "#f8fafc",
+ color_surface: "#ffffff",
+ color_dropdown: "#f1f5f9",
+ color_text: "#0f172a",
+ color_text_muted: "#64748b",
+ color_primary: "#f59e0b",
+ color_navbar: "#ffffff",
+ color_navbar_text: "#1e293b",
+ },
+ {
+ canvas: "#0b0f19",
+ surface: "#151b27",
+ text: "#f8fafc",
+ },
+ "light",
+ );
+ expect(admin.canvas).toBe("#f8fafc");
+ expect(admin.surface).toBe("#ffffff");
+ expect(contrastRatio(admin.text, admin.canvas)).toBeGreaterThanOrEqual(4.5);
+ expect(contrastRatio(admin.sidebarText, admin.sidebar)).toBeGreaterThanOrEqual(
+ 4.5,
+ );
+ });
+
+ it("ignores dark admin overrides while in light mode", () => {
+ const admin = deriveAdminPalette(
+ {
+ color_background: "#ffffff",
+ color_surface: "#ffffff",
+ color_text: "#0f172a",
+ color_primary: "#f59e0b",
+ },
+ { canvas: "#0b0f19", text: "#f8fafc" },
+ "light",
+ );
+ expect(admin.canvas).toBe("#ffffff");
+ expect(admin.text.toLowerCase()).not.toBe("#f8fafc");
+ });
+
it("repairs default light text when admin canvas is forced light", () => {
const admin = deriveAdminPalette(
{ color_primary: "#f59e0b" },
diff --git a/src/lib/theme-contrast.ts b/src/lib/theme-contrast.ts
index 0878c207..930afc3d 100644
--- a/src/lib/theme-contrast.ts
+++ b/src/lib/theme-contrast.ts
@@ -30,9 +30,31 @@ export interface AdminPalette {
export function deriveAdminPalette(
colors: PublicThemeColors,
overrides?: Partial,
+ mode: "light" | "dark" = "dark",
): AdminPalette {
const accent = overrides?.accent ?? colors.color_primary ?? "#f59e0b";
- const defaults: AdminPalette = {
+
+ const lightDefaults: AdminPalette = {
+ canvas: colors.color_background ?? "#f8fafc",
+ surface: colors.color_surface ?? "#ffffff",
+ surfaceElevated: colors.color_dropdown ?? "#f1f5f9",
+ text: colors.color_text ?? "#0f172a",
+ muted: colors.color_text_muted ?? "#64748b",
+ border: "#e2e8f0",
+ accent,
+ accentText: accent,
+ accentForeground: "#ffffff",
+ success: colors.color_success ?? "#16a34a",
+ warning: colors.color_warning ?? "#eab308",
+ error: colors.color_error ?? "#ef4444",
+ info: colors.color_info ?? "#0ea5e9",
+ sidebar: colors.color_navbar ?? colors.color_surface ?? "#ffffff",
+ sidebarText: colors.color_navbar_text ?? colors.color_text ?? "#0f172a",
+ overlay: "rgba(15, 23, 42, 0.45)",
+ focusRing: accent,
+ };
+
+ const darkDefaults: AdminPalette = {
canvas: "#0b0f19",
surface: "#151b27",
surfaceElevated: "#1e2635",
@@ -52,11 +74,18 @@ export function deriveAdminPalette(
focusRing: accent,
};
+ const defaults = mode === "light" ? lightDefaults : darkDefaults;
+
+ // Staff admin_* overrides are the dark-HK palette — apply only in dark mode
+ // so light mode stays readable and tracks the public light theme.
const merged: AdminPalette = {
...defaults,
- ...overrides,
+ ...(mode === "dark" ? overrides : {}),
accent,
- overlay: overrides?.overlay ?? defaults.overlay,
+ overlay:
+ mode === "dark"
+ ? (overrides?.overlay ?? defaults.overlay)
+ : defaults.overlay,
focusRing: accent,
};
diff --git a/src/lib/theme-css.ts b/src/lib/theme-css.ts
index 223c85b9..32a730c6 100644
--- a/src/lib/theme-css.ts
+++ b/src/lib/theme-css.ts
@@ -74,8 +74,13 @@ export function themePaletteCss(
palette: ThemePalette,
adminOverrides?: Partial,
): string {
+ const mode: "light" | "dark" = selector.includes("dark") ? "dark" : "light";
const semantic = derivePublicForegrounds(palette);
- const admin = deriveAdminPalette(palette, adminOverrides);
+ const admin = deriveAdminPalette(
+ palette,
+ mode === "dark" ? adminOverrides : undefined,
+ mode,
+ );
const declarations = Object.entries(CSS_VARIABLES).map(
([key, cssName]) => `--${cssName}:${palette[key as keyof ThemePalette]};`,
);