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 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 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 , 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 = ``