Skip to content

Commit

Permalink
Add support for prefers-color-scheme, simplify vars
Browse files Browse the repository at this point in the history
  • Loading branch information
xypnox committed Feb 9, 2024
1 parent 7336106 commit c3c114c
Show file tree
Hide file tree
Showing 8 changed files with 118 additions and 61 deletions.
24 changes: 21 additions & 3 deletions src/components/themeManager/index.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { For, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js";
import { themeState } from "./themeState";
import { keyframes, styled } from "solid-styled-components";
import { theme, type ThemePalette } from "../../theme";
import { theme, type ThemeMode, type ThemePalette } from "../../theme";
import { generateName } from "../../lib/nameGen";
import { ThemeEditor } from "./editor";
import { nanoid } from "nanoid";
import { icons } from "../icons";
import { DebugModeButton } from "./debug";
import ModeSwitcher from "./modeSwitcher";

const attachFontLink = (newFamily: string) => {
Expand All @@ -27,6 +26,20 @@ const attachFontLink = (newFamily: string) => {
document.head.appendChild(link);
}

// element Classes .dark-mode .light-mode
const updateThemeMode = (mode: ThemeMode) => {
const root = document.documentElement;
if (mode === 'auto') {
root.classList.remove('dark-mode');
root.classList.remove('light-mode');
} else if (mode === 'light') {
root.classList.remove('dark-mode');
root.classList.add('light-mode');
} else if (mode === 'dark') {
root.classList.remove('light-mode');
root.classList.add('dark-mode');
}
}

const updateThemeStyle = (themeCss: string) => {
// Find style element with id _themeVars
Expand All @@ -35,7 +48,7 @@ const updateThemeStyle = (themeCss: string) => {
return;
} else {
// Update style element with new theme variables
style.innerHTML = `:root { ${themeCss} }`
style.innerHTML = themeCss;
}
}

Expand Down Expand Up @@ -209,6 +222,11 @@ const ThemeManager = (props: Props) => {
updateThemeStyle(theme());
})

createEffect(() => {
const mode = themeState.themeConfig.get().mode;
updateThemeMode(mode);
})

createEffect(() => {
if (themeState.isThemeDefault()) {
setEditing(false);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,32 +1,50 @@
---
// Get localStorage themeVars string, and set it as the CSS variables
---
import type { ThemePalette } from "../../theme";

<script is:inline>
const themeCss = localStorage.getItem("xypnoxCssTheme");
let style = document.getElementById("_themeVars");

if (style && themeCss) {
const themeString = JSON.parse(themeCss);
if (themeString) style.innerHTML = `:root { ${themeString} }`;
if (themeString) style.innerHTML = themeString;
}

(() => {
const themes = localStorage.getItem("xypnox-themes");
const themeConfig = localStorage.getItem("xypnox-themeConfig");

if (themeConfig) {
const conf = JSON.parse(themeConfig);
console.log({ conf });
if (conf && conf.mode) {
const mode = conf.mode;
const root = document.documentElement;
if (mode === "auto") {
// Remove both classes
root.classList.remove("dark-mode");
root.classList.remove("light-mode");
} else if (mode === "light") {
root.classList.remove("dark-mode");
root.classList.add("light-mode");
} else if (mode === "dark") {
root.classList.remove("light-mode");
root.classList.add("dark-mode");
}
}
}

const themes = localStorage.getItem("xypnox-themes");

if (themes && themeConfig) {
const themesPalette = JSON.parse(themes);
const themeConfigPalette = JSON.parse(themeConfig);
// find theme from config in palettes
const theme = themesPalette.find(
(theme) => theme.id === themeConfigPalette.theme,
(theme: ThemePalette) => theme.id === themeConfigPalette.theme,
);
if (!theme) return;
const fontFamily = theme.base.font.family;
console.log({ theme, fontFamily });

const getFirstFont = (style /* string */) => {
const getFirstFont = (style: string) => {
const font = style.split(",")[0];
return font.replace(/"/g, "");
};
Expand All @@ -35,8 +53,7 @@ if (style && themeCss) {
fontFamily,
)}:ital,wght@0,400;0,500;0,600;1,400;1,500;1,600&display=swap`;
link.rel = "stylesheet";
link.classList.add("_fontFamily");
document.head.appendChild(link);
}
})();
</script>
link.classList.add("_fontFamily");
document.head.appendChild(link);
}
})();
2 changes: 1 addition & 1 deletion src/components/themeManager/modeSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const Wrapper = styled(ButtonGroup)`
const ModeSwitcher = () => {
return (
<Wrapper>
<For each={['light', 'dark'] as const}>
<For each={['light', 'dark', 'auto'] as const}>
{mode => (
<Button onClick={() => themeState.changeMode(mode)}>
<iconify-icon icon={mode === 'light' ? icons.light : icons.dark} />
Expand Down
24 changes: 6 additions & 18 deletions src/components/themeManager/themeStateDef.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,8 @@
import { createEffect, createMemo, createSignal, on } from "solid-js";
import { createStoredStore, setLocalStorage } from "../../utils/localStore";

import { flattenObject } from "../../lib/objects";
import { generateThemeFromPalette, deepMerge, type ThemeMode, type ThemePalette, type UITheme, defaultPalettes } from "../../theme"

const cssConverter = (theme: UITheme, mode: ThemeMode) => {
const cssVars = flattenObject(deepMerge(theme.vars[mode], theme.base), (keys: string[], value: string) => [
`${keys.join("-")}`,
value,
]);
const cssVarsString = Object.entries(cssVars).map(([key, value]) => {
return `--${key}: ${value};`
}).join('\n');
// console.log({ cssVarsString })
return cssVarsString;
}
import { generateThemeFromPalette, type ThemeMode, type ThemePalette, defaultPalettes, cssConverter } from "../../theme"



export const createThemeState = (initTheme?: 'default_aster' | 'default_brutalist', initMode?: ThemeMode) => {
Expand All @@ -25,7 +13,7 @@ export const createThemeState = (initTheme?: 'default_aster' | 'default_brutalis
theme: string;
debug: boolean;
}>('xypnox-themeConfig', {
mode: initMode ?? 'dark',
mode: initMode ?? 'auto',
theme: initTheme ?? 'default_aster',
debug: false,
});
Expand All @@ -51,11 +39,11 @@ export const createThemeState = (initTheme?: 'default_aster' | 'default_brutalis

const cssTheme = createMemo(
on(
() => ({ mode: themeConfig.get().mode, theme: theme() })
() => ({ theme: theme() })
, (v) => {
// console.log('generating cssTheme for themeState', { theme: theme(), v, currentMode: themeConfig.get().mode })
// console.log('generating cssTheme for themeState', { v })
return cssConverter(v.theme, v.mode);
return cssConverter(v.theme);
}
)
);
Expand All @@ -78,7 +66,7 @@ export const createThemeState = (initTheme?: 'default_aster' | 'default_brutalis
}

const changeMode = (mode: ThemeMode) => {
if (mode !== 'dark' && mode !== 'light') {
if (mode !== 'dark' && mode !== 'light' && mode !== 'auto') {
console.error(`Mode ${mode} is not available`);
return;
}
Expand Down
2 changes: 1 addition & 1 deletion src/dataTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,5 @@ export interface BaseLayoutProps {
showLoading?: boolean;
htmlClass?: string;
hideNav?: boolean;
themeCssVars?: Record<string, string>;
themeCssVars?: string;
}
11 changes: 1 addition & 10 deletions src/layouts/BaseLayout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,6 @@ export interface Props extends BaseLayoutProps {}
const { title, meta, showLoading, htmlClass, themeCssVars } = Astro.props;
const themeVarsString = Object.entries(themeCssVars ?? {})
.map(([key, value]) => `--${key}: ${value};`)
.join("\n");
const themeCssString = `
:root {
${themeVarsString}
}
`;
---

<!doctype html>
Expand All @@ -29,7 +20,7 @@ const themeCssString = `

<Meta {...meta} title={meta?.title ?? title} />
<slot name="head" />
<style id="_themeVars" is:inline set:html={themeCssString}></style>
<style id="_themeVars" is:inline set:html={themeCssVars}></style>
</head>

<body>
Expand Down
3 changes: 1 addition & 2 deletions src/layouts/MainLayout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import Footer from "../components/footer.astro";
import Navbar from "../components/nav/Navbar.astro";
import type { BaseLayoutProps } from "../dataTypes";
import BaseLayout from "./BaseLayout.astro";
import LoadTheme from "../components/themeManager/loadTheme.astro";
import { themeCssVars } from "../theme";
Expand All @@ -23,7 +22,7 @@ const props = Astro.props;
rel="stylesheet"
/>
</Fragment>
<LoadTheme />
<script src="../components/themeManager/loadTheme.ts"></script>
{!props.hideNav && <Navbar />}
<slot />
{!props.hideNav && <Footer />}
Expand Down
70 changes: 57 additions & 13 deletions src/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { flattenObject, forObjectReplace } from "./lib/objects";
import tinycolor from 'tinycolor2'

interface GeneratedTheme<T> {
themeCssVars: Record<string, string>;
themeCss: string;
theme: T;
}

Expand All @@ -16,7 +16,7 @@ export const generateTheme = <T extends Record<string, any>>(vars: T, prefix = '
const theme = forObjectReplace(vars, (keys) => `var(--${keys.join("-")})`)

const generated: GeneratedTheme<T> = {
themeCssVars,
themeCss: Object.entries(themeCssVars).map(([k, v]) => `${k}: ${v};`).join('\n'),
theme,
}

Expand Down Expand Up @@ -45,6 +45,51 @@ export const deepMerge = <T extends Record<string, any>, U extends Record<string
return output;
}

const newKey = (keys: string[], value: any) =>
[`--${keys.join("-")}`, value] as [string, any];

const joinVariables = (vars: Record<string, any>) =>
Object.entries(vars).map(([k, v]) => `${k}: ${v};`).join('\n')

/**
* Final css should be
* :root { // Base vars }
* .dark-mode { // This is added to the body tag }
* .light-mode { // This is added to the body tag }
* @media (prefers-color-scheme: dark) {
* :root { // Dark mode vars }
* }
* @media (prefers-color-scheme: light) {
* :root { // Light mode vars }
* }
*/
export const cssConverter = (theme: UITheme) => {
const baseCssVars = flattenObject(theme.base, newKey);
const modeVars = {
dark: flattenObject(theme.vars.dark, newKey),
light: flattenObject(theme.vars.light, newKey),
}

const cssVars = {
base: baseCssVars,
dark: modeVars.dark,
light: modeVars.light,
}

const cssVarsString = Object.entries(cssVars).map(([key, value]) => {
if (key === 'base') {
return ` :root { ${joinVariables(value)} } `
}
return `
@media (prefers-color-scheme: ${key}) { :root { ${joinVariables(value)} } }
.${key}-mode { ${joinVariables(value)} }
`
}).join('\n')

// console.log({ cssVarsString })
return cssVarsString;
}

/**
* Generates a theme from a UITheme
*
Expand Down Expand Up @@ -73,16 +118,12 @@ export const deepMerge = <T extends Record<string, any>, U extends Record<string
*/
export const generateUITheme = (theme: UITheme, mode: ThemeMode, prefix = '') => {
// Extends the base theme with the mode theme , add specific type from UITheme
const themeVars = deepMerge(theme.base, theme.vars[mode])
const themeCssVars = flattenObject(themeVars, (keys, value) => [
`${prefix}${keys.join("-")}`,
value,
]);
const themeVars = deepMerge(theme.base, theme.vars['light'])

const generatedTheme = forObjectReplace(themeVars, (keys) => `var(--${keys.join("-")})`)
const generatedTheme = forObjectReplace(themeVars, (keys) => `var(--${prefix}${keys.join("-")})`)

const generated: GeneratedTheme<typeof themeVars> = {
themeCssVars,
themeCss: cssConverter(theme),
theme: generatedTheme,
}

Expand Down Expand Up @@ -150,20 +191,23 @@ const poemThemeGen =

/** Use for setting css variables in the parent
* Ex: { 'poems-text' : '#444' } */
export const poemThemeCssVars = poemThemeGen.themeCssVars
export const poemThemeCssVars = poemThemeGen.themeCss

/** Use for declaring css styles in css-in-js
* Ex: { 'text': 'var(--poems-text)' } */
export const poemTheme = poemThemeGen.theme

export type ThemeMode = 'light' | 'dark'
export type ThemeMode = 'auto' | 'light' | 'dark'
export type BaseVars = typeof baseVars

export interface UITheme {
id: string;
name: string;
base: BaseVars;
vars: Record<ThemeMode, ThemeVars>;
vars: {
light: ThemeVars;
dark: ThemeVars;
}
}

const defaultPaletteColors = {
Expand Down Expand Up @@ -424,5 +468,5 @@ export const generateThemeFromPalette = (palette: ThemePalette): UITheme => {
}

const defaultTheme = generateUITheme(generateThemeFromPalette(defaultPalettes[0]), 'dark')
export const themeCssVars = defaultTheme.themeCssVars
export const themeCssVars = defaultTheme.themeCss
export const theme = defaultTheme.theme

2 comments on commit c3c114c

@baddate
Copy link

@baddate baddate commented on c3c114c Apr 2, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hi friend,

this commit causes a flicker on loading when set dark mode, it will show light mode loading animation then correct animation, like this

reproduce:

  1. set theme to dark mode or any others that is not default_aster light mode
  2. click a post or refresh page, then you will see the flicker loading

image

@xypnox
Copy link
Owner Author

@xypnox xypnox commented on c3c114c Apr 2, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @baddate, Thanks for pointing this out.

The flicker is caused due to the delay in the javascript fetching the values from localstorage and then applying them to the DOM. When the page loads initially, the CSS with inital HTML makes it render the page in the auto theme first, which would be light/dark depending on the user preference. And just after the css variable changes are applied and the theme reverts to the user set theme.

I consider this a limitation with static-only theming. This is because the html being served has no idea of the preference of the user, so the first paint (before localstorage values are applied) will always be the auto theme.

Moreover the flicker, from light to dark and not between colors, is noticeable only when the user chooses the mode opposite to their preferred system preferences.

If this seems like a unworthy compromise, one can implement saving themes to backend storage and tieing them to the user session/account, and then prefilling the css variables from the server so the initial html already has the user preferred theme.

Due to the complexity of implementing a server just to manage themes for the users was not something I was looking forward to with a personal static html website. I do recommend the theme storage on server for web apps and SPAs etc.

For me I found the slight flicker acceptable as the loader masks this somewhat.

PS: Yeah I hate the occasional flicker if something in dark mode flickers white but this should not occur when the user's preferred color scheme is dark.

Please sign in to comment.