From 151ea12251ed876aa87bb486da1bc337e25369e9 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sat, 1 Aug 2026 16:18:19 -0300 Subject: --- vrsite/css.go | 171 ++++++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 130 insertions(+), 41 deletions(-) (limited to 'vrsite/css.go') diff --git a/vrsite/css.go b/vrsite/css.go index a2ca377..f1df936 100644 --- a/vrsite/css.go +++ b/vrsite/css.go @@ -1,68 +1,157 @@ -// css.go — the stylesheet is generated per site: theme colors and the mixed -// per-agent coverage colors become custom properties, one block per scheme. +// 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 main import ( + "encoding/json" "fmt" "io" - "sort" "strings" texttemplate "text/template" ) type cssVar struct{ K, V string } -type synCSS struct{ Class, Style, Weight 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 } - var unionKeys []string - seen := map[string]bool{} - for _, t := range []*Theme{site.Theme, site.Light} { - for k := range t.Syntax { - if !seen[k] { - seen[k] = true - unionKeys = append(unionKeys, k) - } + 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)}) } } - sort.Strings(unionKeys) - masks := site.masks() - themeVars := func(t *Theme) []cssVar { - vars := []cssVar{ - {"--bg", t.Bg}, {"--fg", t.Fg}, {"--panel", t.Panel}, {"--border", t.Border}, - {"--ln", t.LineNo}, {"--muted", t.Muted}, {"--accent", t.Accent}, - {"--note", t.Note}, {"--hover", t.Hover}, + 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) } - for _, k := range unionKeys { - if s, ok := t.Syntax[k]; ok && s.Color != "" { - vars = append(vars, cssVar{"--s-" + strings.ReplaceAll(k, ".", "-"), s.Color}) - } + } + 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) } - for _, m := range masks { - vars = append(vars, cssVar{fmt.Sprintf("--am%d", m), site.maskColor(t, m)}) + scheme := "dark" + if t.Appearance == "light" { + scheme = "light" } - return vars + fmt.Fprintf(w, " color-scheme: %s;\n}\n", scheme) } - var syn []synCSS - for _, k := range unionKeys { - s := synCSS{Class: strings.ReplaceAll(k, ".", "-")} - if v, ok := site.Theme.Syntax[k]; ok { - if v.FontStyle != nil { - s.Style = *v.FontStyle - } - if v.FontWeight != nil { - s.Weight = fmt.Sprintf("%.0f", *v.FontWeight) +} + +// 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