summaryrefslogtreecommitdiff
path: root/vrsite/theme.go
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-02 23:43:08 -0300
committerGabriel Schneider <[email protected]>2026-08-03 09:54:39 -0300
commit5ec72f8723d795d0b02d7e9ebb9f555f0c7e6a2e (patch)
treef12f4b700fff4ff6f7e818d2a542672ba0b8d76e /vrsite/theme.go
parenta9263daee9413c5eff3c1f1bebcd224442fd8685 (diff)
downloadnotevi-5ec72f8723d795d0b02d7e9ebb9f555f0c7e6a2e.tar.gz
notevi-5ec72f8723d795d0b02d7e9ebb9f555f0c7e6a2e.zip
rebrand to notevi: one CLI over the jj sidecar
vr and vrsite become a single binary. vrsite/ folds into a web package in one module (0x4200.cafe/notevi); "notevi web" serves and exports exactly what vrsite did, and "notevi read/grep/note/query" is unchanged. The sidecar is renamed with it: notevi_log, notevi-log.jsonl, and the description "private: notevi log". The pre-rebrand names are still recognized, so an old repository opens and reads; it is renamed in place on the first write, or up front with "notevi migrate DIR...". That rename cannot be a single mv inside jj run. jj only auto-tracks a *new* file in the run working copy below a size limit it does not take from the command line, so writing a whole log under a name the change has never held is silently dropped while jj reports success. ensureLogFile creates the file empty first and lets every later byte be a modification of a tracked file, which snapshots at any size; that also fixes the same latent bug when importing a large legacy vr-log.jsonl. Adds a bem-te-vi mark (favicon and nav brand) and a README. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Diffstat (limited to 'vrsite/theme.go')
-rw-r--r--vrsite/theme.go341
1 files changed, 0 insertions, 341 deletions
diff --git a/vrsite/theme.go b/vrsite/theme.go
deleted file mode 100644
index 91eedbe..0000000
--- a/vrsite/theme.go
+++ /dev/null
@@ -1,341 +0,0 @@
-// theme.go — Zed theme JSON in, CSS custom properties out. Per-agent colors
-// come from the theme's player (collaborator) palette.
-//
-// The two themes named on the command line are what the site is built with;
-// themes.go finds the rest of this machine's themes for the picker, and they
-// arrive here as the same *Theme.
-package main
-
-import (
- "encoding/json"
- "errors"
- "fmt"
- "os"
- "sort"
- "strings"
-)
-
-type SyntaxStyle struct {
- Color string `json:"color"`
- FontStyle *string `json:"font_style"`
- FontWeight *float64 `json:"font_weight"`
-}
-
-type Theme struct {
- ID, Source string // "zed/one-dark", "zed" | "helix"
- Name, Appearance string
- Bg, Fg, Panel, Border, LineNo, Muted, Accent string
- Note, Hover string
- Syntax map[string]SyntaxStyle
- Players []string
-}
-
-// defaultTheme finds a zed theme file containing a theme of the wanted
-// appearance; "" means fall back to the embedded One family.
-func defaultTheme(appearance string) string {
- for _, path := range globAll(zedThemeGlobs()) {
- b, err := os.ReadFile(path)
- if err != nil {
- continue
- }
- fam, err := parseZedFamily(b)
- if err != nil {
- continue
- }
- for _, t := range fam {
- if t.Appearance == appearance {
- return path
- }
- }
- }
- return ""
-}
-
-func loadTheme(path, name, appearance string) *Theme {
- var b []byte
- var err error
- if path == "" {
- b, err = tfs.ReadFile("assets/one.json")
- } else {
- b, err = os.ReadFile(path)
- }
- if err != nil {
- fatal("%v", err)
- }
- fam, err := parseZedFamily(b)
- if err != nil {
- fatal("bad zed theme %s: %v", path, err)
- }
- t := fam[0]
- if name != "" {
- for _, c := range fam {
- if c.Name == name {
- t = c
- }
- }
- } else {
- for _, c := range fam {
- if c.Appearance == appearance {
- t = c
- break
- }
- }
- }
- return t
-}
-
-// parseZedFamily reads a zed theme file: one file is a family, and every theme
-// in it is one the picker can offer.
-func parseZedFamily(b []byte) ([]*Theme, error) {
- var family struct {
- Name string `json:"name"`
- Themes []struct {
- Name string `json:"name"`
- Appearance string `json:"appearance"`
- Style map[string]json.RawMessage `json:"style"`
- } `json:"themes"`
- }
- if err := json.Unmarshal(b, &family); err != nil {
- return nil, err
- }
- if len(family.Themes) == 0 {
- return nil, errors.New("no themes in it")
- }
- var out []*Theme
- for _, th := range family.Themes {
- get := func(keys ...string) string {
- for _, k := range keys {
- var s string
- if raw, ok := th.Style[k]; ok && json.Unmarshal(raw, &s) == nil && okColor(s) != "" {
- return s
- }
- }
- return ""
- }
- t := &Theme{ID: "zed/" + slug(th.Name), Source: "zed",
- Name: th.Name, Appearance: th.Appearance,
- Bg: get("editor.background", "background"),
- Fg: get("editor.foreground", "text"),
- Panel: get("panel.background", "surface.background", "background"),
- Border: get("border", "border.variant"),
- LineNo: get("editor.line_number"),
- Muted: get("text.muted", "text.disabled"),
- Accent: get("text.accent", "info"),
- Note: get("warning", "conflict", "modified"),
- Hover: get("element.hover", "ghost_element.hover"),
- }
- json.Unmarshal(th.Style["syntax"], &t.Syntax)
- for k, s := range t.Syntax { // a theme in the wild does drop the odd "#"
- if okColor(s.Color) == "" {
- delete(t.Syntax, k)
- }
- }
- t.fill()
- var players []struct {
- Cursor string `json:"cursor"`
- }
- json.Unmarshal(th.Style["players"], &players)
- var cursors []string
- for _, p := range players {
- cursors = append(cursors, p.Cursor)
- }
- t.setPlayers(cursors, t.syntaxColors())
- out = append(out, t)
- }
- return out, nil
-}
-
-// setPlayers picks the per-agent colors out of the theme's own palette. A zed
-// theme has one meant for exactly this — the collaborator cursors — and it is
-// asked first; when it runs short, or the theme is a helix one with no such
-// palette at all, the code's own colors stand in, since those are what the
-// reader is already telling apart on the page.
-//
-// Within each tier, colors that vanish against fg/bg, collide with the note
-// color, or sit on top of one already taken are dropped, and the rest ordered
-// greedily for mutual distance, so the first few agents contrast most.
-func (t *Theme) setPlayers(tiers ...[]string) {
- t.Players = nil
- taken := func(c string, of []string) bool {
- for _, p := range of {
- if colorDist(c, p) <= 60 {
- return true
- }
- }
- return false
- }
- for _, tier := range tiers {
- var usable []string
- for _, c := range tier {
- c = hex6(c)
- if len(c) != 7 || taken(c, usable) || taken(c, t.Players) ||
- colorDist(c, hex6(t.Fg)) <= 60 || colorDist(c, hex6(t.Bg)) <= 60 ||
- colorDist(c, hex6(t.Note)) <= 60 {
- continue
- }
- usable = append(usable, c)
- }
- for len(usable) > 0 && len(t.Players) < maxAgents {
- best, bestScore := 0, -1
- for i, c := range usable {
- score := 1 << 24 // first pick of the first tier: keep theme order
- for _, p := range t.Players {
- if d := colorDist(c, p); d < score {
- score = d
- }
- }
- if score > bestScore {
- best, bestScore = i, score
- }
- }
- t.Players = append(t.Players, usable[best])
- usable = append(usable[:best], usable[best+1:]...)
- }
- }
-}
-
-// fill supplies whatever a theme does not say. Editors leave gaps a web page
-// cannot — a zed theme need not name a muted text color, a helix theme has no
-// notion of a panel — so every gap is closed with a shade mixed from the
-// theme's own background and foreground, or with a color out of its syntax.
-// A theme with three colors in it still dresses a whole page.
-func (t *Theme) fill() {
- bg, fg := hex6(t.Bg), t.Fg
- // panel and hover are backgrounds the page writes on, so a theme that
- // offers one the text would be lost in does not get to keep it
- t.Panel = first(readable(t.Panel, fg, ""), blend(bg, fg, 0.05))
- t.Hover = first(readable(t.Hover, fg, ""), blend(bg, fg, 0.12))
- t.Border = first(t.Border, blend(bg, fg, 0.2))
- t.LineNo = first(t.LineNo, blend(bg, fg, 0.4))
- t.Muted = first(readable(t.Muted, t.Panel, ""), blend(bg, fg, 0.55))
- t.Accent = first(t.Accent, t.syn("function"), t.syn("keyword"), t.syn("type"), fg)
- t.Note = first(t.Note, t.syn("warning"), t.syn("number"), t.Accent)
-}
-
-// syn is the theme's color for a syntax key, resolved along its dots.
-func (t *Theme) syn(key string) string {
- if k := resolveKey(t, key); k != "" {
- return t.Syntax[k].Color
- }
- return ""
-}
-
-// syntaxColors are the theme's own code colors, in the order they stand out
-// from one another: what a theme offers when its palette runs short.
-func (t *Theme) syntaxColors() []string {
- var out []string
- for _, k := range []string{"keyword", "function", "string", "type", "constant",
- "variable.parameter", "attribute", "constructor", "number", "tag", "property",
- "label", "namespace", "operator", "boolean"} {
- if c := t.syn(k); c != "" {
- out = append(out, c)
- }
- }
- return out
-}
-
-// readable keeps a color only if text in `on` can be seen against it.
-func readable(c, on, fallback string) string {
- if c == "" || colorDist(hex6(c), hex6(on)) < 2000 {
- return fallback
- }
- return c
-}
-
-// syntaxKeys is every syntax key the site paints, and so every one a theme is
-// asked to fill: the union of what the two built-with themes name.
-func syntaxKeys(themes ...*Theme) []string {
- var keys []string
- seen := map[string]bool{}
- for _, t := range themes {
- for k := range t.Syntax {
- if !seen[k] {
- seen[k] = true
- keys = append(keys, k)
- }
- }
- }
- sort.Strings(keys)
- return keys
-}
-
-func colorDist(a, b string) int {
- var ar, ag, ab, br, bg, bb int
- if len(a) != 7 || len(b) != 7 {
- return 1 << 24
- }
- fmt.Sscanf(a[1:], "%02x%02x%02x", &ar, &ag, &ab)
- fmt.Sscanf(b[1:], "%02x%02x%02x", &br, &bg, &bb)
- d := func(x, y int) int { return (x - y) * (x - y) }
- return int(float64(d(ar, br)+d(ag, bg)+d(ab, bb)) / 3)
-}
-
-var fallbackPalette = []string{"#7aa2f7", "#e0af68", "#9ece6a", "#f7768e", "#bb9af7", "#2ac3de"}
-
-func (t *Theme) Player(i int) string {
- if i < len(t.Players) {
- return t.Players[i]
- }
- return fallbackPalette[i%len(fallbackPalette)]
-}
-
-// okColor keeps a color only if it is one a browser will take: #rgb, #rrggbb
-// or #rrggbbaa. Whatever else a theme file says is treated as unsaid, and
-// fill() supplies it.
-func okColor(c string) string {
- if len(c) != 4 && len(c) != 7 && len(c) != 9 || c[0] != '#' {
- return ""
- }
- for _, r := range c[1:] {
- if !(r >= '0' && r <= '9' || r >= 'a' && r <= 'f' || r >= 'A' && r <= 'F') {
- return ""
- }
- }
- return c
-}
-
-// hex6 strips an alpha channel from #rrggbbaa.
-func hex6(c string) string {
- if len(c) == 9 && c[0] == '#' {
- return c[:7]
- }
- if len(c) == 4 && c[0] == '#' { // #rgb
- return "#" + string([]byte{c[1], c[1], c[2], c[2], c[3], c[3]})
- }
- return c
-}
-
-func rgb(c string) (r, g, b int, ok bool) {
- c = hex6(c)
- if len(c) != 7 || c[0] != '#' {
- return 0, 0, 0, false
- }
- if n, _ := fmt.Sscanf(c[1:], "%02x%02x%02x", &r, &g, &b); n != 3 {
- return 0, 0, 0, false
- }
- return r, g, b, true
-}
-
-// lum is perceived brightness, 0..1 — enough to tell a light theme from a
-// dark one when the theme does not say which it is.
-func lum(c string) float64 {
- r, g, b, ok := rgb(c)
- if !ok {
- return 0
- }
- return (0.299*float64(r) + 0.587*float64(g) + 0.114*float64(b)) / 255
-}
-
-// blend mixes frac of b into a — for the shades a theme leaves unsaid.
-func blend(a, b string, frac float64) string {
- ar, ag, ab, ok := rgb(a)
- br, bg, bb, ok2 := rgb(b)
- if !ok || !ok2 {
- return a
- }
- mix := func(x, y int) int { return int(float64(x) + (float64(y)-float64(x))*frac) }
- return fmt.Sprintf("#%02x%02x%02x", mix(ar, br), mix(ag, bg), mix(ab, bb))
-}
-
-// cssKey is a syntax key as it appears in class and property names.
-func cssKey(k string) string { return strings.ReplaceAll(k, ".", "-") }