From 151ea12251ed876aa87bb486da1bc337e25369e9 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sat, 1 Aug 2026 16:18:19 -0300 Subject: --- vrsite/app_test.go | 98 ++++++++- vrsite/assets/app.js | 212 ++++++++++++++++++- vrsite/assets/style.css.tmpl | 159 +++++++++++++- vrsite/bench_test.go | 2 +- vrsite/css.go | 171 +++++++++++---- vrsite/go.mod | 2 + vrsite/go.sum | 4 + vrsite/main.go | 14 +- vrsite/markdown.go | 259 +++++++++++++++++++++++ vrsite/markdown_test.go | 244 ++++++++++++++++++++++ vrsite/model.go | 114 ++++++++-- vrsite/page.go | 84 +++++++- vrsite/permalink_test.go | 210 +++++++++++++++++++ vrsite/render.go | 147 +++++++++++-- vrsite/serve.go | 118 ++++++++++- vrsite/static.go | 50 ++++- vrsite/templates/frag-note.tmpl | 57 ++++- vrsite/templates/layout.tmpl | 30 ++- vrsite/templates/page-note.tmpl | 54 +++++ vrsite/theme.go | 315 ++++++++++++++++++++-------- vrsite/themes.go | 445 ++++++++++++++++++++++++++++++++++++++++ vrsite/themes_test.go | 294 ++++++++++++++++++++++++++ 22 files changed, 2877 insertions(+), 206 deletions(-) create mode 100644 vrsite/markdown.go create mode 100644 vrsite/markdown_test.go create mode 100644 vrsite/permalink_test.go create mode 100644 vrsite/templates/page-note.tmpl create mode 100644 vrsite/themes.go create mode 100644 vrsite/themes_test.go (limited to 'vrsite') diff --git a/vrsite/app_test.go b/vrsite/app_test.go index 4db74d4..403903a 100644 --- a/vrsite/app_test.go +++ b/vrsite/app_test.go @@ -31,6 +31,12 @@ func testSite(t *testing.T) *Site { return s } +// testThemes stands in for whatever themes this machine happens to have, so +// the picker's two files are the same in every test run. +func testThemes(dark, light *Theme) *themeSet { + return themesFrom([]*Theme{dark, light}, dark, light) +} + func TestBuildModel(t *testing.T) { s := testSite(t) if s.Change != "aaa" { @@ -51,6 +57,42 @@ func TestBuildModel(t *testing.T) { } } +func TestNoteThreadRecursesThroughRepliesAndMentions(t *testing.T) { + entries := parseLog([]byte(logLines( + `{"op":"note","session":"s","agent":"a","change":"aaa","note_id":1,"text":"root"}`, + `{"op":"note","session":"s","agent":"a","change":"aaa","note_id":2,"reply_to":1,"text":"child"}`, + `{"op":"note","session":"s","agent":"a","change":"aaa","note_id":3,"reply_to":2,"text":"grandchild"}`, + `{"op":"note","session":"s","agent":"a","change":"aaa","note_id":4,"text":"[mentions root](../note/1.html)"}`, + `{"op":"note","session":"s","agent":"a","change":"aaa","note_id":5,"reply_to":3,"text":"[also mentions child](note/2)"}`, + `{"op":"note","session":"s","agent":"a","change":"aaa","note_id":6,"reply_to":5,"text":"arbitrarily deeper"}`, + ))) + s := buildModel("test", entries, "") + root, child := s.NoteByID(1), s.NoteByID(2) + if len(root.Replies) != 1 || root.Replies[0].ID != 2 || len(root.Mentions) != 1 || root.Mentions[0].ID != 4 { + t.Fatalf("root edges = replies %+v, mentions %+v", root.Replies, root.Mentions) + } + if len(child.Mentions) != 1 || child.Mentions[0].ID != 5 { + t.Fatalf("child mentions = %+v", child.Mentions) + } + + thread := s.NoteThread(1) + if thread == nil || len(thread.Children) != 2 || thread.Children[0].Via != "reply" || thread.Children[1].Via != "mention" { + t.Fatalf("root thread = %+v", thread) + } + // 1 → 2 → 3 → 5 → 6 proves the renderer's data is recursive rather + // than a fixed parent/child pair. + deep := thread.Children[0].Children[0].Children[0] + if deep.Note.ID != 5 || len(deep.Children) != 1 || deep.Children[0].Note.ID != 6 { + t.Fatalf("deep reply branch = %+v", deep) + } + // Note 5 also mentions note 2. It is fully rendered under note 3, then left + // as a DAG reference on the second path instead of recursing twice. + join := thread.Children[0].Children[1] + if join.Note.ID != 5 || !join.Reference || join.Via != "mention" { + t.Errorf("DAG join = %+v", join) + } +} + // A ninth agent gets no color instead of overflowing the uint8 mask (and the // per-mask CSS it would generate). func TestAgentColorsAreBounded(t *testing.T) { @@ -326,7 +368,8 @@ func testServer(t *testing.T) (*server, string, string) { if err := os.WriteFile(logPath, []byte(logLines(entry, note)), 0o644); err != nil { t.Fatal(err) } - sv := newServer(logPath, repo, "test", "tester", loadTheme("", "", "dark"), loadTheme("", "", "light")) + dark, light := loadTheme("", "", "dark"), loadTheme("", "", "light") + sv := newServer(logPath, repo, "test", "tester", dark, light, testThemes(dark, light)) return sv, logPath, change } @@ -352,7 +395,9 @@ func TestServerPages(t *testing.T) { {"/code/hello.txt", "three"}, {"/jj", "revset"}, {"/change/" + change, "first change"}, - {"/style.css", "--am1"}, + {"/style.css", ".am1 {"}, + {"/themes.css", `:root[data-theme="zed/one-light"]`}, + {"/themes.js", "VRSITE_THEMES"}, {"/app.js", "vrsite-mode"}, {"/htmx.min.js", "htmx"}, } { @@ -553,7 +598,7 @@ func TestServerHoldsNoStateOfItsOwn(t *testing.T) { h.ServeHTTP(httptest.NewRecorder(), r) // a second server that has only ever seen the log file - fresh := newServer(logPath, sv.repo, "test", "tester", sv.theme, sv.light).handler() + fresh := newServer(logPath, sv.repo, "test", "tester", sv.theme, sv.light, sv.themes).handler() for _, path := range []string{"/", "/notes", "/notes?scope=all", "/code/hello.txt", "/files", "/tree", "/change/" + change} { was := agoRe.ReplaceAllString(get(t, h, path, false).Body.String(), "AGO") @@ -662,6 +707,43 @@ func TestAddNoteAppendsToLog(t *testing.T) { } } +func TestFocusedNoteRendersAndAppendsAnArbitrarilyDeepThread(t *testing.T) { + sv, logPath, change := testServer(t) + h := sv.handler() + post := func(values url.Values) { + t.Helper() + values.Set("change", change) + r := httptest.NewRequest("POST", "/notes", strings.NewReader(values.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != http.StatusSeeOther { + t.Fatalf("POST /notes = %d: %s", w.Code, w.Body) + } + } + post(url.Values{"reply_to": {"1"}, "text": {"child from focused view"}}) + post(url.Values{"reply_to": {"2"}, "text": {"grandchild from focused view"}}) + post(url.Values{"text": {"[backlink to root](/note/1)"}}) + + entries := parseLog(mustRead(t, logPath)) + if len(entries) < 5 || entries[len(entries)-3].ReplyTo != 1 || entries[len(entries)-2].ReplyTo != 2 || entries[len(entries)-1].ReplyTo != 0 { + t.Fatalf("thread entries were not appended as explicit replies plus a plain mention: %+v", entries) + } + body := get(t, h, "/note/1", false).Body.String() + for _, want := range []string{ + "child from focused view", "grandchild from focused view", "backlink to root", + `class="threadnode via-reply"`, `class="threadnode via-mention"`, + `name="reply_to" value="2"`, "reply to #2", "Replies & mentions", + } { + if !strings.Contains(body, want) { + t.Errorf("focused thread is missing %q", want) + } + } + if strings.Index(body, "grandchild from focused view") < strings.Index(body, "child from focused view") { + t.Error("grandchild rendered outside its parent branch") + } +} + func TestAddNoteRejectsCrossOriginAndEmptyText(t *testing.T) { sv, _, _ := testServer(t) h := sv.handler() @@ -683,6 +765,16 @@ func TestAddNoteRejectsCrossOriginAndEmptyText(t *testing.T) { if w.Code != http.StatusBadRequest { t.Errorf("empty note = %d, want 400", w.Code) } + + r = httptest.NewRequest("POST", "/notes", strings.NewReader(url.Values{ + "text": {"orphan"}, "reply_to": {"999"}, + }.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w = httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != http.StatusBadRequest { + t.Errorf("missing reply target = %d, want 400", w.Code) + } } func TestJJQueries(t *testing.T) { diff --git a/vrsite/assets/app.js b/vrsite/assets/app.js index 104cc85..696041c 100644 --- a/vrsite/assets/app.js +++ b/vrsite/assets/app.js @@ -32,13 +32,202 @@ document.addEventListener("keydown", e => { }); // ── theme ───────────────────────────────────────────────────────────────── -const themesel = document.getElementById("themesel"); -if (themesel) { - themesel.value = localStorage.getItem("vrsite-theme") || "system"; - themesel.addEventListener("change", () => { - localStorage.setItem("vrsite-theme", themesel.value); - if (themesel.value === "system") delete root.dataset.theme; - else root.dataset.theme = themesel.value; +// Every theme this machine has is a :root block in themes.css, so wearing one +// is a single attribute — and the menu can therefore preview as it moves. +// Arrow through it or hover and the page changes under you; enter keeps what +// you are wearing, escape puts back the one you came with. +// +// themes.css and themes.js are fetched the first time the menu opens (the +// stylesheet earlier than that, from the head, when a theme is already +// chosen), so a reader who never switches pays nothing for them. +const pick = document.getElementById("themepick"); +if (pick) { + const btn = document.getElementById("themebtn"), + menu = document.getElementById("thememenu"), + find = document.getElementById("themefind"), + list = document.getElementById("themelist"); + const builtin = [ + {id: "system", name: "system", src: "follows the browser"}, + {id: "dark", name: pick.dataset.dark, src: "built with · dark"}, + {id: "light", name: pick.dataset.light, src: "built with · light"}, + ]; + let cur = localStorage.getItem("vrsite-theme") || "system"; + let active = null, filled = false, pending = null; + + const wear = id => { + if (id === "system") delete root.dataset.theme; + else root.dataset.theme = id; + }; + // the name of a theme we may not have the menu for yet: kept beside the id + // so the button says something true before themes.js has loaded + const label = id => { + const b = builtin.find(t => t.id === id); + if (b) return b.name; + const t = (window.VRSITE_THEMES || []).find(t => t.id === id); + return t ? t.name : localStorage.getItem("vrsite-theme-name") || + id.replace(/^[^/]*\//, "").replace(/[-_]/g, " "); + }; + btn.textContent = label(cur); + + // themes.css is a link and themes.js a script, not a fetch: the static + // export is opened from the filesystem, where fetch() is not allowed + const ensure = () => { + if (!document.getElementById("themescss")) { + const l = document.createElement("link"); + l.rel = "stylesheet"; l.id = "themescss"; l.href = pick.dataset.css; + document.head.append(l); + } + if (window.VRSITE_THEMES) return Promise.resolve(); + if (!pending) pending = new Promise(done => { + const s = document.createElement("script"); + s.src = pick.dataset.js; + s.onload = s.onerror = done; + document.head.append(s); + }); + return pending; + }; + + const row = t => { + const li = document.createElement("li"); + li.setAttribute("role", "option"); + li.id = "themeopt-" + list.children.length; + li.dataset.id = t.id; + li.dataset.q = (t.name + " " + t.src + " " + t.id).toLowerCase(); + const sw = document.createElement("span"); + sw.className = t.sw ? "sw" : "sw none"; // empty, but the names still line up + for (const c of t.sw || []) { + const chip = document.createElement("i"); + chip.style.background = c; + sw.append(chip); + } + li.append(sw); + const nm = document.createElement("span"); + nm.className = "nm"; + nm.textContent = t.name; + const src = document.createElement("span"); + src.className = "src"; + src.textContent = t.src; + li.append(nm, src); + return li; + }; + + const fill = () => { + if (filled) return; + filled = true; + // the two the site was built with are usually in the menu as well, so the + // rows for them can wear the same swatch — and "system", both at once + const all = window.VRSITE_THEMES || []; + const swOf = id => (all.find(t => t.id === id) || {}).sw; + const dsw = swOf(pick.dataset.darkid), lsw = swOf(pick.dataset.lightid); + builtin[0].sw = dsw && lsw && [...dsw.slice(0, 3), ...lsw.slice(0, 3)]; + builtin[1].sw = dsw; + builtin[2].sw = lsw; + for (const t of builtin) list.append(row(t)); + for (const t of window.VRSITE_THEMES || []) + list.append(row({...t, src: t.src + (t.dark ? " · dark" : " · light")})); + if (list.children.length === builtin.length) { + const p = document.createElement("li"); + p.className = "meta"; + p.textContent = "no zed or helix themes found on this machine"; + list.append(p); + } + }; + + const setActive = (li, preview = true) => { + if (!li || li === active) return; + if (active) { + active.classList.remove("on"); + active.removeAttribute("aria-selected"); + } + active = li; + li.classList.add("on"); + li.setAttribute("aria-selected", "true"); + find.setAttribute("aria-activedescendant", li.id); + li.scrollIntoView({block: "nearest"}); + if (preview) wear(li.dataset.id); + }; + + const rows = () => [...list.querySelectorAll("li[data-id]:not([hidden])")]; + const rowFor = id => list.querySelector('li[data-id="' + CSS.escape(id) + '"]'); + + const filter = () => { + const q = find.value.trim().toLowerCase(); + for (const li of list.children) + li.hidden = !!q && !(li.dataset.q || "").includes(q); + if (!active || active.hidden) setActive(rows()[0]); + }; + find.addEventListener("input", filter); + + const open = () => { + if (!menu.hidden) return; + menu.hidden = false; + btn.setAttribute("aria-expanded", "true"); + find.value = ""; + find.focus(); + ensure().then(() => { + fill(); + filter(); + for (const li of list.querySelectorAll("li.cur")) li.classList.remove("cur"); + const here = rowFor(cur); + if (here) here.classList.add("cur"); + setActive(here || rows()[0], false); + }); + }; + + // leaving the menu either keeps what is on screen or puts back what was + // there before it opened — nothing is stored until it is kept + const close = keep => { + if (menu.hidden) return; + if (keep && active) { + cur = active.dataset.id; + localStorage.setItem("vrsite-theme", cur); + localStorage.setItem("vrsite-theme-name", label(cur)); + btn.textContent = label(cur); + for (const li of list.querySelectorAll("li.cur")) li.classList.remove("cur"); + active.classList.add("cur"); + } + wear(cur); + menu.hidden = true; + btn.setAttribute("aria-expanded", "false"); + }; + + btn.addEventListener("click", () => (menu.hidden ? open() : close(false))); + // mousemove, not mouseover: arrowing scrolls rows under a still pointer, + // and the pointer must not then take the selection back + list.addEventListener("mousemove", e => { + const li = e.target.closest("li[data-id]"); + if (li) setActive(li); + }); + list.addEventListener("click", e => { + const li = e.target.closest("li[data-id]"); + if (li) { setActive(li); close(true); } + }); + find.addEventListener("keydown", e => { + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + e.preventDefault(); + const all = rows(); + if (!all.length) return; + const i = all.indexOf(active), step = e.key === "ArrowDown" ? 1 : -1; + setActive(all[(i + step + all.length) % all.length]); + } else if (e.key === "Enter") { + e.preventDefault(); + close(true); + btn.focus(); + } else if (e.key === "Escape") { + e.preventDefault(); + close(false); + btn.focus(); + } + }); + document.addEventListener("click", e => { + if (!menu.hidden && !pick.contains(e.target)) close(false); + }); + document.addEventListener("keydown", e => { + if (e.key === "Escape") close(false); + if (e.key === "T" && !e.metaKey && !e.ctrlKey && !e.altKey && !typing(e)) { + e.preventDefault(); // or the T lands in the filter we just focused + open(); + } }); } @@ -165,6 +354,10 @@ for (const w of document.querySelectorAll(".notewrap[data-href]")) { // ?note=N means someone followed a note here rather than opening the file: // show what they came for — notes focus (coverage hides the note), the card // flashed in the pane, the lines it covers marked. +// A permalink is the same arrival with nothing to scroll to: the page is the +// note, so it only needs the focus that shows notes at all. +if (document.body.dataset.kind === "note") setMode("notes"); + const want = new URLSearchParams(location.search).get("note"); if (want && document.body.dataset.kind === "file" && /^\d+$/.test(want)) { setMode("notes"); @@ -200,7 +393,10 @@ if (filters) { from = val("from"), to = val("to"); let shown = 0; for (const art of document.querySelectorAll("#notelist .note")) { - const d = art.dataset, body = art.querySelector("p").textContent.toLowerCase(); + // the whole rendered body, not its first

: a note is markdown, so it + // may open with a list or a code fence and run to several blocks + const d = art.dataset, el = art.querySelector(".body"); + const body = (el ? el.textContent : "").toLowerCase(); const ok = (!text || body.includes(text)) && (!file || (d.file || "").toLowerCase().includes(file)) && diff --git a/vrsite/assets/style.css.tmpl b/vrsite/assets/style.css.tmpl index f57e454..9418087 100644 --- a/vrsite/assets/style.css.tmpl +++ b/vrsite/assets/style.css.tmpl @@ -111,7 +111,7 @@ nav .brand { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .note.away:hover { opacity: 1; } .atchg.away { color: var(--note); } .atchg.away code { color: inherit; } -.find, .filters input, .filters select, .notform input, .notform textarea, .jjform input { +.find, .filters input, .filters select, .notform input, .notform textarea, .jjform input, .thememenu input { background: var(--bg); color: var(--fg); border: 1px solid var(--border); border-radius: 5px; font: inherit; font-size: 12px; padding: 3px 6px; @@ -153,11 +153,44 @@ nav > footer { margin-top: 14px; border-top: 1px solid var(--border); padding-top: 8px; display: flex; justify-content: space-between; align-items: center; gap: 8px; } -nav > footer select { + +/* ── the theme picker ────────────────────────────────────────── + Every theme on the machine, previewed as you move through it. The + menu is fixed rather than absolute so the scrolling tree above it + cannot clip it. */ +#themebtn { background: var(--panel); color: var(--muted); border: 1px solid var(--border); border-radius: 4px; - font: inherit; font-size: 11px; padding: 1px 2px; -} + font: inherit; font-size: 11px; padding: 1px 6px; cursor: pointer; + max-width: 130px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +#themebtn:hover, #themebtn[aria-expanded="true"] { color: var(--fg); background: var(--hover); } +.thememenu { + position: fixed; left: 8px; bottom: 8px; z-index: 20; + width: 264px; max-height: min(60vh, 520px); + display: flex; flex-direction: column; gap: 6px; + background: var(--panel); border: 1px solid var(--border); + border-radius: 7px; padding: 8px; + box-shadow: 0 8px 30px rgb(0 0 0 / 0.35); +} +.thememenu input { width: 100%; } +.thememenu ul { list-style: none; margin: 0; padding: 0; overflow: auto; flex: 1; } +.thememenu li { + display: flex; align-items: center; gap: 7px; + padding: 3px 5px; border-radius: 4px; cursor: pointer; + font-size: 12px; color: var(--fg); +} +/* the menu is hidden until it is opened and the filter hides rows: both are + the hidden attribute, which a display of our own would otherwise outrank */ +.thememenu[hidden], .thememenu li[hidden] { display: none; } +.thememenu li.on { background: var(--hover); } +.thememenu li .nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.thememenu li .src { margin-left: auto; color: var(--muted); font-size: 10px; white-space: nowrap; } +.thememenu li.cur .nm::after { content: " ✓"; color: var(--accent); } +.thememenu .sw { display: flex; flex: none; border: 1px solid var(--border); border-radius: 3px; overflow: hidden; } +.thememenu .sw i { display: block; width: 7px; height: 14px; } +.thememenu .sw.none { width: 42px; border-color: transparent; } +.thememenu .hint { margin: 0; font-size: 10px; } /* files.html full tree */ .filetree { font: 13px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; padding-bottom: 40vh; } @@ -256,14 +289,115 @@ aside summary { cursor: pointer; color: var(--muted); } .note header b { margin-left: auto; color: var(--cov); font-weight: 600; } .note p { margin: 6px 0 0; line-height: 1.45; } .note.flash { animation: flash 1.2s ease-out; } + +/* A note body is markdown. It is prose about code inside a card that is + itself only ~320px wide in the columns, so every block here is tuned down + from document scale: headings barely outrank the text, rules and quotes are + hairlines, and nothing may push the card wider than its column. The + selectors carry .body so they outrank .turn p in the conversation view, + where notes are cards too. */ +.note .body > :first-child { margin-top: 6px; } +.note .body p, +.note .body ul, .note .body ol, .note .body blockquote, +.note .body pre, .note .body table { margin: 6px 0 0; } +.note .body p { line-height: 1.45; white-space: normal; } +.note .body :is(h1, h2, h3, h4, h5, h6) { + margin: 10px 0 0; font-size: 1em; font-weight: 600; line-height: 1.3; +} +.note .body h1, .note .body h2 { color: var(--fg); font-size: 1.05em; } +.note .body ul, .note .body ol { padding-left: 1.3em; } +.note .body li { margin: 2px 0; line-height: 1.45; } +.note .body li > p { margin: 0; } +.note .body li::marker { color: var(--muted); } +.note .body blockquote { + border-left: 2px solid var(--border); padding-left: 8px; color: var(--muted); +} +.note .body hr { border: none; border-top: 1px solid var(--border); margin: 10px 0 0; } +.note .body code { background: var(--hover); border-radius: 3px; padding: 0 3px; } +.note .body a { overflow-wrap: anywhere; } +.note .body img { max-width: 100%; height: auto; } +/* a fenced block: the code view's spans without its line-number scaffolding */ +.note .body pre.mdcode { + background: var(--panel); border: 1px solid var(--border); border-radius: 4px; + padding: 6px 8px; overflow-x: auto; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.92em; + line-height: 1.4; tab-size: 4; +} +.note .body pre.mdcode code { background: none; padding: 0; font-size: inherit; } +.note .body table { border-collapse: collapse; display: block; overflow-x: auto; font-size: 0.95em; } +.note .body :is(th, td) { border: 1px solid var(--border); padding: 2px 6px; text-align: left; } +.note .body th { color: var(--muted); font-weight: 600; } +.note .body input[type="checkbox"] { margin-right: 4px; vertical-align: -1px; } @keyframes flash { 0% { background: color-mix(in srgb, var(--cov) 30%, var(--bg)); } 100% { background: var(--bg); } } .note footer { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 6px; font-size: 11px; } .note footer code { color: var(--muted); } .note .model { opacity: 0.85; } .note .conv { margin-left: auto; } +.note .parent { color: var(--muted); } +.note .relations { + color: var(--note); text-decoration: none; + background: var(--note-bg); border-radius: 3px; padding: 0 4px; +} +.note .relations:hover { color: var(--accent); text-decoration: underline; } .notecols { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 0 14px; } +/* the permalink: the one link on a card that names nothing but the note, so + it is the one to hand someone else. Quiet until wanted. */ +.note .perma { color: var(--ln); font-variant-numeric: tabular-nums; text-decoration: none; } +.note .perma:hover { color: var(--accent); text-decoration: underline; } +.note .perma.self { color: var(--muted); } + +/* ── one note, on its own page ───────────────────────────────── + What a shared link opens: the note, then the lines it is about. Wider + than a card in a list — nothing is competing for the room here. */ +.notepage { padding-bottom: 30vh; } +.notepage .onenote .note { margin: 0 0 14px; max-width: 760px; } +.notepage .exhead { margin: 0 0 6px; } +.notepage .excerpt { border: 1px solid var(--border); border-radius: 6px; background: var(--panel); } +.notepage .excerpt ol.code { padding: 6px 0; } +.notepage .excerpt ol.code:last-of-type { padding-bottom: 6px; } +.notepage .more { margin-top: 20px; } +.filehead h1 .lns { color: var(--muted); font-weight: 400; } +.filehead .kind { color: var(--note); font-style: normal; border: 1px solid var(--note); border-radius: 3px; padding: 0 4px; font-size: 11px; } +.filehead .ago { color: var(--muted); } +/* the excerpt exists to show what the note is about, so its lines stay + marked in either focus — the toggle is about reading a whole file */ +:root[data-mode="trace"] .excerpt ol.code li.noted { background: var(--note-bg); cursor: default; } +:root[data-mode="trace"] .excerpt ol.code li.noted::before { color: color-mix(in srgb, var(--note) 55%, var(--ln)); } + +/* Incoming reply and mention edges share one recursively nested discussion. + The narrow rail is intentionally HN-like: enough hierarchy to scan without + turning the existing cards into a second design system. */ +.discussion { max-width: 820px; margin-top: 24px; scroll-margin-top: 16px; } +.discussion h2 { margin-bottom: 6px; } +.discussion h2 .meta { font-size: 11px; font-weight: 400; margin-left: 6px; } +.threadnode { + display: grid; grid-template-columns: 20px minmax(0, 1fr); + align-items: stretch; +} +.threadedge { position: relative; color: var(--muted); text-align: center; font-size: 11px; } +.threadedge::after { + content: ""; position: absolute; top: 20px; bottom: 0; left: 50%; + border-left: 1px solid var(--border); +} +.threadedge span { position: relative; z-index: 1; background: var(--bg); } +.via-mention > .threadedge { color: var(--note); } +.via-mention > .threadedge::after { border-left-style: dashed; border-left-color: var(--note); } +.threadcontent { min-width: 0; } +.threadcontent > .note { margin: 7px 0; } +.threadchildren { min-width: 0; } +.threadref { + margin: 8px 0; padding: 5px 8px; border: 1px dashed var(--border); + border-radius: 5px; background: var(--panel); +} +.replybox { max-width: 760px; margin: 4px 0 10px; } +.replybox > summary { cursor: pointer; color: var(--muted); font-size: 11px; } +.replybox[open] > summary { color: var(--accent); } +.replyform { margin: 5px 0 10px; padding-left: 8px; border-left: 2px solid var(--border); } +.replyform textarea { width: 100%; box-sizing: border-box; } +.threadnode .replybox { margin-left: 1px; } + /* ── conversation ──────────────────────────────────────────── */ .convpage { padding: 0 16px 40vh; max-width: 1100px; } .turn { @@ -403,13 +537,22 @@ table.changed .st { color: var(--muted); font-size: 11px; width: 7ch; } table.changed .st-added { color: var(--accent); } table.changed .st-deleted { color: var(--note); } -/* ── agent coverage colors (bitmask -> per-scheme mixed color) */ -{{range .MaskList}}.am{{.}} { --cov: var(--am{{.}}); } +/* ── agent coverage colors ────────────────────────────────── + A bitmask is its agents' colors averaged. The blend is left to the + browser so these rules hold for any theme: a theme only has to name + --ag0..7, which every theme in themes.css does. */ +{{range .Masks}}.am{{.Mask}} { --cov: {{.Mix}}; } {{end}} -/* ── syntax (zed theme, colors are per-scheme vars) ────────── */ -{{range .Syntax}}.s-{{.Class}} { color: var(--s-{{.Class}}, inherit);{{if eq .Style "italic"}} font-style: italic;{{end}}{{if .Weight}} font-weight: {{.Weight}};{{end}} } +/* ── syntax (theme colors, italics and weights are vars too, so + switching theme switches those with the colors) ─────────── */ +{{range .Syntax}}.s-{{.Class}} { color: var(--s-{{.Class}}, inherit); font-style: var(--s-{{.Class}}-fs, inherit); font-weight: var(--s-{{.Class}}-fw, inherit); } {{end}} @media (max-width: 1100px) { body, body.with-aside { grid-template-columns: 1fr; } nav, aside { position: static; height: auto; border: none; border-bottom: 1px solid var(--border); } } +@media (max-width: 620px) { + .threadnode { grid-template-columns: 14px minmax(0, 1fr); } + .discussion h2 .meta { display: block; margin: 2px 0 0; } + .replyform { padding-left: 5px; } +} diff --git a/vrsite/bench_test.go b/vrsite/bench_test.go index c3eac0c..6e3f907 100644 --- a/vrsite/bench_test.go +++ b/vrsite/bench_test.go @@ -268,7 +268,7 @@ func BenchmarkRender(b *testing.B) { // template execution for an already-rendered file page func BenchmarkExecute(b *testing.B) { hl := benchHL(b) - tpl := newTemplates() + tpl := newTemplates(hl) site := &Site{ Title: "bench", Files: map[string]*File{}, Theme: loadTheme("", "", "dark"), Light: loadTheme("", "", "light"), 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 is quoted from [the file](https://go.dev)"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := string(m.render(c.in)) + if strings.Contains(got, "

") || strings.Contains(got, " - {{if .Live}}{{end}} @@ -35,11 +43,20 @@ files{{if .Live}} · jj{{end}} - +
+ + +
@@ -47,6 +64,7 @@ {{if .Err}}

{{.Err}}

{{end}} {{if eq .Kind "file"}}{{template "page-file" .}} {{else if eq .Kind "notes"}}{{template "page-notes" .}} +{{else if eq .Kind "note"}}{{template "page-note" .}} {{else if eq .Kind "files"}}{{template "page-files" .}} {{else if eq .Kind "jj"}}{{template "page-jj" .}} {{else if eq .Kind "change"}}{{template "page-change" .}} diff --git a/vrsite/templates/page-note.tmpl b/vrsite/templates/page-note.tmpl new file mode 100644 index 0000000..8cd0e64 --- /dev/null +++ b/vrsite/templates/page-note.tmpl @@ -0,0 +1,54 @@ +{{/* One note, on its own — the page a shared permalink opens. + + It has to stand up for a reader who has never seen this site: what was + said, about which lines of which file, at which change, and the code + itself, right there. Everything else on the page is a way further in. */}} +{{define "page-note"}} +{{$n := .Note}} +
+

{{if $n.File}}{{base $n.File}}{{lines $n}}{{else}}Note{{end}}

+

+ note #{{$n.ID}} by {{$n.Agent.Short}}{{if $n.Kind}} · {{$n.Kind}}{{end}} + {{if not $n.At.IsZero}} · + ({{$n.Ago}}){{end}} + {{if $n.Change}} · read at + {{if .Live}}{{short $n.Change}} + {{else}}{{short $n.Change}}{{end}} + {{end}} +

+ {{if ne $n.File (base $n.File)}}

{{$n.File}}

{{end}} +
+ +
+
{{template "note" (dict "N" $n "P" $)}}
+ + {{$open := .HasCode $n.File}} + {{if .Excerpt}} +

lines {{.ExFrom}}–{{.ExTo}} of {{$n.File}}{{if $open}} · + open the whole file →{{end}}

+
{{.Excerpt}}
+ {{else if .ExErr}} +

{{.ExErr}}

+ {{else if $n.File}} +

this note is about the whole file{{if $open}} — + open {{base $n.File}} →{{end}}

+ {{else}} +

this note is not pinned to a file

+ {{end}} + + {{if .Live}}{{template "replyform" (dict "N" $n "P" $)}}{{end}} + +
+

Replies & mentions + {{len $n.Replies}} repl{{if eq (len $n.Replies) 1}}y{{else}}ies{{end}} · {{len $n.Mentions}} mention{{if ne (len $n.Mentions) 1}}s{{end}} +

+ {{if .Thread.Children}} +
{{range .Thread.Children}}{{template "threadnode" (dict "Node" . "P" $)}}{{end}}
+ {{else}} +

no notes reply to or mention this one yet

+ {{end}} +
+ +

← every note in the log

+
+{{end}} diff --git a/vrsite/theme.go b/vrsite/theme.go index fc63de1..91eedbe 100644 --- a/vrsite/theme.go +++ b/vrsite/theme.go @@ -1,12 +1,18 @@ // 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" - "path/filepath" + "sort" + "strings" ) type SyntaxStyle struct { @@ -16,6 +22,7 @@ type SyntaxStyle struct { } 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 @@ -26,22 +33,16 @@ type Theme struct { // 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 { - home, _ := os.UserHomeDir() - m, _ := filepath.Glob(filepath.Join(home, ".config", "zed", "themes", "*.json")) - for _, path := range m { + for _, path := range globAll(zedThemeGlobs()) { b, err := os.ReadFile(path) if err != nil { continue } - var family struct { - Themes []struct { - Appearance string `json:"appearance"` - } `json:"themes"` - } - if json.Unmarshal(b, &family) != nil { + fam, err := parseZedFamily(b) + if err != nil { continue } - for _, t := range family.Themes { + for _, t := range fam { if t.Appearance == appearance { return path } @@ -61,6 +62,31 @@ func loadTheme(path, name, appearance string) *Theme { 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 { @@ -69,76 +95,168 @@ func loadTheme(path, name, appearance string) *Theme { Style map[string]json.RawMessage `json:"style"` } `json:"themes"` } - if err := json.Unmarshal(b, &family); err != nil || len(family.Themes) == 0 { - fatal("bad zed theme %s: %v", path, err) + if err := json.Unmarshal(b, &family); err != nil { + return nil, err } - th := family.Themes[0] - if name != "" { - for _, t := range family.Themes { - if t.Name == name { - th = t + 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 "" } - } else { - for _, t := range family.Themes { - if t.Appearance == appearance { - th = t - break + 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) } - get := func(keys ...string) string { - for _, k := range keys { - var s string - if raw, ok := th.Style[k]; ok && json.Unmarshal(raw, &s) == nil && s != "" { - return s + 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 "" + return false } - t := &Theme{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) - var players []struct { - Cursor string `json:"cursor"` - } - json.Unmarshal(th.Style["players"], &players) - // agent colors: drop player colors that vanish against fg/bg or collide - // with the note color, then order greedily for mutual distance so the - // first few agents contrast most - var usable []string - for _, p := range players { - c := hex6(p.Cursor) - if c != "" && colorDist(c, hex6(t.Fg)) > 60 && colorDist(c, hex6(t.Bg)) > 60 && colorDist(c, hex6(t.Note)) > 60 { + 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 { - best, bestScore := 0, -1 - for i, c := range usable { - score := 1 << 24 // first pick: keep theme order - for _, p := range t.Players { - if d := colorDist(c, p); d < score { - score = d + 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 } } - 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) } } - t.Players = append(t.Players, usable[best]) - usable = append(usable[:best], usable[best+1:]...) } - return t + sort.Strings(keys) + return keys } func colorDist(a, b string) int { @@ -161,34 +279,63 @@ func (t *Theme) Player(i int) string { 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 mixColors(cols []string) string { - if len(cols) == 1 { - return cols[0] +func rgb(c string) (r, g, b int, ok bool) { + c = hex6(c) + if len(c) != 7 || c[0] != '#' { + return 0, 0, 0, false } - var r, g, b int - n := 0 - for _, c := range cols { - c = hex6(c) - if len(c) != 7 { - continue - } - var cr, cg, cb int - fmt.Sscanf(c[1:], "%02x%02x%02x", &cr, &cg, &cb) - r += cr - g += cg - b += cb - n++ + 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 } - if n == 0 { - return "#888888" + 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 } - return fmt.Sprintf("#%02x%02x%02x", r/n, g/n, b/n) + 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, ".", "-") } diff --git a/vrsite/themes.go b/vrsite/themes.go new file mode 100644 index 0000000..f185fab --- /dev/null +++ b/vrsite/themes.go @@ -0,0 +1,445 @@ +// themes.go — the themes this machine already has. Zed keeps families of them +// in JSON, Helix keeps one TOML each with a palette and single inheritance; +// both are read into the same *Theme, so the browser can wear either. +// +// Discovery is best-effort: a theme that will not parse is skipped, never +// fatal. What the site is built with stays what -theme and -light-theme say — +// these only widen what the picker can offer. +package main + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + + "github.com/BurntSushi/toml" +) + +// themeSet is those themes plus the two files the browser switches with: one +// CSS block per theme, and the list the picker draws from. +type themeSet struct { + List []*Theme + CSS []byte // themes.css — one :root[data-theme="…"] block per theme + JS []byte // themes.js — window.VRSITE_THEMES, the picker's menu +} + +func newThemeSet(dark, light *Theme) *themeSet { + return themesFrom(discoverThemes(), dark, light) +} + +// themesFrom writes the two files for a given list of themes. The keys the +// site paints with come from the themes it was built with, since those are the +// ones the stylesheet has classes for. +func themesFrom(list []*Theme, dark, light *Theme) *themeSet { + ts := &themeSet{List: list} + keys := syntaxKeys(dark, light) + var css, js bytes.Buffer + writeThemesCSS(&css, ts.List, keys) + writeThemesJS(&js, ts.List) + ts.CSS, ts.JS = css.Bytes(), js.Bytes() + return ts +} + +func (ts *themeSet) count(source string) int { + n := 0 + for _, t := range ts.List { + if t.Source == source { + n++ + } + } + return n +} + +// discoverThemes reads every theme both editors can see, user directories +// first so a theme you installed wins the id over one that merely ships. +func discoverThemes() []*Theme { + var out []*Theme + seen := map[string]bool{} + add := func(t *Theme) { + if t == nil || t.ID == "" || t.Bg == "" || t.Fg == "" || seen[t.ID] { + return + } + seen[t.ID] = true + out = append(out, t) + } + 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 { + add(t) + } + } + dirs := helixThemeDirs() + for _, dir := range dirs { + for _, path := range globAll([]string{filepath.Join(dir, "*.toml")}) { + add(loadHelixTheme(path, dirs)) + } + } + sort.Slice(out, func(i, j int) bool { + if out[i].Source != out[j].Source { + return out[i].Source < out[j].Source + } + return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) + }) + return out +} + +func globAll(pats []string) []string { + var out []string + for _, p := range pats { + m, _ := filepath.Glob(p) + sort.Strings(m) + out = append(out, m...) + } + return out +} + +func configHome() string { + if d := os.Getenv("XDG_CONFIG_HOME"); d != "" { + return d + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".config") +} + +func dataHome() string { + if d := os.Getenv("XDG_DATA_HOME"); d != "" { + return d + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".local", "share") +} + +// zedThemeGlobs: themes you dropped in yourself, then the ones extensions +// brought. Zed's own built-ins live inside the binary, so they are not here. +func zedThemeGlobs() []string { + home, _ := os.UserHomeDir() + mac := filepath.Join(home, "Library", "Application Support", "Zed") + return []string{ + filepath.Join(configHome(), "zed", "themes", "*.json"), + filepath.Join(mac, "themes", "*.json"), + filepath.Join(dataHome(), "zed", "extensions", "installed", "*", "themes", "*.json"), + filepath.Join(mac, "extensions", "installed", "*", "themes", "*.json"), + } +} + +// helixThemeDirs: yours first, then whichever runtime the installed hx uses — +// found the way helix itself looks, by walking out from the binary. +func helixThemeDirs() []string { + home, _ := os.UserHomeDir() + dirs := []string{filepath.Join(configHome(), "helix", "themes")} + var runtimes []string + if r := os.Getenv("HELIX_RUNTIME"); r != "" { + runtimes = append(runtimes, r) + } + runtimes = append(runtimes, + filepath.Join(configHome(), "helix", "runtime"), + filepath.Join(dataHome(), "helix", "runtime")) + if bin, err := exec.LookPath("hx"); err == nil { + if bin, err = filepath.EvalSymlinks(bin); err == nil { + dir := filepath.Dir(bin) + runtimes = append(runtimes, + filepath.Join(dir, "runtime"), + filepath.Join(filepath.Dir(dir), "lib", "helix", "runtime"), + filepath.Join(filepath.Dir(dir), "share", "helix", "runtime")) + } + } + runtimes = append(runtimes, + "/usr/lib/helix/runtime", "/usr/share/helix/runtime", + "/usr/local/lib/helix/runtime", "/opt/homebrew/opt/helix/libexec/runtime", + filepath.Join(home, ".cargo", "share", "helix", "runtime")) + seen := map[string]bool{} + for _, r := range runtimes { + d := filepath.Join(r, "themes") + if seen[d] { + continue + } + seen[d] = true + if fi, err := os.Stat(d); err == nil && fi.IsDir() { + dirs = append(dirs, d) + } + } + return dirs +} + +// ── helix ───────────────────────────────────────────────────────────────── +// +// A helix theme is scope -> style, with colors named out of a [palette] at the +// bottom, and may inherit one other theme by name. Inheritance merges both +// halves before anything resolves, which is what lets a child theme restate +// the palette alone (catppuccin_latte is catppuccin_mocha with new colors). + +type helixStyle struct { + fg, bg string // palette names, hex, or one of the sixteen terminal names + italic bool + bold bool +} + +type helixRaw struct { + scopes map[string]helixStyle + palette map[string]string +} + +func loadHelixTheme(path string, dirs []string) *Theme { + raw := readHelixRaw(path, dirs, 0) + if raw == nil { + return nil + } + name := strings.TrimSuffix(filepath.Base(path), ".toml") + return helixTheme(name, raw) +} + +func readHelixRaw(path string, dirs []string, depth int) *helixRaw { + b, err := os.ReadFile(path) + if err != nil { + return nil + } + var doc map[string]any + if err := toml.Unmarshal(b, &doc); err != nil { + return nil + } + raw := &helixRaw{scopes: map[string]helixStyle{}, palette: map[string]string{}} + if parent, _ := doc["inherits"].(string); parent != "" && depth < 8 { + if p := findHelixTheme(parent, dirs); p != "" { + if base := readHelixRaw(p, dirs, depth+1); base != nil { + raw = base + } + } + } + if pal, ok := doc["palette"].(map[string]any); ok { + for k, v := range pal { + if s, ok := v.(string); ok { + raw.palette[k] = s + } + } + } + delete(doc, "palette") + delete(doc, "inherits") + flattenHelix("", doc, raw.scopes) + return raw +} + +func findHelixTheme(name string, dirs []string) string { + if name == "" || strings.ContainsAny(name, "/\\.") { + return "" + } + for _, d := range dirs { + p := filepath.Join(d, name+".toml") + if _, err := os.Stat(p); err == nil { + return p + } + } + return "" +} + +// flattenHelix walks the decoded document into scope -> style. A table is a +// style when it holds only style keys; anything else is a dotted scope name +// someone wrote out as a table, so it keeps descending. +func flattenHelix(prefix string, m map[string]any, into map[string]helixStyle) { + for k, v := range m { + key := k + if prefix != "" { + key = prefix + "." + k + } + switch v := v.(type) { + case string: + into[key] = helixStyle{fg: v} + case map[string]any: + if st, ok := helixStyleOf(v); ok { + into[key] = st + } else { + flattenHelix(key, v, into) + } + } + } +} + +func helixStyleOf(m map[string]any) (helixStyle, bool) { + var st helixStyle + for k, v := range m { + switch k { + case "fg": + st.fg, _ = v.(string) + case "bg": + st.bg, _ = v.(string) + case "modifiers": + mods, _ := v.([]any) + for _, mod := range mods { + switch mod { + case "italic": + st.italic = true + case "bold": + st.bold = true + } + } + case "underline", "underline_style", "underline_color": + // decoration we have no use for, but still a style + default: + return helixStyle{}, false + } + } + return st, true +} + +// the sixteen names a helix theme may use without a palette entry +var termHex = map[string]string{ + "black": "#000000", "red": "#cd3131", "green": "#0dbc79", "yellow": "#e5e510", + "blue": "#2472c8", "magenta": "#bc3fbc", "cyan": "#11a8cd", "gray": "#8a8a8a", + "light-red": "#f14c4c", "light-green": "#23d18b", "light-yellow": "#f5f543", + "light-blue": "#3b8eea", "light-magenta": "#d670d6", "light-cyan": "#29b8db", + "light-gray": "#c0c0c0", "white": "#e5e5e5", +} + +func (r *helixRaw) color(name string) string { + if name == "" || name == "default" { + return "" + } + if c, ok := r.palette[name]; ok { + name = c + } + if strings.HasPrefix(name, "#") { + return hex6(okColor(name)) + } + return termHex[name] +} + +// fg / bg take the first of several scopes that names one, so a theme that +// skips a scope is answered by whatever it does say. Scope names fall back +// along their dots the way helix resolves them. +func (r *helixRaw) fg(scopes ...string) string { return r.pick(true, scopes) } +func (r *helixRaw) bg(scopes ...string) string { return r.pick(false, scopes) } + +func (r *helixRaw) pick(wantFg bool, scopes []string) string { + for _, scope := range scopes { + for key := scope; key != ""; { + if st, ok := r.scopes[key]; ok { + c := st.bg + if wantFg { + c = st.fg + } + if c := r.color(c); c != "" { + return c + } + } + i := strings.LastIndex(key, ".") + if i < 0 { + break + } + key = key[:i] + } + } + return "" +} + +// zed syntax keys helix spells differently; everything else either matches or +// falls back along its dots (punctuation.bracket -> punctuation). +var helixAlias = map[string]string{ + "number": "constant.numeric", + "boolean": "constant.builtin.boolean", + "property": "variable.other.member", + "preproc": "keyword.directive", + "primary": "variable", + "enum": "type.enum", + "string.escape": "constant.character.escape", + "string.regex": "string.regexp", + "variable.special": "variable.builtin", + "comment.doc": "comment.block.documentation", + "link_uri": "markup.link.url", + "link_text": "markup.link.text", + "title": "markup.heading", + "emphasis": "markup.italic", + "emphasis.strong": "markup.bold", +} + +func helixTheme(name string, raw *helixRaw) *Theme { + bg := raw.bg("ui.background", "ui.window") + fg := raw.fg("ui.text", "ui.window") + if bg == "" || fg == "" { + return nil // a theme that paints on the terminal's colors, not ours + } + t := &Theme{ + ID: "helix/" + slug(name), Source: "helix", Name: name, + Appearance: "dark", Bg: bg, Fg: fg, + Syntax: map[string]SyntaxStyle{}, + } + if lum(bg) > 0.5 { + t.Appearance = "light" + } + // A helix theme dresses an editor, not a web page, so it is read for what + // the page needs and fill() closes the rest. The quiet backgrounds come + // first: a selected menu row is often the theme's loudest color. + t.Panel = raw.bg("ui.statusline", "ui.menu", "ui.popup") + t.Hover = raw.bg("ui.cursorline.primary", "ui.selection", "ui.menu.selected") + t.Border = raw.fg("ui.window") + t.LineNo = raw.fg("ui.linenr") + t.Muted = raw.fg("comment", "ui.linenr") + t.Accent = raw.fg("function", "keyword", "type") + t.Note = raw.fg("warning", "diagnostic.warning", "constant.numeric") + + for scope, st := range raw.scopes { + if strings.HasPrefix(scope, "ui.") { + continue + } + if c := raw.color(st.fg); c != "" { + t.Syntax[scope] = syntaxStyle(c, st.italic, st.bold) + } + } + for key, scope := range helixAlias { + if _, ok := t.Syntax[key]; ok { + continue + } + if st, ok := t.Syntax[resolveKey(t, scope)]; ok { + t.Syntax[key] = st + } + } + t.fill() + t.setPlayers(t.syntaxColors()) + return t +} + +func syntaxStyle(color string, italic, bold bool) SyntaxStyle { + s := SyntaxStyle{Color: color} + if italic { + it := "italic" + s.FontStyle = &it + } + if bold { + w := 700.0 + s.FontWeight = &w + } + return s +} + +func first(vals ...string) string { + for _, v := range vals { + if v != "" { + return v + } + } + return "" +} + +// slug is a theme's id in an attribute and a URL: ascii, lowercase, dashed. +func slug(s string) string { + var b strings.Builder + dash := false + for _, r := range strings.ToLower(s) { + if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' { + b.WriteRune(r) + dash = false + } else if !dash && b.Len() > 0 { + b.WriteByte('-') + dash = true + } + } + return strings.Trim(b.String(), "-") +} diff --git a/vrsite/themes_test.go b/vrsite/themes_test.go new file mode 100644 index 0000000..4ef1cca --- /dev/null +++ b/vrsite/themes_test.go @@ -0,0 +1,294 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// everything a helix theme reader has to survive: quoted scope keys, a +// palette, one of the terminal color names, modifiers, and an inline table +// broken over lines the way a formatter leaves it +const helixSrc = ` +# a comment +"ui.background" = { bg = "base" } +"ui.text" = { fg = "text" } +"ui.linenr" = { fg = "muted" } +"ui.statusline" = { fg = "text", bg = "surface" } +"ui.cursorline.primary" = { bg = "highlight" } +"ui.menu.selected" = { fg = "base", bg = "rose" } +"ui.window" = { fg = "muted" } +comment = { fg = "muted", modifiers = ["italic"] } +keyword = { fg = "pine", modifiers = [ + "bold", +] } +string = "gold" +function = { fg = "rose" } +"constant.numeric" = { fg = "iris" } +"variable.other.member" = { fg = "foam" } +"punctuation" = { fg = "muted" } +warning = { fg = "gold" } +type = "white" + +[palette] +base = "#191724" +text = "#e0def4" +muted = "#6e6a86" +surface = "#1f1d2e" +highlight = "#21202e" +pine = "#31748f" +gold = "#f6c177" +rose = "#ebbcba" +iris = "#c4a7e7" +foam = "#9ccfd8" +` + +func writeTheme(t *testing.T, dir, name, body string) string { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return path +} + +func TestHelixTheme(t *testing.T) { + dir := t.TempDir() + path := writeTheme(t, dir, "rose_pine.toml", helixSrc) + th := loadHelixTheme(path, []string{dir}) + if th == nil { + t.Fatal("theme did not load") + } + for _, tc := range []struct{ what, got, want string }{ + {"id", th.ID, "helix/rose-pine"}, + {"source", th.Source, "helix"}, + {"name", th.Name, "rose_pine"}, + {"appearance", th.Appearance, "dark"}, + {"bg", th.Bg, "#191724"}, + {"fg", th.Fg, "#e0def4"}, + {"panel", th.Panel, "#1f1d2e"}, + {"line numbers", th.LineNo, "#6e6a86"}, + {"border", th.Border, "#6e6a86"}, + {"accent", th.Accent, "#ebbcba"}, // function + {"note", th.Note, "#f6c177"}, // warning + {"hover", th.Hover, "#21202e"}, // cursorline, not the loud menu row + {"muted", th.Muted, "#6e6a86"}, // comment + } { + if tc.got != tc.want { + t.Errorf("%s = %q, want %q", tc.what, tc.got, tc.want) + } + } + // syntax keys the site paints with, spelled the way helix spells them + for _, tc := range []struct{ key, want string }{ + {"comment", "#6e6a86"}, + {"keyword", "#31748f"}, + {"string", "#f6c177"}, + {"type", "#e5e5e5"}, // a terminal color name, not a palette one + {"number", "#c4a7e7"}, // zed's name for constant.numeric + {"property", "#9ccfd8"}, // zed's name for variable.other.member + {"punctuation.bracket", "#6e6a86"}, // falls back along its dots + } { + if got := th.syn(tc.key); got != tc.want { + t.Errorf("syntax %s = %q, want %q", tc.key, got, tc.want) + } + } + if s := th.Syntax["comment"]; s.FontStyle == nil || *s.FontStyle != "italic" { + t.Errorf("comment is not italic: %+v", s) + } + if s := th.Syntax["keyword"]; s.FontWeight == nil || *s.FontWeight != 700 { + t.Errorf("keyword is not bold: %+v", s) + } + // agent colors: from the code's own colors, and none of them invisible + if len(th.Players) < 3 { + t.Fatalf("agent colors = %v, want at least 3", th.Players) + } + for _, p := range th.Players { + if colorDist(p, hex6(th.Bg)) <= 60 || colorDist(p, hex6(th.Fg)) <= 60 { + t.Errorf("agent color %s vanishes against the theme", p) + } + } +} + +// A child theme restating only the palette is how helix ships most of its +// families: the parent's scopes have to resolve against the child's colors. +func TestHelixInherits(t *testing.T) { + dir := t.TempDir() + writeTheme(t, dir, "parent.toml", helixSrc) + path := writeTheme(t, dir, "child.toml", ` +inherits = "parent" +comment = { fg = "muted" } + +[palette] +base = "#eff1f5" +text = "#4c4f69" +muted = "#7c7f93" +surface = "#e6e9ef" +highlight = "#dce0e8" +pine = "#1e66f5" +gold = "#df8e1d" +rose = "#ea76cb" +iris = "#8839ef" +foam = "#04a5e5" +`) + th := loadHelixTheme(path, []string{dir}) + if th == nil { + t.Fatal("child theme did not load") + } + if th.Bg != "#eff1f5" || th.Fg != "#4c4f69" { + t.Errorf("inherited scopes kept the parent's palette: bg=%s fg=%s", th.Bg, th.Fg) + } + if th.Appearance != "light" { + t.Errorf("appearance = %q, want light for a pale background", th.Appearance) + } + if got := th.syn("keyword"); got != "#1e66f5" { + t.Errorf("inherited keyword = %q, want the child's pine", got) + } + // the child's own entry wins over the parent's, modifiers and all + if s := th.Syntax["comment"]; s.FontStyle != nil { + t.Errorf("child comment kept the parent's italics: %+v", s) + } +} + +// A theme that names almost nothing still has to dress a whole page. +func TestFillClosesTheGaps(t *testing.T) { + th := &Theme{Bg: "#101010", Fg: "#f0f0f0", Syntax: map[string]SyntaxStyle{ + "function": {Color: "#88c0d0"}, + }} + th.fill() + for _, tc := range []struct{ what, got string }{ + {"panel", th.Panel}, {"border", th.Border}, {"line numbers", th.LineNo}, + {"muted", th.Muted}, {"accent", th.Accent}, {"note", th.Note}, {"hover", th.Hover}, + } { + if okColor(tc.got) == "" { + t.Errorf("%s = %q, want a color", tc.what, tc.got) + } + } + if th.Accent != "#88c0d0" { + t.Errorf("accent = %q, want the theme's own function color", th.Accent) + } + if th.Panel == th.Bg || th.Hover == th.Bg { + t.Errorf("panel %q and hover %q must lift off the background %q", th.Panel, th.Hover, th.Bg) + } + // a panel the text would be lost in is not kept + lost := &Theme{Bg: "#f7f3ee", Fg: "#605a52", Panel: "#605a52"} + lost.fill() + if lost.Panel == "#605a52" { + t.Error("kept a panel the foreground disappears into") + } +} + +func TestZedFamily(t *testing.T) { + fam, err := parseZedFamily([]byte(`{"name":"Fake","themes":[ + {"name":"Fake Dark","appearance":"dark","style":{ + "editor.background":"#101010","editor.foreground":"#e0e0e0", + "text.accent":"0f4b6e","warning":"#e0af68", + "players":[{"cursor":"#7aa2f7"},{"cursor":"#9ece6a"}], + "syntax":{"keyword":{"color":"#bb9af7","font_style":"italic"}, + "string":{"color":"9ece6a"}}}}, + {"name":"Fake Light","appearance":"light","style":{ + "editor.background":"#fafafa","editor.foreground":"#383a42"}}]}`)) + if err != nil { + t.Fatal(err) + } + if len(fam) != 2 { + t.Fatalf("themes = %d, want 2", len(fam)) + } + dark, light := fam[0], fam[1] + if dark.ID != "zed/fake-dark" || light.ID != "zed/fake-light" { + t.Errorf("ids = %q, %q", dark.ID, light.ID) + } + // a theme in the wild does drop the odd "#": what is not a color is + // treated as unsaid, and filled in rather than emitted as nonsense + if dark.Accent == "0f4b6e" { + t.Error("kept a color with no #") + } + if _, ok := dark.Syntax["string"]; ok { + t.Error("kept a syntax color with no #") + } + if dark.syn("keyword") != "#bb9af7" { + t.Errorf("keyword = %q", dark.syn("keyword")) + } + if len(dark.Players) < 2 || dark.Players[0] != "#7aa2f7" { + t.Errorf("agent colors = %v, want the player palette first", dark.Players) + } + // nothing to work from but bg and fg, and still a whole theme + if okColor(light.Muted) == "" || okColor(light.Hover) == "" { + t.Errorf("light theme left gaps: %+v", light) + } +} + +// The picker's two files: a block per theme, and a menu that names them. +func TestThemeFiles(t *testing.T) { + dark, light := loadTheme("", "", "dark"), loadTheme("", "", "light") + helix := loadHelixTheme(writeTheme(t, t.TempDir(), "rose_pine.toml", helixSrc), nil) + ts := themesFrom([]*Theme{dark, light, helix}, dark, light) + + css := string(ts.CSS) + for _, th := range ts.List { + if !strings.Contains(css, `:root[data-theme="`+th.ID+`"]`) { + t.Errorf("no css block for %s", th.ID) + } + if !strings.Contains(string(ts.JS), th.ID) { + t.Errorf("%s is not in the menu", th.ID) + } + } + for _, want := range []string{"--bg:", "--fg:", "--ag0:", "color-scheme: dark", "color-scheme: light"} { + if !strings.Contains(css, want) { + t.Errorf("themes.css never says %q", want) + } + } + // every declaration has to be worth something to the browser + for _, line := range strings.Split(css, "\n") { + if l := strings.TrimSpace(line); strings.HasPrefix(l, "--") && strings.HasSuffix(l, ": ;") { + t.Errorf("empty declaration: %s", l) + } + } + if n := strings.Count(string(ts.JS), `"id":`); n != len(ts.List) { + t.Errorf("menu has %d themes, want %d", n, len(ts.List)) + } +} + +// The two editors' theme directories, as they are on a machine that has them. +func TestDiscoverFindsBothEditors(t *testing.T) { + home := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "config")) + t.Setenv("XDG_DATA_HOME", filepath.Join(home, "data")) + writeTheme(t, filepath.Join(home, "config", "zed", "themes"), "mine.json", + `{"name":"Mine","themes":[{"name":"Mine Dark","appearance":"dark","style":{ + "editor.background":"#101010","editor.foreground":"#e0e0e0"}}]}`) + writeTheme(t, filepath.Join(home, "data", "zed", "extensions", "installed", "ext", "themes"), + "ext.json", `{"name":"Ext","themes":[{"name":"Ext Light","appearance":"light","style":{ + "editor.background":"#fafafa","editor.foreground":"#383a42"}}]}`) + writeTheme(t, filepath.Join(home, "config", "helix", "themes"), "mine_helix.toml", helixSrc) + + found := map[string]*Theme{} + for _, th := range discoverThemes() { + found[th.ID] = th + } + for _, want := range []string{"zed/mine-dark", "zed/ext-light", "helix/mine-helix"} { + if found[want] == nil { + t.Errorf("did not find %s", want) + } + } +} + +// Coverage colors are mixed in the browser so every theme can supply them. +func TestMaskMix(t *testing.T) { + s := &Site{Agents: []*Agent{{Bit: 1}, {Bit: 2}, {Bit: 4}}} + for _, tc := range []struct { + mask uint8 + want string + }{ + {1, "var(--ag0)"}, + {5, "color-mix(in srgb, var(--ag0) 50.000%, var(--ag2))"}, + {7, "color-mix(in srgb, var(--ag0) 33.333%, color-mix(in srgb, var(--ag1) 50.000%, var(--ag2)))"}, + } { + if got := s.maskMix(tc.mask); got != tc.want { + t.Errorf("mask %d = %q, want %q", tc.mask, got, tc.want) + } + } +} -- cgit v1.3