summaryrefslogtreecommitdiff
path: root/web/css.go
diff options
context:
space:
mode:
Diffstat (limited to 'web/css.go')
-rw-r--r--web/css.go157
1 files changed, 157 insertions, 0 deletions
diff --git a/web/css.go b/web/css.go
new file mode 100644
index 0000000..e2637bc
--- /dev/null
+++ b/web/css.go
@@ -0,0 +1,157 @@
+// 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 <script src> still loads.
+func writeThemesJS(w io.Writer, themes []*Theme) {
+ cards := make([]themeCard, 0, len(themes))
+ for _, t := range themes {
+ syn := func(key string) string {
+ if k := resolveKey(t, key); k != "" {
+ return hex6(t.Syntax[k].Color)
+ }
+ return hex6(t.Fg)
+ }
+ cards = append(cards, themeCard{ID: t.ID, Name: t.Name, Src: t.Source,
+ Dark: t.Appearance != "light",
+ Sw: []string{hex6(t.Bg), hex6(t.Fg), hex6(t.Accent),
+ syn("keyword"), syn("string"), syn("function")}})
+ }
+ b, err := json.Marshal(cards)
+ if err != nil {
+ b = []byte("[]")
+ }
+ fmt.Fprintf(w, "// %d themes, written by notevi from this machine's zed and helix installs\n", len(cards))
+ fmt.Fprintf(w, "window.VRSITE_THEMES=%s;\n", strings.ReplaceAll(string(b), "},{", "},\n{"))
+}