From bdd806efff877ba2c3013f28861f32f1e5727bb6 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Mon, 6 Apr 2026 19:43:19 +0300 Subject: [PATCH] refactor: delegate theme management completely to client via bootstrap --- apps/client/src/desktop.ts | 8 +- apps/client/src/index.ts | 75 ++++++++++++++----- apps/client/src/services/theme.ts | 35 +++++++++ apps/client/src/stylesheets/theme-next.css | 11 --- apps/client/src/stylesheets/theme.css | 11 --- apps/client/src/types.d.ts | 6 +- apps/client/src/widgets/note_map/NoteMap.tsx | 37 ++++----- apps/client/src/widgets/note_map/utils.ts | 4 - apps/client/src/widgets/react/hooks.tsx | 11 +-- apps/server/src/routes/index.ts | 33 ++++---- .../Developer Guide/Concepts/Themes.md | 2 +- 11 files changed, 134 insertions(+), 99 deletions(-) create mode 100644 apps/client/src/services/theme.ts delete mode 100644 apps/client/src/stylesheets/theme-next.css delete mode 100644 apps/client/src/stylesheets/theme.css diff --git a/apps/client/src/desktop.ts b/apps/client/src/desktop.ts index 6f22d3fc7b..bb1f859809 100644 --- a/apps/client/src/desktop.ts +++ b/apps/client/src/desktop.ts @@ -54,7 +54,7 @@ function initOnElectron() { const currentWindow = electronRemote.getCurrentWindow(); const style = window.getComputedStyle(document.body); - initDarkOrLightMode(style); + initDarkOrLightMode(); initTransparencyEffects(style, currentWindow); initFullScreenDetection(currentWindow); @@ -119,11 +119,11 @@ function initTransparencyEffects(style: CSSStyleDeclaration, currentWindow: Elec * * @param style the root CSS element to read variables from. */ -function initDarkOrLightMode(style: CSSStyleDeclaration) { +function initDarkOrLightMode() { let themeSource: typeof nativeTheme.themeSource = "system"; - const themeStyle = style.getPropertyValue("--theme-style"); - if (style.getPropertyValue("--theme-style-auto") !== "true" && (themeStyle === "light" || themeStyle === "dark")) { + const themeStyle = window.glob.getThemeStyle(); + if (themeStyle !== "auto") { themeSource = themeStyle; } diff --git a/apps/client/src/index.ts b/apps/client/src/index.ts index fba6e17d7e..720d3eb59c 100644 --- a/apps/client/src/index.ts +++ b/apps/client/src/index.ts @@ -1,3 +1,5 @@ +import { getThemeStyle } from "./services/theme"; + async function bootstrap() { showSplash(); await setupGlob(); @@ -38,6 +40,7 @@ async function setupGlob() { ...json, activeDialog: null }; + window.glob.getThemeStyle = getThemeStyle; } async function loadBootstrapCss() { @@ -49,31 +52,65 @@ async function loadBootstrapCss() { } } -function loadStylesheets() { - const { device, assetPath, themeCssUrl, themeUseNextAsBase } = window.glob; +type StylesheetRef = { + href: string; + media?: string; +}; - const cssToLoad: string[] = []; - if (device !== "print") { - cssToLoad.push(`${assetPath}/stylesheets/ckeditor-theme.css`); - cssToLoad.push(`api/fonts`); - cssToLoad.push(`${assetPath}/stylesheets/theme-light.css`); - if (themeCssUrl) { - cssToLoad.push(themeCssUrl); - } - if (themeUseNextAsBase === "next") { - cssToLoad.push(`${assetPath}/stylesheets/theme-next.css`); - } else if (themeUseNextAsBase === "next-dark") { - cssToLoad.push(`${assetPath}/stylesheets/theme-next-dark.css`); - } else if (themeUseNextAsBase === "next-light") { - cssToLoad.push(`${assetPath}/stylesheets/theme-next-light.css`); - } - cssToLoad.push(`${assetPath}/stylesheets/style.css`); +function getConfiguredThemeStylesheets(stylesheetsPath: string, theme: string, customThemeCssUrl?: string) { + if (theme === "auto") { + return [{ href: `${stylesheetsPath}/theme-dark.css`, media: "(prefers-color-scheme: dark)" }]; } - for (const href of cssToLoad) { + if (theme === "dark") { + return [{ href: `${stylesheetsPath}/theme-dark.css` }]; + } + + if (theme === "next") { + return [ + { href: `${stylesheetsPath}/theme-next-light.css` }, + { href: `${stylesheetsPath}/theme-next-dark.css`, media: "(prefers-color-scheme: dark)" } + ]; + } + + if (theme === "next-light") { + return [{ href: `${stylesheetsPath}/theme-next-light.css` }]; + } + + if (theme === "next-dark") { + return [{ href: `${stylesheetsPath}/theme-next-dark.css` }]; + } + + if (theme !== "light" && customThemeCssUrl) { + return [{ href: customThemeCssUrl }]; + } + + return []; +} + +function loadStylesheets() { + const { device, assetPath, theme, themeBase, customThemeCssUrl } = window.glob; + const stylesheetsPath = `${assetPath}/stylesheets`; + + const cssToLoad: StylesheetRef[] = []; + if (device !== "print") { + cssToLoad.push({ href: `${stylesheetsPath}/ckeditor-theme.css` }); + cssToLoad.push({ href: `api/fonts` }); + cssToLoad.push({ href: `${stylesheetsPath}/theme-light.css` }); + cssToLoad.push(...getConfiguredThemeStylesheets(stylesheetsPath, theme, customThemeCssUrl)); + if (themeBase) { + cssToLoad.push(...getConfiguredThemeStylesheets(stylesheetsPath, themeBase)); + } + cssToLoad.push({ href: `${stylesheetsPath}/style.css` }); + } + + for (const { href, media } of cssToLoad) { const linkEl = document.createElement("link"); linkEl.href = href; linkEl.rel = "stylesheet"; + if (media) { + linkEl.media = media; + } document.head.appendChild(linkEl); } } diff --git a/apps/client/src/services/theme.ts b/apps/client/src/services/theme.ts new file mode 100644 index 0000000000..9aa42c22ba --- /dev/null +++ b/apps/client/src/services/theme.ts @@ -0,0 +1,35 @@ +export function getThemeStyle(): "auto" | "light" | "dark" { + const configuredTheme = window.glob?.theme; + if (configuredTheme === "auto" || configuredTheme === "next") { + return "auto"; + } + + if (configuredTheme === "light" || configuredTheme === "dark") { + return configuredTheme; + } + + if (configuredTheme === "next-light") { + return "light"; + } + + if (configuredTheme === "next-dark") { + return "dark"; + } + + const style = window.getComputedStyle(document.body); + const themeStyle = style.getPropertyValue("--theme-style"); + if (style.getPropertyValue("--theme-style-auto") !== "true" && (themeStyle === "light" || themeStyle === "dark")) { + return themeStyle as "light" | "dark"; + } + + return "auto"; +} + +export function getEffectiveThemeStyle(): "light" | "dark" { + const themeStyle = getThemeStyle(); + if (themeStyle === "auto") { + return window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + } + + return themeStyle === "dark" ? "dark" : "light"; +} diff --git a/apps/client/src/stylesheets/theme-next.css b/apps/client/src/stylesheets/theme-next.css deleted file mode 100644 index 1c8a7d810d..0000000000 --- a/apps/client/src/stylesheets/theme-next.css +++ /dev/null @@ -1,11 +0,0 @@ -/* Import the light color scheme. - * This is the base color scheme, always active and overridden by the dark - * color scheme stylesheet when necessary. */ -@import url(./theme-next-light.css); - -/* Import the dark color scheme when the system preference is set to dark mode */ -@import url(./theme-next-dark.css) (prefers-color-scheme: dark); - -:root { - --theme-style-auto: true; -} diff --git a/apps/client/src/stylesheets/theme.css b/apps/client/src/stylesheets/theme.css deleted file mode 100644 index e99702d325..0000000000 --- a/apps/client/src/stylesheets/theme.css +++ /dev/null @@ -1,11 +0,0 @@ -/* Import the light color scheme. - * This is the base color scheme, always active and overridden by the dark - * color scheme stylesheet when necessary. */ -@import url(./theme-light.css); - -/* Import the dark color scheme when the system preference is set to dark mode */ -@import url(./theme-dark.css) (prefers-color-scheme: dark); - -:root { - --theme-style-auto: true; -} diff --git a/apps/client/src/types.d.ts b/apps/client/src/types.d.ts index f7673901c1..6e2196eab9 100644 --- a/apps/client/src/types.d.ts +++ b/apps/client/src/types.d.ts @@ -24,6 +24,7 @@ interface CustomGlobals { getReferenceLinkTitle: (href: string) => Promise; getReferenceLinkTitleSync: (href: string) => string; getActiveContextNote: () => FNote | null; + getThemeStyle: () => "auto" | "light" | "dark"; ESLINT: Library; appContext: AppContext; froca: Froca; @@ -51,8 +52,9 @@ interface CustomGlobals { isElectron: boolean; isRtl: boolean; iconRegistry: IconRegistry; - themeCssUrl: string; - themeUseNextAsBase?: "next" | "next-light" | "next-dark"; + theme: string; + themeBase?: "next" | "next-light" | "next-dark"; + customThemeCssUrl?: string; iconPackCss: string; headingStyle: "plain" | "underline" | "markdown"; layoutOrientation: "vertical" | "horizontal"; diff --git a/apps/client/src/widgets/note_map/NoteMap.tsx b/apps/client/src/widgets/note_map/NoteMap.tsx index 12e15202d2..2677e4aa59 100644 --- a/apps/client/src/widgets/note_map/NoteMap.tsx +++ b/apps/client/src/widgets/note_map/NoteMap.tsx @@ -1,18 +1,21 @@ -import { useEffect, useMemo, useRef, useState } from "preact/hooks"; import "./NoteMap.css"; -import { getThemeStyle, MapType, NoteMapWidgetMode, rgb2hex } from "./utils"; -import { RefObject } from "preact"; -import FNote from "../../entities/fnote"; -import { useElementSize, useNoteLabel } from "../react/hooks"; + import ForceGraph from "force-graph"; +import { RefObject } from "preact"; +import { useEffect, useMemo, useRef, useState } from "preact/hooks"; + +import appContext from "../../components/app_context"; +import FNote from "../../entities/fnote"; +import link_context_menu from "../../menus/link_context_menu"; +import hoisted_note from "../../services/hoisted_note"; +import { t } from "../../services/i18n"; +import { getEffectiveThemeStyle } from "../../services/theme"; +import ActionButton from "../react/ActionButton"; +import { useElementSize, useNoteLabel } from "../react/hooks"; +import Slider from "../react/Slider"; import { loadNotesAndRelations, NoteMapLinkObject, NoteMapNodeObject, NotesAndRelationsData } from "./data"; import { CssData, setupRendering } from "./rendering"; -import ActionButton from "../react/ActionButton"; -import { t } from "../../services/i18n"; -import link_context_menu from "../../menus/link_context_menu"; -import appContext from "../../components/app_context"; -import Slider from "../react/Slider"; -import hoisted_note from "../../services/hoisted_note"; +import { MapType, NoteMapWidgetMode, rgb2hex } from "./utils"; interface NoteMapProps { note: FNote; @@ -40,9 +43,9 @@ export default function NoteMap({ note, widgetMode, parentRef }: NoteMapProps) { return hoisted_note.getHoistedNoteId(); } else if (mapRootIdLabel) { return mapRootIdLabel; - } else { - return appContext.tabManager.getActiveContext()?.parentNoteId ?? null; } + return appContext.tabManager.getActiveContext()?.parentNoteId ?? null; + }, [ note ]); // Build the note graph instance. @@ -67,7 +70,7 @@ export default function NoteMap({ note, widgetMode, parentRef }: NoteMapProps) { noteIdToSizeMap: notesAndRelations.noteIdToSizeMap, cssData, notesAndRelations, - themeStyle: getThemeStyle(), + themeStyle: getEffectiveThemeStyle(), widgetMode, mapType }); @@ -113,7 +116,7 @@ export default function NoteMap({ note, widgetMode, parentRef }: NoteMapProps) { node.fx = undefined; node.fy = undefined; } - }) + }); }, [ fixNodes, mapType ]); return ( @@ -159,7 +162,7 @@ function MapTypeSwitcher({ icon, text, type, currentMapType, setMapType }: { onClick={() => setMapType(type)} frame /> - ) + ); } function getCssData(container: HTMLElement, styleResolver: HTMLElement): CssData { @@ -170,5 +173,5 @@ function getCssData(container: HTMLElement, styleResolver: HTMLElement): CssData fontFamily: containerStyle.fontFamily, textColor: rgb2hex(containerStyle.color), mutedTextColor: rgb2hex(styleResolverStyle.color) - } + }; } diff --git a/apps/client/src/widgets/note_map/utils.ts b/apps/client/src/widgets/note_map/utils.ts index d551ea235b..923b5cb001 100644 --- a/apps/client/src/widgets/note_map/utils.ts +++ b/apps/client/src/widgets/note_map/utils.ts @@ -27,7 +27,3 @@ export function generateColorFromString(str: string, themeStyle: "light" | "dark return color; } -export function getThemeStyle() { - const documentStyle = window.getComputedStyle(document.documentElement); - return documentStyle.getPropertyValue("--theme-style")?.trim() as "light" | "dark"; -} diff --git a/apps/client/src/widgets/react/hooks.tsx b/apps/client/src/widgets/react/hooks.tsx index 6bae391f56..a6f9c4595f 100644 --- a/apps/client/src/widgets/react/hooks.tsx +++ b/apps/client/src/widgets/react/hooks.tsx @@ -1385,7 +1385,7 @@ export function useGetContextDataFrom( } export function useColorScheme() { - const themeStyle = getThemeStyle(); + const themeStyle = window.glob.getThemeStyle(); const defaultValue = themeStyle === "auto" ? (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) : themeStyle === "dark"; const [ prefersDark, setPrefersDark ] = useState(defaultValue); @@ -1400,12 +1400,3 @@ export function useColorScheme() { return prefersDark ? "dark" : "light"; } - -function getThemeStyle() { - const style = window.getComputedStyle(document.body); - const themeStyle = style.getPropertyValue("--theme-style"); - if (style.getPropertyValue("--theme-style-auto") !== "true" && (themeStyle === "light" || themeStyle === "dark")) { - return themeStyle as "light" | "dark"; - } - return "auto"; -} diff --git a/apps/server/src/routes/index.ts b/apps/server/src/routes/index.ts index 8094c0bdf6..0eec58bfa7 100644 --- a/apps/server/src/routes/index.ts +++ b/apps/server/src/routes/index.ts @@ -11,9 +11,9 @@ import { generateCss, generateIconRegistry, getIconPacks, MIME_TO_EXTENSION_MAPP import log from "../services/log.js"; import optionService from "../services/options.js"; import protectedSessionService from "../services/protected_session.js"; -import { generateCsrfToken } from "./csrf_protection.js"; import sql from "../services/sql.js"; import { isDev, isElectron, isMac, isWindows11 } from "../services/utils.js"; +import { generateCsrfToken } from "./csrf_protection.js"; type View = "desktop" | "mobile" | "print"; @@ -38,6 +38,7 @@ export function bootstrap(req: Request, res: Response) { const view = getView(req); const theme = options.theme; const themeNote = attributeService.getNoteWithLabel("appTheme", theme); + const themeUseNextAsBase = themeNote?.getAttributeValue("label", "appThemeBase") ?? undefined; const nativeTitleBarVisible = options.nativeTitleBarVisible === "true"; const iconPacks = getIconPacks(); const currentLocale = getCurrentLocale(); @@ -45,8 +46,9 @@ export function bootstrap(req: Request, res: Response) { res.send({ device: view, csrfToken, - themeCssUrl: getThemeCssUrl(theme, themeNote), - themeUseNextAsBase: themeNote?.getAttributeValue("label", "appThemeBase"), + theme, + themeBase: themeUseNextAsBase, + customThemeCssUrl: getCustomThemeCssUrl(theme, themeNote), headingStyle: options.headingStyle, layoutOrientation: options.layoutOrientation, platform: process.platform, @@ -117,25 +119,16 @@ function getView(req: Request): View { return "desktop"; } -function getThemeCssUrl(theme: string, themeNote: BNote | null) { - if (theme === "auto") { - return `${assetPath}/stylesheets/theme.css`; - } else if (theme === "light") { - // light theme is always loaded as baseline - return false; - } else if (theme === "dark") { - return `${assetPath}/stylesheets/theme-dark.css`; - } else if (theme === "next") { - return `${assetPath}/stylesheets/theme-next.css`; - } else if (theme === "next-light") { - return `${assetPath}/stylesheets/theme-next-light.css`; - } else if (theme === "next-dark") { - return `${assetPath}/stylesheets/theme-next-dark.css`; - } else if (!process.env.TRILIUM_SAFE_MODE && themeNote) { +function getCustomThemeCssUrl(theme: string, themeNote: BNote | null) { + if (["auto", "light", "dark", "next", "next-light", "next-dark"].includes(theme)) { + return undefined; + } + + if (!process.env.TRILIUM_SAFE_MODE && themeNote) { return `api/notes/download/${themeNote.noteId}`; } - // baseline light theme - return false; + + return undefined; } function getAppCssNoteIds() { diff --git a/docs/Developer Guide/Developer Guide/Concepts/Themes.md b/docs/Developer Guide/Developer Guide/Concepts/Themes.md index 2b399605d0..314c886ccc 100644 --- a/docs/Developer Guide/Developer Guide/Concepts/Themes.md +++ b/docs/Developer Guide/Developer Guide/Concepts/Themes.md @@ -4,7 +4,7 @@ * There are three themes embedded in the application: * `light`, located in `src\public\stylesheets\theme-light.css` * `dark`, located in `src\public\stylesheets\theme-dark.css` - * `next`, located in `src\public\stylesheets\theme-next.css`. + * `next`, composed from `src\public\stylesheets\theme-next-light.css` and `src\public\stylesheets\theme-next-dark.css`. * The default theme is set only once, when the database is created and is managed by `options_init#initNotSyncedOptions`. * In the original implementation: On Electron, the choice between `light` and `dark` is done based on the OS preference. Otherwise, the theme is always `dark`. * Now, we always choose `next` as the default theme.