Files
kjol/go/webui/theme.go

208 lines
7.1 KiB
Go

package webui
import (
"kjol/wasmruntime"
"kjol/wasmruntime/vdom"
)
// Dark mode.
//
// The kit is themed by TOKENS, not by a dark: variant on every class. A component says
// bg-surface, text-ink, border-line; the theme decides what those mean. Flipping the
// theme is therefore one class on <html> and a block of CSS variables — not four hundred
// class strings, each of which is a chance to forget one and leave a white card sitting
// in the middle of a dark page.
//
// Only genuinely COLOURED things (an alert's red tint, a validation message) carry dark:
// variants, because those are not surfaces or ink and there is no token that means "red,
// but for a dark background".
//
// The app must define the tokens and the variant in its stylesheet. See the example's
// css/app.css; the required set is listed in ThemeTokens below.
// ThemeTokens is the contract between the kit and an app's stylesheet: the CSS custom
// properties every component assumes exist, with light values in :root and dark values
// under .dark.
//
// It is a documentation constant, not code — but it is here, next to the components that
// depend on it, rather than in a README nobody opens when a card comes out the wrong
// colour.
const ThemeTokens = `
--color-surface page and card backgrounds
--color-surface-muted a subtle fill (table stripes, footers)
--color-surface-raised a hovered or filled row
--color-surface-strong the strongest neutral fill
--color-line ordinary borders and dividers
--color-line-strong an input's border — the one you must be able to see
--color-ink body text and headings
--color-ink-soft secondary text
--color-ink-muted labels, captions
--color-ink-faint placeholders, disabled text
--color-accent accent TEXT and icons (readable on the surface)
--color-primary accent FILLS, which carry white text
--color-primary-hover
--color-primary-subtle a tinted panel
--color-primary-border
`
// ThemeMode is what the user chose. System is the default: follow the OS until told
// otherwise, because a site that ignores the OS setting is a site that flashes white at
// someone who asked their whole computer not to.
type ThemeMode string
const (
ThemeSystem ThemeMode = "system"
ThemeLight ThemeMode = "light"
ThemeDark ThemeMode = "dark"
)
// themeStorageKey is where the choice is kept. The bootstrap script in the document head
// reads the SAME key — see ThemeBootScript.
const themeStorageKey = "kjol-theme"
// Theme is the site-wide theme controller. Create one, call Init once after mount, and
// render its Toggle wherever the switch belongs.
type Theme struct {
mode *vdom.Signal[ThemeMode]
// dark is the RESOLVED answer: what "system" actually means right now.
dark *vdom.Signal[bool]
stop wasmruntime.Unsub
}
// NewTheme creates the controller. It reads no storage and touches no DOM — Init does
// that, once the document exists.
func NewTheme() *Theme {
return &Theme{
mode: vdom.NewSignal(ThemeSystem),
dark: vdom.NewSignal(false),
}
}
// Init adopts the stored choice and starts following the OS while the mode is "system".
// Call it once, after the app has mounted.
//
// It does not FLASH, because it is not what puts the class on <html> for the first
// paint: ThemeBootScript already did that, before any of this code existed. Init only
// takes over.
func (t *Theme) Init() {
if s, ok := wasmruntime.StorageGet(themeStorageKey); ok {
switch ThemeMode(s) {
case ThemeLight:
t.mode.Set(ThemeLight)
case ThemeDark:
t.mode.Set(ThemeDark)
}
}
t.apply()
// Follow the OS, but only while the user has not overridden it.
t.stop = wasmruntime.OnMediaChange("(prefers-color-scheme: dark)", func(bool) {
if t.mode.Get() == ThemeSystem {
t.apply()
}
})
}
// Dispose stops following the OS.
func (t *Theme) Dispose() {
if t.stop != nil {
t.stop()
}
}
// Mode is the user's choice; IsDark is what that currently resolves to.
func (t *Theme) Mode() ThemeMode { return t.mode.Get() }
func (t *Theme) IsDark() bool { return t.dark.Get() }
// Set changes the theme. "system" forgets the choice entirely rather than storing the
// current resolution — otherwise "follow my OS" would quietly freeze at whatever the OS
// happened to say the day you chose it.
func (t *Theme) Set(m ThemeMode) {
t.mode.Set(m)
if m == ThemeSystem {
wasmruntime.StorageRemove(themeStorageKey)
} else {
wasmruntime.StorageSet(themeStorageKey, string(m))
}
t.apply()
}
// Toggle flips between light and dark. It resolves "system" first, so the first click
// does what it looks like it will do — the opposite of what you are looking at.
func (t *Theme) Toggle() {
if t.resolve() {
t.Set(ThemeLight)
return
}
t.Set(ThemeDark)
}
// resolve turns the mode into a yes or no.
func (t *Theme) resolve() bool {
switch t.mode.Get() {
case ThemeDark:
return true
case ThemeLight:
return false
default:
return wasmruntime.PrefersDark()
}
}
func (t *Theme) apply() {
dark := t.resolve()
t.dark.Set(dark)
wasmruntime.SetRootClass("dark", dark)
}
// ThemeToggleProps configures the switch.
type ThemeToggleProps struct {
Class string
// Small renders an icon-only button.
Small bool
}
// ThemeToggle is the switch: one button, showing the theme you would get by pressing it.
func (t *Theme) ThemeToggle(p ThemeToggleProps) *vdom.VNode {
icon, label := "moon", "Dark"
if t.IsDark() {
icon, label = "sun", "Light"
}
cls := "inline-flex items-center gap-2 rounded-default border border-line px-2.5 py-1.5 text-sm text-ink-soft hover:bg-surface-raised hover:text-ink"
if p.Small {
cls = "inline-flex h-8 w-8 items-center justify-center rounded-default border border-line text-ink-soft hover:bg-surface-raised hover:text-ink"
}
mods := []vdom.Mod{
vdom.Attr("type", "button"),
vdom.Attr("class", cx(cls, p.Class)),
vdom.Attr("aria-label", "Switch to "+label+" theme"),
vdom.Attr("title", "Switch to "+label+" theme"),
vdom.On(vdom.EVENT_CLICK, t.Toggle),
IconInline(icon, 15, ""),
}
if !p.Small {
mods = append(mods, vdom.Span(vdom.Text(label)))
}
return vdom.Button(mods...)
}
// ThemeBootScript is the inline script an app puts in its <head>, BEFORE any stylesheet
// or markup.
//
// It exists to prevent the flash. The server cannot read localStorage, so it cannot know
// which theme to render; if the class were applied by the WebAssembly after it loads,
// every dark-mode user would be shown a white page for as long as the binary takes to
// download, and then have it yanked out from under them. This runs first, synchronously,
// and the first paint is already correct.
//
// It is ten lines of JavaScript in a project that has none. That is the price of the
// browser giving the page no way to ask about localStorage before it paints, and it is
// worth paying — the alternative is a white flash on every single load.
const ThemeBootScript = `<script>(function(){try{
var m = localStorage.getItem("kjol-theme");
var dark = m === "dark" || (!m && matchMedia("(prefers-color-scheme: dark)").matches);
if (dark) document.documentElement.classList.add("dark");
}catch(e){}})();</script>`