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]};`, );