// css.go — the stylesheet is generated per site: theme colors become custom // properties, one block per scheme, and the per-agent coverage colors are // blended from them in the browser so any theme can supply them. // // Everything switchable is a custom property on :root. That is what lets // themes.css hold nothing but one block per theme (writeThemesCSS) and the // picker repaint the page by setting a single attribute. package web import ( "encoding/json" "fmt" "io" "strings" texttemplate "text/template" ) type cssVar struct{ K, V string } type synCSS struct{ Class string } type maskCSS struct { Mask uint8 Mix string } func renderCSS(w io.Writer, site *Site) error { css, err := texttemplate.ParseFS(tfs, "assets/style.css.tmpl") if err != nil { return err } keys := syntaxKeys(site.Theme, site.Light) var syn []synCSS for _, k := range keys { syn = append(syn, synCSS{Class: cssKey(k)}) } var masks []maskCSS for _, m := range site.masks() { masks = append(masks, maskCSS{Mask: m, Mix: site.maskMix(m)}) } return css.Execute(w, map[string]any{ "DarkName": site.Theme.Name, "LightName": site.Light.Name, "DarkVars": themeVars(site.Theme, keys), "LightVars": themeVars(site.Light, keys), "Syntax": syn, "Masks": masks, }) } // themeVars is one theme as custom properties: its own colors, the syntax // colors under the keys the site paints with (resolved along their dots, so a // theme that only names "punctuation" still colors brackets), and the agent // colors every coverage blend is mixed from. func themeVars(t *Theme, keys []string) []cssVar { // bg and panel are what everything else is read against, so they are the // two that may not carry a theme's alpha channel through vars := []cssVar{ {"--bg", hex6(t.Bg)}, {"--fg", t.Fg}, {"--panel", hex6(t.Panel)}, {"--border", t.Border}, {"--ln", t.LineNo}, {"--muted", t.Muted}, {"--accent", t.Accent}, {"--note", t.Note}, {"--hover", t.Hover}, } for _, k := range keys { key := resolveKey(t, k) if key == "" { continue } s, c := t.Syntax[key], cssKey(k) vars = append(vars, cssVar{"--s-" + c, s.Color}) if s.FontStyle != nil && *s.FontStyle != "" && *s.FontStyle != "normal" { vars = append(vars, cssVar{"--s-" + c + "-fs", *s.FontStyle}) } if s.FontWeight != nil && *s.FontWeight > 0 { vars = append(vars, cssVar{"--s-" + c + "-fw", fmt.Sprintf("%.0f", *s.FontWeight)}) } } for i := 0; i < maxAgents; i++ { vars = append(vars, cssVar{fmt.Sprintf("--ag%d", i), hex6(t.Player(i))}) } return vars } // maskMix is the color of a coverage bitmask: the agent colors it stands for, // averaged. The average is written as nested color-mix rather than mixed here, // so the same rule holds for every theme the browser can switch to. func (s *Site) maskMix(mask uint8) string { var idx []int for i, a := range s.Agents { if a.Bit != 0 && mask&a.Bit != 0 { idx = append(idx, i) } } return mixExpr(idx) } func mixExpr(idx []int) string { switch len(idx) { case 0: return "var(--fg)" case 1: return fmt.Sprintf("var(--ag%d)", idx[0]) } return fmt.Sprintf("color-mix(in srgb, var(--ag%d) %.3f%%, %s)", idx[0], 100/float64(len(idx)), mixExpr(idx[1:])) } // ── the switchable themes ───────────────────────────────────────────────── // writeThemesCSS writes one block per theme found on this machine. It is only // fetched once a theme has been picked (or the picker opened), so the page // costs nothing for the reader who never switches. func writeThemesCSS(w io.Writer, themes []*Theme, keys []string) { fmt.Fprintf(w, "/* %d themes, found on this machine — set data-theme on :root to wear one */\n", len(themes)) for _, t := range themes { fmt.Fprintf(w, ":root[data-theme=%q] {\n", t.ID) for _, v := range themeVars(t, keys) { fmt.Fprintf(w, " %s: %s;\n", v.K, v.V) } scheme := "dark" if t.Appearance == "light" { scheme = "light" } fmt.Fprintf(w, " color-scheme: %s;\n}\n", scheme) } } // themeCard is a theme as the picker shows it: a name, where it came from, and // the few colors that make it recognizable at a glance. type themeCard struct { ID string `json:"id"` Name string `json:"name"` Src string `json:"src"` Dark bool `json:"dark"` Sw []string `json:"sw"` } // writeThemesJS is the picker's menu. It is a script, not JSON, because the // static export is opened from the filesystem, where fetch() is not allowed // but a