208 lines
7.1 KiB
Go
208 lines
7.1 KiB
Go
package webui
|
|
|
|
import (
|
|
"kjol/vdom"
|
|
"kjol/wasmruntime"
|
|
)
|
|
|
|
// 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>`
|