diff --git a/src/app/globals.css b/src/app/globals.css index c36068d9..7ba04006 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -86,22 +86,11 @@ --border-subtle: color-mix(in srgb, var(--color-text-muted) 18%, transparent); } -/* Dark mode (toggled by html.dark; wins over :root + ThemeVars via specificity). */ +/* Dark mode colors are generated by ThemeVars from the selected preset. */ html.dark { - --color-background: #0b0d14; - --color-surface: #141820; - --color-dropdown: #1d222b; - --color-navbar: #141820; - --color-navbar-text: #e5e7eb; - --color-text: #e5e7eb; - --color-text-muted: #94a3b8; --color-text-readable: var(--color-text); --color-text-muted-readable: var(--color-text-muted); - --color-primary-readable: var(--color-text); - --color-accent-readable: var(--color-text); --color-navbar-text-readable: var(--color-navbar-text); - --link-color-readable: var(--color-text); - --link-hover-color-readable: var(--color-text); } html.dark input, html.dark select, diff --git a/src/components/theme-vars.test.ts b/src/components/theme-vars.test.ts new file mode 100644 index 00000000..a2f5a400 --- /dev/null +++ b/src/components/theme-vars.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { themePaletteCss } from "@/lib/theme-css"; +import { PRESETS } from "@/lib/theme-presets"; + +describe("themePaletteCss", () => { + it("emits separate preset-specific light and dark scopes", () => { + const ocean = PRESETS.Ocean; + const css = `${themePaletteCss(":root", ocean.light)}${themePaletteCss("html.dark", ocean.dark)}`; + + expect(css).toContain(":root{"); + expect(css).toContain("html.dark{"); + expect(css).toContain(`--color-primary:${ocean.light.color_primary}`); + expect(css).toContain(`--color-background:${ocean.light.color_background}`); + expect(css).toContain(`--color-background:${ocean.dark.color_background}`); + expect(ocean.dark.color_primary).toBe(ocean.light.color_primary); + }); +}); diff --git a/src/components/theme-vars.tsx b/src/components/theme-vars.tsx index 7c227cba..809c4b73 100644 --- a/src/components/theme-vars.tsx +++ b/src/components/theme-vars.tsx @@ -1,5 +1,6 @@ import { siteSettings } from "@/lib/services/site-settings"; -import { FONTS } from "@/lib/theme-presets"; +import { themePaletteCss } from "@/lib/theme-css"; +import { FONTS, PRESETS, THEME_COLOR_KEYS, type ThemePalette } from "@/lib/theme-presets"; import { derivePublicForegrounds, readableColor } from "@/lib/theme-contrast"; // Injects the DB-driven CSS custom properties into :root, exactly like @@ -124,6 +125,18 @@ export async function ThemeVars() { const font = FONTS[fontKey ?? "nunito"] ?? FONTS.nunito; + const presetName = (await g("theme_preset", "Atom (golden)")) ?? "Atom (golden)"; + const preset = PRESETS[presetName] ?? PRESETS["Atom (golden)"]; + const darkPalette = Object.fromEntries( + await Promise.all( + THEME_COLOR_KEYS.map(async (key) => [ + key, + safe(await g(`${key}_dark`, preset.dark[key]), preset.dark[key]), + ] as const), + ), + ) as ThemePalette; + const darkCss = themePaletteCss("html.dark", darkPalette); + const css = `:root{ --color-primary:${safe(primary, "#f59e0b")}; --color-background:${safeBackground}; @@ -174,7 +187,7 @@ export async function ThemeVars() { --size-heading-h1:${px(h1, "30")}px; --size-heading-h2:${px(h2, "22")}px; --size-heading-h3:${px(h3, "18")}px; -}`; +}${darkCss}`; const googleHref = font.google ? `https://fonts.googleapis.com/css2?family=${font.google}&display=swap` diff --git a/src/lib/theme-css.ts b/src/lib/theme-css.ts new file mode 100644 index 00000000..3daf7a42 --- /dev/null +++ b/src/lib/theme-css.ts @@ -0,0 +1,44 @@ +import { derivePublicForegrounds, readableColor } from "@/lib/theme-contrast"; +import type { ThemePalette } from "@/lib/theme-presets"; + +const CSS_VARIABLES: Record = { + color_primary: "color-primary", color_background: "color-background", + color_surface: "color-surface", color_dropdown: "color-dropdown", + color_navbar: "color-navbar", color_navbar_text: "color-navbar-text", + color_text: "color-text", color_text_muted: "color-text-muted", + color_accent: "color-accent", color_success: "color-success", + color_warning: "color-warning", color_error: "color-error", color_info: "color-info", + button_primary_color: "button-color", button_text_color: "button-text-color", + button_secondary_color: "button-secondary-color", + button_secondary_text_color: "button-secondary-text-color", + button_danger_color: "button-danger-color", button_danger_text_color: "button-danger-text-color", + button_outline_color: "button-outline-color", button_outline_text_color: "button-outline-text-color", + link_color: "link-color", link_hover_color: "link-hover-color", + border_color: "border-color", gradient_from: "gradient-from", gradient_to: "gradient-to", +}; + +export function themePaletteCss(selector: string, palette: ThemePalette): string { + const semantic = derivePublicForegrounds(palette); + const declarations = Object.entries(CSS_VARIABLES).map( + ([key, cssName]) => `--${cssName}:${palette[key as keyof ThemePalette]};`, + ); + declarations.push( + `--color-text-readable:${readableColor(palette.color_text, [palette.color_background, palette.color_surface])};`, + `--color-text-muted-readable:${readableColor(palette.color_text_muted, [palette.color_background, palette.color_surface])};`, + `--color-primary-readable:${readableColor(palette.color_primary, [palette.color_background, palette.color_surface])};`, + `--color-accent-readable:${readableColor(palette.color_accent, [palette.color_background, palette.color_surface])};`, + `--color-navbar-text-readable:${semantic.byName.navbar};`, + `--button-text-color-readable:${semantic.byName["button-primary"]};`, + `--button-secondary-text-color-readable:${semantic.byName["button-secondary"]};`, + `--button-danger-text-color-readable:${semantic.byName["button-danger"]};`, + `--button-outline-text-color-readable:${semantic.byName["button-outline"]};`, + `--link-color-readable:${semantic.byName.link};`, + `--link-hover-color-readable:${semantic.byName["link-hover"]};`, + `--color-surface-text-readable:${semantic.byName.surface};`, + `--color-dropdown-text-readable:${semantic.byName.dropdown};`, + `--color-pill-text-readable:${semantic.byName.pill};`, + `--color-badge-text-readable:${semantic.byName.badge};`, + `--color-overlay-text-readable:${semantic.byName.overlay};`, + ); + return `${selector}{${declarations.join("")}}`; +}