Indicate if themes support light or dark modes, or both

This commit is contained in:
2026-08-29 01:41:16 +00:00
parent 794e1ba26e
commit d21c3edbfc
4 changed files with 52 additions and 9 deletions
+2
View File
@@ -16,6 +16,8 @@ Themes are folders in `pagerite/themes/{name}/` containing `theme.css` and/or `b
`Data.theme` selects the active theme (empty = none/base only) and the site editor can switch it, choosing from the theme folders found on disk. Vue may add per-component styles on top where needed.
The site editor shows a light/dark-mode indicator in front of each theme name, read from the theme's `color-scheme` declaration in `theme.css`: ☀️ for light-only, 🌙 for dark-only, and 🌓 for themes that support both. The base theme (`none`) is light-only.
Current themes:
- `purple` — dark dusk palette with Fraunces/Literata and a tilted oversized gradient brand.
+10 -3
View File
@@ -56,12 +56,19 @@ const brand = ref('')
const theme = ref('')
// Theme options come from the backend (theme folders on disk, see GET
// /_api/settings), so added themes need no frontend changes.
const themeOptions = ref([{ value: '', label: 'none' }])
const themeOptions = ref([{ value: '', label: '☀️ none' }])
// Page transition (cube, crossfade, ...): a design folder with
// transition.css under pagerite/themes/, injected as #pagerite-transition.
const transition = ref('cube')
const transitionOptions = ref([])
// Mode icons match the ones used in the Paskia auth frontend.
const MODE_ICONS = { light: '☀️', dark: '🌙', both: '🌓' }
function themeLabel(t) {
return `${MODE_ICONS[t.mode] || MODE_ICONS.light} ${t.name}`
}
async function loadSettings() {
try {
const s = await (await fetch('/_api/settings')).json()
@@ -73,8 +80,8 @@ async function loadSettings() {
customCss.value = s.custom_css || ''
favicon.value = s.favicon || ''
themeOptions.value = [
{ value: '', label: 'none' },
...(s.themes || []).map((t) => ({ value: t, label: t })),
{ value: '', label: `${MODE_ICONS.light} none` },
...(s.themes || []).map((t) => ({ value: t.name, label: themeLabel(t) })),
]
transition.value = s.transition || 'cube'
transitionOptions.value = s.transitions || []
+1 -1
View File
@@ -557,7 +557,7 @@ async def get_settings() -> dict:
"theme": data.theme,
"custom_css": data.custom_css,
"favicon": f"/_f/{data.favicon}" if data.favicon else "",
"themes": views._theme_names(),
"themes": views._theme_info(),
"banner_designs": views._banner_design_names(),
"transition": data.transition,
"transitions": views._transition_names(),
+39 -5
View File
@@ -47,11 +47,45 @@ def _manifest() -> dict:
return _manifest_cache
def _theme_names() -> list[str]:
"""Theme folders on disk (a folder is a theme when it has theme.css)."""
return sorted(
d.name for d in THEMES.iterdir() if d.is_dir() and (d / "theme.css").exists()
)
def _theme_color_schemes(theme: str) -> set[str]:
"""Return the color-scheme keywords (``light``/``dark``) from theme.css.
Reads the first ``color-scheme:`` declaration in the file. An empty set
means the theme did not declare one.
"""
path = THEMES / theme / "theme.css"
try:
css = path.read_text()
except (OSError, ValueError):
return set()
css = re.sub(r"/\*.*?\*/", "", css, flags=re.DOTALL)
m = re.search(r"color-scheme\s*:\s*([^;]+);", css, re.IGNORECASE)
if not m:
return set()
return {tok.lower() for tok in m.group(1).split() if tok.lower() in {"light", "dark"}}
def _theme_mode(theme: str) -> str:
"""Light/dark mode support of a theme, derived from its CSS.
Returns one of ``"light"``, ``"dark"``, or ``"both"``. Themes without a
``color-scheme`` declaration are treated as light-only.
"""
schemes = _theme_color_schemes(theme)
if "light" in schemes and "dark" in schemes:
return "both"
if "dark" in schemes:
return "dark"
return "light"
def _theme_info() -> list[dict[str, str]]:
"""Theme folders on disk, each with its name and supported color mode."""
return [
{"name": d.name, "mode": _theme_mode(d.name)}
for d in sorted(THEMES.iterdir(), key=lambda d: d.name)
if d.is_dir() and (d / "theme.css").exists()
]
def _banner_design_names() -> list[str]: