summaryrefslogtreecommitdiff
path: root/vrsite
diff options
context:
space:
mode:
Diffstat (limited to 'vrsite')
-rw-r--r--vrsite/app_test.go98
-rw-r--r--vrsite/assets/app.js212
-rw-r--r--vrsite/assets/style.css.tmpl157
-rw-r--r--vrsite/bench_test.go2
-rw-r--r--vrsite/css.go171
-rw-r--r--vrsite/go.mod2
-rw-r--r--vrsite/go.sum4
-rw-r--r--vrsite/main.go14
-rw-r--r--vrsite/markdown.go259
-rw-r--r--vrsite/markdown_test.go244
-rw-r--r--vrsite/model.go114
-rw-r--r--vrsite/page.go84
-rw-r--r--vrsite/permalink_test.go210
-rw-r--r--vrsite/render.go147
-rw-r--r--vrsite/serve.go118
-rw-r--r--vrsite/static.go50
-rw-r--r--vrsite/templates/frag-note.tmpl57
-rw-r--r--vrsite/templates/layout.tmpl30
-rw-r--r--vrsite/templates/page-note.tmpl54
-rw-r--r--vrsite/theme.go315
-rw-r--r--vrsite/themes.go445
-rw-r--r--vrsite/themes_test.go294
22 files changed, 2876 insertions, 205 deletions
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 &amp; 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 <p>: 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 <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)
}
- syn = append(syn, s)
+ 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")}})
}
- return css.Execute(w, map[string]any{
- "DarkName": site.Theme.Name, "LightName": site.Light.Name,
- "DarkVars": themeVars(site.Theme), "LightVars": themeVars(site.Light),
- "Syntax": syn, "MaskList": masks,
- })
+ b, err := json.Marshal(cards)
+ if err != nil {
+ b = []byte("[]")
+ }
+ fmt.Fprintf(w, "// %d themes, written by vrsite from this machine's zed and helix installs\n", len(cards))
+ fmt.Fprintf(w, "window.VRSITE_THEMES=%s;\n", strings.ReplaceAll(string(b), "},{", "},\n{"))
}
diff --git a/vrsite/go.mod b/vrsite/go.mod
index cb9dd9e..e4c19c8 100644
--- a/vrsite/go.mod
+++ b/vrsite/go.mod
@@ -3,6 +3,7 @@ module 0x4200.cafe/vrsite
go 1.26.5
require (
+ github.com/BurntSushi/toml v1.6.0
github.com/tree-sitter/go-tree-sitter v0.25.0
github.com/tree-sitter/tree-sitter-bash v0.25.1
github.com/tree-sitter/tree-sitter-c v0.24.2
@@ -13,6 +14,7 @@ require (
github.com/tree-sitter/tree-sitter-python v0.25.0
github.com/tree-sitter/tree-sitter-rust v0.24.2
github.com/tree-sitter/tree-sitter-typescript v0.23.2
+ github.com/yuin/goldmark v1.8.5
)
require github.com/mattn/go-pointer v0.0.1 // indirect
diff --git a/vrsite/go.sum b/vrsite/go.sum
index af50473..12a46d2 100644
--- a/vrsite/go.sum
+++ b/vrsite/go.sum
@@ -1,3 +1,5 @@
+github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
+github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/mattn/go-pointer v0.0.1 h1:n+XhsuGeVO6MEAp7xyEukFINEa+Quek5psIR/ylA6o0=
@@ -36,5 +38,7 @@ github.com/tree-sitter/tree-sitter-rust v0.24.2 h1:NL4nF67ib21RMzzfvkmXlVwe45vvh
github.com/tree-sitter/tree-sitter-rust v0.24.2/go.mod h1:hfeGWic9BAfgTrc7Xf6FaOAguCFJRo3RBbs7QJ6D7MI=
github.com/tree-sitter/tree-sitter-typescript v0.23.2 h1:/Odvphn18PniVixb9e97X0DbNVsU6Qocv9mfkyzdXwU=
github.com/tree-sitter/tree-sitter-typescript v0.23.2/go.mod h1:zjzMXT/Ulffel2xfOcAkQQkiAkmgnbtPGlFQw/5X4xA=
+github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
+github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/vrsite/main.go b/vrsite/main.go
index 5f9b1ef..69c90a8 100644
--- a/vrsite/main.go
+++ b/vrsite/main.go
@@ -11,7 +11,9 @@
// repository is only ever read, never modified.
//
// Theme comes from a Zed theme JSON; per-agent colors come from the theme's
-// player (collaborator) palette.
+// player (collaborator) palette. Every other theme this machine has — Zed's
+// and Helix's both — is offered in the picker, previewed as you move through
+// it, and remembered in the browser.
package main
import (
@@ -69,6 +71,11 @@ and the app keeps no state of its own.
Session transcripts (the conversation behind a note) are looked up by session
id under -transcripts; Claude Code and Codex are found automatically.
+The site is built with the two themes -theme and -light-theme name. Every
+other theme installed on this machine — Zed families and Helix themes both —
+is offered in the picker at the bottom of the tree: it previews as you move
+through it, and the browser remembers what you settled on.
+
flags:
`
@@ -112,15 +119,16 @@ func main() {
}
theme := loadTheme(*themePath, *themeName, "dark")
light := loadTheme(*lightPath, *lightName, "light")
+ themes := newThemeSet(theme, light)
if *out == "" {
if *watch {
fatal("-watch is for -out builds; the served app already follows the log")
}
- serve(*addr, *logPath, repoAbs, *title, username(*who), strings.Split(*roots, ","), theme, light)
+ serve(*addr, *logPath, repoAbs, *title, username(*who), strings.Split(*roots, ","), theme, light, themes)
return
}
- build := func() { buildSite(*logPath, repoAbs, *out, *title, theme, light) }
+ build := func() { buildSite(*logPath, repoAbs, *out, *title, theme, light, themes) }
build()
if !*watch {
return
diff --git a/vrsite/markdown.go b/vrsite/markdown.go
new file mode 100644
index 0000000..31581ae
--- /dev/null
+++ b/vrsite/markdown.go
@@ -0,0 +1,259 @@
+// markdown.go — note text is markdown. Agents write prose about code:
+// backticked identifiers, bullet lists, the occasional fenced snippet, and
+// links out. Rendering it as one escaped <p> threw all of that away.
+//
+// Two things make markdown fit text nobody wrote for a browser:
+//
+// - angle brackets are never markup. In a note they are code —
+// RwLock<SysvarCache> is a type — so the HTML parsers come out and the
+// brackets survive as the characters they were typed as.
+// - fenced code goes through the same tree-sitter highlighter as the code
+// view, so a snippet in a note is painted by the reader's theme exactly
+// like the file it was copied from.
+package main
+
+import (
+ "bytes"
+ stdhtml "html"
+ "html/template"
+ "net/url"
+ "path"
+ "regexp"
+ "strconv"
+ "strings"
+
+ "github.com/yuin/goldmark"
+ "github.com/yuin/goldmark/ast"
+ "github.com/yuin/goldmark/extension"
+ mdparser "github.com/yuin/goldmark/parser"
+ "github.com/yuin/goldmark/renderer"
+ "github.com/yuin/goldmark/renderer/html"
+ mdtext "github.com/yuin/goldmark/text"
+ "github.com/yuin/goldmark/util"
+)
+
+// markdown renders note text. goldmark is safe for concurrent use, which the
+// server (a goroutine per request) and the export (a goroutine per file) both
+// rely on.
+type markdown struct{ md goldmark.Markdown }
+
+// newMarkdown builds the note renderer. GFM adds the things people actually
+// type — tables, ~~strikethrough~~, bare URLs, task lists — and hard wraps
+// keep a newline a newline, since a note is typed into a textarea or passed on
+// a command line, not authored as a document.
+func newMarkdown(hl *highlighter) *markdown {
+ md := goldmark.New(
+ goldmark.WithParser(notesParser()),
+ goldmark.WithExtensions(extension.GFM),
+ goldmark.WithRendererOptions(html.WithHardWraps()),
+ )
+ // lower priority number wins: this one replaces the stock code-block
+ // renderers, everything else stays goldmark's
+ md.Renderer().AddOptions(renderer.WithNodeRenderers(
+ util.Prioritized(&noteRenderer{hl: hl}, 100)))
+ return &markdown{md: md}
+}
+
+// goldmark's priorities for the two parsers that read angle brackets as
+// markup, from mdparser.DefaultBlockParsers and mdparser.DefaultInlineParsers.
+const (
+ htmlBlockPriority = 900
+ rawHTMLPriority = 400
+)
+
+// notesParser is goldmark's default parser with those two dropped. Leaving
+// them in costs the note either way: goldmark's safe default deletes raw HTML
+// outright, so "RwLock<SysvarCache>" loses a word, and the block form
+// additionally stops parsing markdown for the rest of the paragraph — a note
+// that opens with a quoted <div> would render its links and lists as source.
+// Without the parsers a bracket is just a character the text renderer escapes,
+// which is both what the writer meant and the one thing a browser cannot act
+// on. Dropping by priority keeps every other default, including any upstream
+// adds later.
+func notesParser() mdparser.Parser {
+ return mdparser.NewParser(
+ mdparser.WithBlockParsers(without(mdparser.DefaultBlockParsers(), htmlBlockPriority)...),
+ mdparser.WithInlineParsers(without(mdparser.DefaultInlineParsers(), rawHTMLPriority)...),
+ mdparser.WithParagraphTransformers(mdparser.DefaultParagraphTransformers()...),
+ )
+}
+
+func without(vs []util.PrioritizedValue, priority int) []util.PrioritizedValue {
+ out := vs[:0]
+ for _, v := range vs {
+ if v.Priority != priority {
+ out = append(out, v)
+ }
+ }
+ return out
+}
+
+// newNoteLinkParser is shared across one model build. It uses the same parser
+// and GFM extensions as rendered note bodies, so only links the reader can
+// actually click become mentions (code spans and plain prose do not).
+func newNoteLinkParser() mdparser.Parser {
+ return goldmark.New(
+ goldmark.WithParser(notesParser()),
+ goldmark.WithExtensions(extension.GFM),
+ ).Parser()
+}
+
+func noteLinks(p mdparser.Parser, source string) []int {
+ if !strings.Contains(source, "note") {
+ return nil
+ }
+ src := []byte(source)
+ doc := p.Parse(mdtext.NewReader(src))
+ seen := map[int]bool{}
+ var ids []int
+ _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
+ if !entering || n.Kind() != ast.KindLink {
+ return ast.WalkContinue, nil
+ }
+ id := noteLinkID(string(n.(*ast.Link).Destination))
+ if id > 0 && !seen[id] {
+ seen[id] = true
+ ids = append(ids, id)
+ }
+ return ast.WalkContinue, nil
+ })
+ return ids
+}
+
+// noteLinkID recognizes local links ending in note/N. Leading /, ./ and ../
+// components are all allowed; schemes and protocol-relative hosts are not.
+func noteLinkID(destination string) int {
+ u, err := url.Parse(strings.TrimSpace(destination))
+ if err != nil || u.Scheme != "" || u.Host != "" {
+ return 0
+ }
+ clean := path.Clean("/" + strings.TrimSuffix(u.Path, ".html"))
+ parts := strings.Split(strings.Trim(clean, "/"), "/")
+ if len(parts) < 2 || parts[len(parts)-2] != "note" {
+ return 0
+ }
+ id, err := strconv.Atoi(parts[len(parts)-1])
+ if err != nil || id < 1 {
+ return 0
+ }
+ return id
+}
+
+// render turns one note's text into HTML. A note is never worth losing to a
+// renderer error, so a failure falls back to the text as written.
+func (m *markdown) render(text string) template.HTML {
+ if strings.TrimSpace(text) == "" {
+ return ""
+ }
+ var b bytes.Buffer
+ b.Grow(len(text) + len(text)/2)
+ if err := m.md.Convert([]byte(text), &b); err != nil {
+ return template.HTML("<p>" + template.HTMLEscapeString(text) + "</p>")
+ }
+ return template.HTML(b.String())
+}
+
+var noteHrefRE = regexp.MustCompile(`href="([^"]+)"`)
+
+// renderAt canonicalizes note links for the page carrying the card. A note is
+// shown at several URL depths (/notes, /code/x, /note/N, and static variants),
+// so leaving its relative Markdown href untouched would make the mention work
+// in only one of those places.
+func (m *markdown) renderAt(text string, p *page) template.HTML {
+ body := m.render(text)
+ if body == "" || p == nil {
+ return body
+ }
+ out := noteHrefRE.ReplaceAllStringFunc(string(body), func(attr string) string {
+ destination := stdhtml.UnescapeString(attr[len(`href="`) : len(attr)-1])
+ id := noteLinkID(destination)
+ if id == 0 {
+ return attr
+ }
+ if !p.Live && p.Site != nil && p.Site.NoteByID(id) == nil {
+ return attr
+ }
+ href := p.Href("note", strconv.Itoa(id))
+ if href == "" {
+ return attr
+ }
+ return `href="` + template.HTMLEscapeString(href) + `"`
+ })
+ return template.HTML(out)
+}
+
+// noteRenderer overrides the two node kinds the stock renderer emits without
+// syntax colors. Everything else it renders is fine as goldmark writes it.
+type noteRenderer struct{ hl *highlighter }
+
+func (nr *noteRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
+ reg.Register(ast.KindFencedCodeBlock, nr.renderFenced)
+ reg.Register(ast.KindCodeBlock, nr.renderIndented)
+}
+
+func (nr *noteRenderer) renderFenced(w util.BufWriter, src []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
+ if !entering {
+ return ast.WalkSkipChildren, nil
+ }
+ n := node.(*ast.FencedCodeBlock)
+ nr.code(w, blockLines(src, n), string(n.Language(src)))
+ return ast.WalkSkipChildren, nil
+}
+
+func (nr *noteRenderer) renderIndented(w util.BufWriter, src []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
+ if !entering {
+ return ast.WalkSkipChildren, nil
+ }
+ nr.code(w, blockLines(src, node), "")
+ return ast.WalkSkipChildren, nil
+}
+
+// blockLines joins a block node's source lines. They are contiguous in the
+// source, but only the segments are authoritative about where the block's
+// indentation was stripped.
+func blockLines(src []byte, n ast.Node) []byte {
+ lines := n.Lines()
+ var b bytes.Buffer
+ for i := 0; i < lines.Len(); i++ {
+ seg := lines.At(i)
+ b.Write(seg.Value(src))
+ }
+ return bytes.TrimRight(b.Bytes(), "\n")
+}
+
+// code writes one code block, syntax-painted when a grammar claims the fence's
+// language and plain when none does. It emits the same <span class="s-…">
+// runs as the code view, so the stylesheet already knows how to color it; the
+// <li>-per-line scaffolding does not come along, since a snippet has no line
+// numbers, no coverage and no notes of its own.
+func (nr *noteRenderer) code(w util.BufWriter, src []byte, lang string) {
+ w.WriteString(`<pre class="mdcode"`)
+ if lang != "" {
+ w.WriteString(` data-lang="`)
+ w.WriteString(template.HTMLEscapeString(lang))
+ w.WriteByte('"')
+ }
+ w.WriteString("><code>")
+
+ var b strings.Builder
+ b.Grow(len(src) + len(src)/4)
+ classes := nr.hl.classifyLang(lang, src)
+ names := []string(nil)
+ if nr.hl != nil {
+ names = nr.hl.classNames
+ }
+ for start := 0; start <= len(src); {
+ end := start
+ for end < len(src) && src[end] != '\n' {
+ end++
+ }
+ emitLine(&b, src[start:end], classes[start:end], names)
+ if end >= len(src) {
+ break
+ }
+ b.WriteByte('\n')
+ start = end + 1
+ }
+ w.WriteString(b.String())
+ w.WriteString("</code></pre>\n")
+}
diff --git a/vrsite/markdown_test.go b/vrsite/markdown_test.go
new file mode 100644
index 0000000..e8f1fe1
--- /dev/null
+++ b/vrsite/markdown_test.go
@@ -0,0 +1,244 @@
+package main
+
+import (
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+)
+
+func testMarkdown(t *testing.T) *markdown {
+ t.Helper()
+ return newMarkdown(newHighlighter(loadTheme("", "", "dark"), loadTheme("", "", "light")))
+}
+
+func TestMarkdownNotes(t *testing.T) {
+ m := testMarkdown(t)
+ cases := []struct {
+ name string
+ in string
+ want []string
+ not []string
+ }{
+ {
+ name: "inline marks",
+ in: "`detect()` prefers **direct env** and only walks the tree as *fallback*",
+ want: []string{"<code>detect()</code>", "<strong>direct env</strong>", "<em>fallback</em>"},
+ },
+ {
+ name: "list after a paragraph",
+ in: "three classes:\n\n- persisted RPC narrowed\n- ingress bounds change",
+ want: []string{"<p>three classes:</p>", "<ul>", "<li>persisted RPC narrowed</li>"},
+ },
+ {
+ name: "bare url",
+ in: "see https://example.com/x for the rest",
+ want: []string{`<a href="https://example.com/x">`},
+ },
+ {
+ name: "single newlines are kept",
+ in: "first line\nsecond line",
+ want: []string{"first line<br>", "second line"},
+ not: []string{"<ul>"},
+ },
+ // the identifiers real notes are full of must survive untouched:
+ // underscores are not emphasis, "4.2" is not a list, "@" is not a
+ // mail address
+ {
+ name: "code prose is left alone",
+ in: "4.2 drops stake_minimum_delegation_for_rewards and remove_simple_vote, unlike @",
+ want: []string{"stake_minimum_delegation_for_rewards", "remove_simple_vote", "4.2 drops"},
+ not: []string{"<em>", "<ol>", "<a href"},
+ },
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ got := string(m.render(c.in))
+ for _, w := range c.want {
+ if !strings.Contains(got, w) {
+ t.Errorf("missing %q in:\n%s", w, got)
+ }
+ }
+ for _, n := range c.not {
+ if strings.Contains(got, n) {
+ t.Errorf("unwanted %q in:\n%s", n, got)
+ }
+ }
+ })
+ }
+}
+
+func TestNoteLinksFindOnlyLocalMarkdownLinks(t *testing.T) {
+ p := newNoteLinkParser()
+ got := noteLinks(p, strings.Join([]string{
+ "[root](/note/2)",
+ "[same target](note/2#discussion)",
+ "[static relative](../note/3.html?scope=all)",
+ "`[code](note/4)` and plain note/5 are not links",
+ "[remote](https://example.com/note/6)",
+ "[protocol relative](//example.com/note/7)",
+ "[reference][n]",
+ "[n]: ./note/8",
+ }, "\n\n"))
+ if len(got) != 3 || got[0] != 2 || got[1] != 3 || got[2] != 8 {
+ t.Errorf("note links = %v, want [2 3 8]", got)
+ }
+
+ m := testMarkdown(t)
+ text := "[note](../note/3.html) and [remote](https://example.com/note/4)"
+ if body := string(m.renderAt(text, &page{Live: true, Root: "/"})); !strings.Contains(body, `href="/note/3"`) || !strings.Contains(body, `href="https://example.com/note/4"`) {
+ t.Errorf("live note links were not canonicalized selectively:\n%s", body)
+ }
+ if body := string(m.renderAt(text, &page{Root: "../"})); !strings.Contains(body, `href="../note/3.html"`) {
+ t.Errorf("static note link does not follow the page root:\n%s", body)
+ }
+}
+
+// Angle brackets in a note are code, never markup: they must come through as
+// text, and must not stop the markdown around them from being parsed. That
+// second half is what goldmark's HTML *block* parser would break — a note
+// opening with a quoted tag would render the rest of its paragraph as source.
+func TestMarkdownShowsAngleBracketsAsText(t *testing.T) {
+ m := testMarkdown(t)
+ cases := []struct{ name, in string }{
+ {"inline", "sysvar_cache: RwLock<SysvarCache> — hoisted out of the loop"},
+ {"block, tag alone on the line", "<div>\nthe **wrapper** in [page.go](https://go.dev)"},
+ {"script", "<script>alert(1)</script> 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, "<div>") || strings.Contains(got, "<script>") {
+ t.Errorf("markup reached the page:\n%s", got)
+ }
+ if strings.Contains(got, "raw HTML omitted") {
+ t.Errorf("text was dropped:\n%s", got)
+ }
+ for _, w := range []string{"&lt;", "&gt;"} {
+ if !strings.Contains(got, w) {
+ t.Errorf("missing %q in:\n%s", w, got)
+ }
+ }
+ if strings.Contains(c.in, "[") && !strings.Contains(got, `<a href="https://go.dev">`) {
+ t.Errorf("markdown stopped being parsed:\n%s", got)
+ }
+ })
+ }
+}
+
+// A note is written by whoever ran vr, so the renderer is the last thing
+// standing between the log and the page.
+func TestMarkdownDropsDangerousLinks(t *testing.T) {
+ m := testMarkdown(t)
+ for _, in := range []string{
+ "[click](javascript:alert(1))",
+ "![pic](javascript:alert(1))",
+ "[click](vbscript:alert(1))",
+ } {
+ if got := string(m.render(in)); strings.Contains(got, "javascript:") || strings.Contains(got, "vbscript:") {
+ t.Errorf("%q rendered as %s", in, got)
+ }
+ }
+}
+
+// A fenced block is painted by the same grammars as the code view, so a
+// snippet in a note wears the reader's theme.
+func TestMarkdownHighlightsFences(t *testing.T) {
+ m := testMarkdown(t)
+ got := string(m.render("```go\nfunc load() error { return nil }\n```"))
+ for _, w := range []string{`<pre class="mdcode" data-lang="go">`, `<span class="s-keyword">func</span>`, "</code></pre>"} {
+ if !strings.Contains(got, w) {
+ t.Errorf("missing %q in:\n%s", w, got)
+ }
+ }
+ // a language no grammar claims still renders, just unpainted
+ got = string(m.render("```brainfuck\n+[-]<>\n```"))
+ if !strings.Contains(got, "+[-]&lt;&gt;") {
+ t.Errorf("unclaimed fence lost its text:\n%s", got)
+ }
+ if strings.Contains(got, "<span") {
+ t.Errorf("unclaimed fence was painted:\n%s", got)
+ }
+}
+
+// Fence languages are mostly extensions already, so the grammar table does the
+// lookup; fenceAliases only has to cover the spelled-out names. Each pair here
+// is a snippet the claimed grammar must find something to paint in.
+func TestMarkdownFenceLanguages(t *testing.T) {
+ hl := newHighlighter(loadTheme("", "", "dark"), loadTheme("", "", "light"))
+ for _, c := range []struct{ lang, src string }{
+ {"go", "func f() {}"}, {"GO", "func f() {}"}, {"golang", "func f() {}"},
+ {"rs", "fn f() {}"}, {"rust", "fn f() {}"},
+ {"py", "def f(): pass"}, {"python", "def f(): pass"}, {"python3", "def f(): pass"},
+ {"js", "function f() {}"}, {"javascript", "function f() {}"}, {"node", "function f() {}"},
+ {"jsx", "function f() {}"}, {"mjs", "function f() {}"},
+ {"ts", "function f(): void {}"}, {"typescript", "function f(): void {}"},
+ {"tsx", "function f(): void {}"},
+ {"c", "int f(void) { return 0; }"},
+ {"cpp", "int f() { return 0; }"}, {"c++", "int f() { return 0; }"}, {"h", "int f();"},
+ {"json", `{"a": 1}`}, {"jsonc", `{"a": 1}`}, {"json5", `{"a": 1}`},
+ {"sh", "for x in a; do echo $x; done"}, {"bash", "for x in a; do echo $x; done"},
+ {"shell", "for x in a; do echo $x; done"}, {"zsh", "for x in a; do echo $x; done"},
+ {"console", "for x in a; do echo $x; done"},
+ } {
+ if !painted(hl.classifyLang(c.lang, []byte(c.src))) {
+ t.Errorf("fence language %q painted nothing in %q", c.lang, c.src)
+ }
+ }
+ for _, lang := range []string{"", "text", "brainfuck", " ", "."} {
+ src := []byte("func f() {}")
+ if got := hl.classifyLang(lang, src); len(got) != len(src) || painted(got) {
+ t.Errorf("classifyLang(%q) claimed a grammar it should not have", lang)
+ }
+ }
+}
+
+func painted(classes []int16) bool {
+ for _, c := range classes {
+ if c != 0 {
+ return true
+ }
+ }
+ return false
+}
+
+// The note card is the one place all of this has to arrive: the notes page,
+// the aside on a file, and the conversation view all render it.
+func TestNotePageRendersMarkdown(t *testing.T) {
+ sv, _, _ := testServer(t)
+ h := sv.handler()
+
+ form := url.Values{"file": {"hello.txt"}, "text": {
+ "`cover()` widens the **mask**\n\n- one\n- two\n\n```go\nfunc f() {}\n```"}, "rev": {"@"}}
+ r := httptest.NewRequest("POST", "/notes", strings.NewReader(form.Encode()))
+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ r.Header.Set("HX-Request", "true")
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, r)
+ if w.Code != 200 {
+ t.Fatalf("POST /notes = %d: %s", w.Code, w.Body)
+ }
+ for _, want := range []string{
+ `<div class="body">`,
+ "<code>cover()</code>", "<strong>mask</strong>",
+ "<li>one</li>",
+ `<pre class="mdcode" data-lang="go">`, `<span class="s-keyword">func</span>`,
+ } {
+ if !strings.Contains(w.Body.String(), want) {
+ t.Errorf("missing %q in the posted note card:\n%s", want, w.Body)
+ }
+ }
+ // and again off the page itself, not just the htmx fragment
+ if b := get(t, h, "/notes", false).Body.String(); !strings.Contains(b, "<code>cover()</code>") {
+ t.Error("notes page did not render the note as markdown")
+ }
+}
+
+func TestMarkdownEmpty(t *testing.T) {
+ m := testMarkdown(t)
+ for _, in := range []string{"", " ", "\n\n"} {
+ if got := m.render(in); got != "" {
+ t.Errorf("render(%q) = %q, want empty", in, got)
+ }
+ }
+}
diff --git a/vrsite/model.go b/vrsite/model.go
index 678d5ee..b580b49 100644
--- a/vrsite/model.go
+++ b/vrsite/model.go
@@ -35,6 +35,7 @@ type Entry struct {
Pattern string `json:"pattern,omitempty"`
Matches int `json:"matches,omitempty"`
NoteID int `json:"note_id,omitempty"`
+ ReplyTo int `json:"reply_to,omitempty"`
Type string `json:"type,omitempty"`
Text string `json:"text,omitempty"`
}
@@ -52,10 +53,23 @@ type Note struct {
Session, Model, Reason string
File, Kind, Text, Change, Rev string
Start, End int
+ ReplyTo int
+ Replies, Mentions []*Note // notes that point back to this one
At time.Time
When, Ago string
}
+// ThreadNode is the tree-shaped rendering of the note DAG. A note reached by
+// more than one path is emitted fully once and then as a compact reference, so
+// the focused page keeps valid DOM ids and cannot recurse through malformed
+// cyclic logs.
+type ThreadNode struct {
+ Note *Note
+ Via string // "reply" | "mention"; empty for the focused root
+ Children []*ThreadNode
+ Reference bool
+}
+
type File struct {
Path, Change string
Traced bool
@@ -150,7 +164,8 @@ func buildModel(title string, entries []Entry, only string) *Site {
at := parseTime(e.Time)
n := &Note{ID: noteN, NoteID: e.NoteID, Agent: a, Session: e.Session,
Model: e.Model, Reason: e.Reason, File: e.File, Change: e.Change,
- Rev: e.Rev, Start: e.Start, End: e.End, Kind: e.Type, Text: e.Text,
+ Rev: e.Rev, Start: e.Start, End: e.End, ReplyTo: e.ReplyTo,
+ Kind: e.Type, Text: e.Text,
At: at, When: stamp(at), Ago: ago(at)}
s.Notes = append(s.Notes, n)
if e.File != "" {
@@ -183,6 +198,7 @@ func buildModel(title string, entries []Entry, only string) *Site {
s.Order = append(s.Order, p)
}
sort.Strings(s.Order)
+ linkNotes(s.Notes)
return s
}
@@ -243,17 +259,6 @@ func (s *Site) filesAt(paths []string, change string) []*File {
return all
}
-// maskColor mixes the theme's agent colors for a coverage bitmask.
-func (s *Site) maskColor(t *Theme, mask uint8) string {
- var cols []string
- for i, a := range s.Agents {
- if a.Bit != 0 && mask&a.Bit != 0 {
- cols = append(cols, t.Player(i))
- }
- }
- return mixColors(cols)
-}
-
// masks lists every bitmask combination the colored agents can produce.
func (s *Site) masks() []uint8 {
n := len(s.Agents)
@@ -318,6 +323,91 @@ func (s *Site) FilterNotes(f NoteFilter) []*Note {
return out
}
+// NoteByID finds a note by the id a permalink names. Ids count over the whole
+// log and the log only ever grows, so the link a reader was given goes on
+// meaning the same note however the site is later scoped or rebuilt.
+func (s *Site) NoteByID(id int) *Note {
+ for _, n := range s.Notes {
+ if n.ID == id {
+ return n
+ }
+ }
+ return nil
+}
+
+// linkNotes derives the graph without adding another log operation. Replies
+// are explicit reply_to edges; mentions are ordinary local Markdown links to
+// /note/N (or an equivalent relative path). If a reply also links its parent,
+// the explicit relationship wins and the child is not counted twice.
+func linkNotes(notes []*Note) {
+ byID := make(map[int]*Note, len(notes))
+ for _, n := range notes {
+ byID[n.ID] = n
+ }
+ links := newNoteLinkParser()
+ for _, child := range notes {
+ if parent := byID[child.ReplyTo]; parent != nil && parent != child {
+ parent.Replies = append(parent.Replies, child)
+ }
+ for _, id := range noteLinks(links, child.Text) {
+ parent := byID[id]
+ if parent == nil || parent == child || id == child.ReplyTo {
+ continue
+ }
+ parent.Mentions = append(parent.Mentions, child)
+ }
+ }
+}
+
+// NoteThread expands replies and mentions together, oldest first. The global
+// seen set turns a DAG into one readable Hacker News-style thread while still
+// leaving a reference at every additional incoming edge.
+func (s *Site) NoteThread(id int) *ThreadNode {
+ root := s.NoteByID(id)
+ if root == nil {
+ return nil
+ }
+ seen := map[int]bool{id: true}
+ var build func(*Note) *ThreadNode
+ build = func(n *Note) *ThreadNode {
+ node := &ThreadNode{Note: n}
+ for _, edge := range noteEdges(n) {
+ child := &ThreadNode{Note: edge.Note, Via: edge.Via}
+ if seen[edge.Note.ID] {
+ child.Reference = true
+ } else {
+ seen[edge.Note.ID] = true
+ child = build(edge.Note)
+ child.Via = edge.Via
+ }
+ node.Children = append(node.Children, child)
+ }
+ return node
+ }
+ return build(root)
+}
+
+type noteEdge struct {
+ Note *Note
+ Via string
+}
+
+func noteEdges(n *Note) []noteEdge {
+ seen := map[int]bool{}
+ edges := make([]noteEdge, 0, len(n.Replies)+len(n.Mentions))
+ for _, child := range n.Replies {
+ seen[child.ID] = true
+ edges = append(edges, noteEdge{Note: child, Via: "reply"})
+ }
+ for _, child := range n.Mentions {
+ if !seen[child.ID] {
+ edges = append(edges, noteEdge{Note: child, Via: "mention"})
+ }
+ }
+ sort.SliceStable(edges, func(i, j int) bool { return edges[i].Note.ID < edges[j].Note.ID })
+ return edges
+}
+
// Facets are the distinct values the note filters can select, so the form
// only ever offers choices that exist in the log.
type Facets struct {
diff --git a/vrsite/page.go b/vrsite/page.go
index 2974eec..cca0c58 100644
--- a/vrsite/page.go
+++ b/vrsite/page.go
@@ -16,7 +16,7 @@ import (
type page struct {
Site *Site
Title string
- Kind string // index | file | files | notes | jj | change | session
+ Kind string // index | file | files | notes | note | jj | change | session
Root string // static: relative prefix; live: "/"
Live bool
Rev string // revision being browsed ("" = the change the log points at)
@@ -40,6 +40,14 @@ type page struct {
File *File
Files []*File // every file at this change, for counts
+ // one note on its own page — the permalink. Excerpt is the code it is
+ // about, already rendered; ExErr says why there is none.
+ Note *Note
+ Thread *ThreadNode
+ Excerpt template.HTML
+ ExFrom, ExTo int
+ ExErr string
+
Notes []*Note
Filter NoteFilter
Facets *Facets
@@ -105,7 +113,7 @@ func (p *page) BackHref() string {
// that can be read at any change, the file tree otherwise.
func (p *page) JumpPath() string {
switch p.Kind {
- case "index", "file", "files", "notes":
+ case "index", "file", "files", "notes", "note":
if p.Path != "" {
return p.Path
}
@@ -134,6 +142,8 @@ func (p *page) Href(kind string, arg ...string) string {
return "/jj"
case "code":
return "/code/" + escPath(a) + p.params()
+ case "note":
+ return "/note/" + a
case "change":
return "/change/" + a
case "session":
@@ -154,6 +164,8 @@ func (p *page) Href(kind string, arg ...string) string {
return p.Root + "tree.html"
case "code":
return p.Root + "code/" + a + ".html"
+ case "note":
+ return p.Root + "note/" + a + ".html"
case "asset":
return p.Root + a
}
@@ -184,6 +196,67 @@ func (p *page) ConvHref(n *Note) string {
return p.Href("session", n.Session) + "?note=" + id + "#note-" + id
}
+// PermaHref is the note's own page: the link to hand someone else. It is the
+// one link on the card that names nothing but the note, so it survives the
+// change scope, the filters, and the file having moved on since.
+//
+// It is empty on the note's own page, which is where it already points, and in
+// a static export of a different change, which has no page for it.
+func (p *page) PermaHref(n *Note) string {
+ if p.Kind == "note" && (p.Note == nil || p.Note.ID == n.ID) {
+ return ""
+ }
+ if !p.Live && p.Site != nil && p.Site.NoteByID(n.ID) == nil {
+ return ""
+ }
+ return p.Href("note", strconv.Itoa(n.ID))
+}
+
+// ThreadHref opens the incoming replies and mentions for a note. The focused
+// root only needs an in-page jump; every other card gets its own permalink.
+func (p *page) ThreadHref(n *Note) string {
+ if len(n.Replies) == 0 && len(n.Mentions) == 0 {
+ return ""
+ }
+ if p.Kind == "note" && p.Note != nil && p.Note.ID == n.ID {
+ return "#discussion"
+ }
+ h := p.Href("note", strconv.Itoa(n.ID))
+ if h == "" {
+ return ""
+ }
+ return h + "#discussion"
+}
+
+// ReplyHref is the explicit parent link. A static export may not contain the
+// parent's change, in which case it leaves the relationship as text instead of
+// manufacturing a link to a page the export did not write.
+func (p *page) ReplyHref(n *Note) string {
+ if n.ReplyTo < 1 {
+ return ""
+ }
+ if !p.Live && p.Site != nil && p.Site.NoteByID(n.ReplyTo) == nil {
+ return ""
+ }
+ return p.Href("note", strconv.Itoa(n.ReplyTo))
+}
+
+// HasCode reports whether this view has a page for a path. The live app can
+// render any file at the change it is reading; a static export only wrote the
+// ones present at the change it holds, so a note about a file that is not
+// there must not offer a link into nothing.
+func (p *page) HasCode(path string) bool {
+ if p.Live {
+ return path != ""
+ }
+ for _, f := range p.Files {
+ if f.Path == path {
+ return true
+ }
+ }
+ return false
+}
+
// NoteHref points at the noted line, or the note list when it is unpinned.
// The ?note= is what turns a jump into an arrival: the page opens in notes
// focus with the card flashed and its lines marked (see app.js).
@@ -204,9 +277,14 @@ func (p *page) NoteHref(n *Note) string {
return h
}
-func newTemplates() *template.Template {
+// newTemplates parses the page templates. The highlighter comes along because
+// note bodies are markdown, and a fenced code block in a note is painted by
+// the same grammars as the code view.
+func newTemplates(hl *highlighter) *template.Template {
+ md := newMarkdown(hl)
funcs := template.FuncMap{
"short": short,
+ "md": md.renderAt,
"band": func(a, b uint8) bool { return b != 0 && a&b != 0 },
"lines": func(n *Note) string {
if n.Start <= 0 {
diff --git a/vrsite/permalink_test.go b/vrsite/permalink_test.go
new file mode 100644
index 0000000..c3d572f
--- /dev/null
+++ b/vrsite/permalink_test.go
@@ -0,0 +1,210 @@
+package main
+
+import (
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "testing"
+)
+
+func TestNoteByIDSpansTheWholeLog(t *testing.T) {
+ entries := parseLog([]byte(logLines(noteA, noteB)))
+ full := buildModel("test", entries, "")
+ if n := full.NoteByID(2); n == nil || n.Text != "entry point does too much" {
+ t.Fatalf("NoteByID(2) = %+v", n)
+ }
+ if n := full.NoteByID(99); n != nil {
+ t.Errorf("NoteByID(99) = %+v, want nil", n)
+ }
+ // ids are counted over the whole log, so a link keeps meaning the same
+ // note when the site is scoped to one change — that is the whole point of
+ // a permalink
+ scoped := buildModel("test", entries, "aaa")
+ for _, id := range []int{1, 2} {
+ a, b := full.NoteByID(id), scoped.NoteByID(id)
+ if a == nil || b == nil || a.Text != b.Text {
+ t.Errorf("note %d differs between scopes: %+v vs %+v", id, a, b)
+ }
+ }
+}
+
+func TestPermaHref(t *testing.T) {
+ n := &Note{ID: 7}
+ if got := (&page{Live: true, Root: "/"}).PermaHref(n); got != "/note/7" {
+ t.Errorf("live permalink = %q", got)
+ }
+ // a permalink names nothing but the note: no rev, no scope, so it means
+ // the same thing wherever it is pasted
+ wide := &page{Live: true, Root: "/", Rev: "xyz", Scope: "all"}
+ if got := wide.PermaHref(n); got != "/note/7" {
+ t.Errorf("permalink from a scoped view = %q", got)
+ }
+ static := &page{Root: "", Site: &Site{Notes: []*Note{n}}}
+ if got := static.PermaHref(n); got != "note/7.html" {
+ t.Errorf("static permalink = %q", got)
+ }
+ // on the note's own page there is nowhere to go
+ if got := (&page{Live: true, Root: "/", Kind: "note"}).PermaHref(n); got != "" {
+ t.Errorf("self permalink = %q, want none", got)
+ }
+ // an export holding another change has no page to point at
+ empty := &page{Root: "", Site: &Site{}}
+ if got := empty.PermaHref(n); got != "" {
+ t.Errorf("permalink into an export without the note = %q, want none", got)
+ }
+}
+
+func TestExcerptShowsTheNotedLinesInContext(t *testing.T) {
+ hl := newHighlighter(loadTheme("", "", "dark"), loadTheme("", "", "light"))
+ var b strings.Builder
+ for i := 1; i <= 60; i++ {
+ b.WriteString("line\n")
+ }
+ src := []byte(b.String())
+
+ body, from, to := excerptFor("x.txt", src, nil, hl, &Note{ID: 3, Start: 20, End: 24})
+ if from != 20-excerptContext || to != 24+excerptContext {
+ t.Errorf("range = %d–%d, want %d–%d", from, to, 20-excerptContext, 24+excerptContext)
+ }
+ got := string(body)
+ // real line numbers, not the excerpt's own
+ if !strings.Contains(got, `id="L16"`) || !strings.Contains(got, `id="L28"`) {
+ t.Errorf("excerpt is not numbered from the file:\n%s", got)
+ }
+ if strings.Contains(got, `id="L15"`) || strings.Contains(got, `id="L29"`) {
+ t.Errorf("excerpt reaches past its context:\n%s", got)
+ }
+ // only the noted lines are marked, and they carry the note's anchor
+ for _, want := range []string{`<li id="L20"`, `class="noted"`, `data-note="note-3"`} {
+ if !strings.Contains(got, want) {
+ t.Errorf("missing %q in:\n%s", want, got)
+ }
+ }
+ if n := strings.Count(got, "noted"); n != 5 {
+ t.Errorf("marked %d lines, want 5 (20–24)", n)
+ }
+
+ // context is clamped to the file, never past either end
+ if _, from, to = excerptFor("x.txt", src, nil, hl, &Note{Start: 2, End: 2}); from != 1 {
+ t.Errorf("excerpt from = %d, want 1", from)
+ }
+ if _, from, to = excerptFor("x.txt", src, nil, hl, &Note{Start: 59, End: 59}); to != 60 {
+ t.Errorf("excerpt to = %d, want 60", to)
+ }
+ // a note pinned to no line has nothing to excerpt
+ if body, _, _ := excerptFor("x.txt", src, nil, hl, &Note{}); body != "" {
+ t.Errorf("whole-file note produced an excerpt: %s", body)
+ }
+}
+
+// content-visibility: auto brings style containment with it, and a contained
+// element's own counter-reset comes out as 0 whatever value it names. Every
+// chunk after the first would then count from 1, so the number a line shows
+// has to be set on the line itself.
+func TestChunksAreNumberedFromTheirFirstLine(t *testing.T) {
+ hl := newHighlighter(loadTheme("", "", "dark"), loadTheme("", "", "light"))
+ var b strings.Builder
+ for i := 1; i <= chunkLines*2+10; i++ {
+ b.WriteString("x\n")
+ }
+ f := &File{Path: "big.txt", NoteAt: map[int]int{}}
+ f.render([]byte(b.String()), hl)
+ got := string(f.Body)
+
+ if strings.Contains(got, "counter-reset") {
+ t.Error("a chunk still resets the counter on the <ol>, which containment ignores")
+ }
+ for _, ln := range []int{1, chunkLines + 1, chunkLines*2 + 1} {
+ want := `<li id="L` + strconv.Itoa(ln) + `" style="counter-set: ln ` + strconv.Itoa(ln) + `"`
+ if !strings.Contains(got, want) {
+ t.Errorf("chunk starting at line %d does not set its number (want %q)", ln, want)
+ }
+ }
+ if n := strings.Count(got, "counter-set"); n != 3 {
+ t.Errorf("%d lines set the counter, want 3 — one per chunk", n)
+ }
+}
+
+// The permalink is the link handed to someone else, so it has to open on its
+// own: the note, and the code it is about, at the change it was written at.
+func TestNotePermalinkOverHTTP(t *testing.T) {
+ sv, _, _ := testServer(t)
+ h := sv.handler()
+
+ // testServer's log holds one note, on hello.txt:2
+ w := get(t, h, "/note/1", false)
+ if w.Code != 200 {
+ t.Fatalf("GET /note/1 = %d: %s", w.Code, w.Body)
+ }
+ body := w.Body.String()
+ for _, want := range []string{
+ "second line matters", // the note
+ `data-kind="note"`, // and its own page, not a list
+ `<li id="L2"`, // the line it is about…
+ "two", // …with the code on it
+ `<li id="L1"`, // in context
+ "open the whole file",
+ } {
+ if !strings.Contains(body, want) {
+ t.Errorf("permalink page is missing %q", want)
+ }
+ }
+ // the card does not link to the page it is already on
+ if strings.Contains(body, `class="perma" href`) {
+ t.Error("the note links to its own page")
+ }
+
+ // and the notes list offers the link in the first place
+ if list := get(t, h, "/notes", false).Body.String(); !strings.Contains(list, `class="perma" href="/note/1"`) {
+ t.Error("the notes page offers no permalink")
+ }
+
+ for _, path := range []string{"/note/999", "/note/abc", "/note/0"} {
+ if code := get(t, h, path, false).Code; code != 404 {
+ t.Errorf("GET %s = %d, want 404", path, code)
+ }
+ }
+}
+
+func TestStaticExportWritesNotePages(t *testing.T) {
+ sv, logPath, change := testServer(t)
+ extra := logLines(
+ `{"time":"2026-07-20T10:02:00Z","op":"note","session":"s2","agent":"claude","change":"`+change+`","note_id":1,"reply_to":1,"text":"static child"}`,
+ `{"time":"2026-07-20T10:03:00Z","op":"note","session":"s3","agent":"codex","change":"`+change+`","note_id":1,"text":"[static mention](note/1)"}`,
+ )
+ if err := os.WriteFile(logPath, append(mustRead(t, logPath), extra...), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ site, _, _ := sv.snap()
+ out := t.TempDir()
+ writeSite(site, out, sv.repo, testThemes(sv.theme, sv.light))
+
+ b, err := os.ReadFile(filepath.Join(out, "note", "1.html"))
+ if err != nil {
+ t.Fatalf("no page for note 1: %v", err)
+ }
+ page := string(b)
+ for _, want := range []string{"second line matters", `<li id="L2"`, "two",
+ `href="../code/hello.txt.html?note=1#L2"`, "static child", "static mention",
+ `class="threadnode via-reply"`, `class="threadnode via-mention"`,
+ `href="../note/1.html"`} {
+ if !strings.Contains(page, want) {
+ t.Errorf("exported note page is missing %q", want)
+ }
+ }
+ // the links out have to be relative, since an export is opened off a disk
+ if strings.Contains(page, `href="/`) {
+ t.Errorf("exported note page carries an absolute link:\n%s", page)
+ }
+ if strings.Contains(page, `class="replybox"`) {
+ t.Error("the static thread offers a reply form without a server")
+ }
+ notes, err := os.ReadFile(filepath.Join(out, "notes.html"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(notes), `class="perma" href="note/1.html"`) {
+ t.Error("the exported notes page offers no permalink")
+ }
+}
diff --git a/vrsite/render.go b/vrsite/render.go
index f18c3a8..61b1a1a 100644
--- a/vrsite/render.go
+++ b/vrsite/render.go
@@ -58,34 +58,66 @@ func (f *File) render(src []byte, hl *highlighter) {
}
}
}
+ f.Body = f.emit(src, classes, hl.classNames, 1, f.Total)
+}
+// emit writes lines [from..to] as <ol> chunks. from is where the chunking (and
+// so the browser's content-visibility windows) starts counting, which is what
+// lets an excerpt come out as one chunk rather than a slice of the file's.
+func (f *File) emit(src []byte, classes []int16, names []string, from, to int) template.HTML {
+ if from < 1 {
+ from = 1
+ }
+ if to > f.Total {
+ to = f.Total
+ }
+ if from > to {
+ return ""
+ }
+ start := 0
+ for ln := 1; ln < from; ln++ { // skip to the first line asked for
+ start = pastEOL(src, start)
+ }
+ stop := start
+ for ln := from; ln <= to; ln++ {
+ stop = pastEOL(src, stop)
+ }
var b strings.Builder
- b.Grow(len(src) + len(src)/2 + f.Total*48)
+ span := stop - start
+ b.Grow(span + span/2 + (to-from+1)*48)
var num []byte
writeInt := func(n int) { num = strconv.AppendInt(num[:0], int64(n), 10); b.Write(num) }
- start := 0
- for ln := 1; ln <= f.Total; ln++ {
+ for ln := from; ln <= to; ln++ {
end := start
for end < len(src) && src[end] != '\n' {
end++
}
- if (ln-1)%chunkLines == 0 {
- if ln > 1 {
+ opens := (ln-from)%chunkLines == 0
+ if opens {
+ if ln > from {
b.WriteString("</ol>")
}
- rem := f.Total - (ln - 1)
+ rem := to - (ln - 1)
if rem > chunkLines {
rem = chunkLines
}
- b.WriteString(`<ol class="code" style="counter-reset: ln `)
- writeInt(ln - 1)
- b.WriteString(`; --h: `)
+ b.WriteString(`<ol class="code" style="--h: `)
writeInt(rem * lineHeightPx)
b.WriteString(`px">`)
}
b.WriteString(`<li id="L`)
writeInt(ln)
b.WriteByte('"')
+ // Where the chunk's numbering starts. It has to be counter-set on the
+ // first line rather than counter-reset on the <ol>, because
+ // content-visibility: auto brings style containment with it and a
+ // contained element's own counter-reset comes out as 0 no matter what
+ // value it names — so every chunk but the first would count from 1.
+ if opens {
+ b.WriteString(` style="counter-set: ln `)
+ writeInt(ln)
+ b.WriteByte('"')
+ }
mask := uint8(0)
if ln < len(f.Cov) {
mask = f.Cov[ln]
@@ -110,14 +142,64 @@ func (f *File) render(src []byte, hl *highlighter) {
b.WriteByte('"')
}
b.WriteByte('>')
- emitLine(&b, src[start:end], classes[start:end], hl.classNames)
+ emitLine(&b, src[start:end], classes[start:end], names)
b.WriteString("</li>")
start = end + 1
}
- if f.Total > 0 {
- b.WriteString("</ol>")
+ b.WriteString("</ol>")
+ return template.HTML(b.String())
+}
+
+// pastEOL is the offset just past the line starting at i.
+func pastEOL(src []byte, i int) int {
+ for i < len(src) && src[i] != '\n' {
+ i++
}
- f.Body = template.HTML(b.String())
+ if i < len(src) {
+ i++
+ }
+ return i
+}
+
+// excerptContext is how many lines either side of a note's range its own page
+// shows, so the noted lines are read in context rather than in isolation.
+const excerptContext = 4
+
+// excerptFor renders just the lines a note is about, the way the code view
+// would: the file's real line numbers, its coverage tint, and the noted lines
+// marked. This is what lets a shared permalink stand on its own — whoever
+// opens it sees the note and the code it is about together, without having to
+// find their way into the file first.
+//
+// cov comes from the model and is read-only here, so the caller can pass the
+// live *File's slice rather than copying it.
+func excerptFor(path string, src []byte, cov []uint8, hl *highlighter, n *Note) (body template.HTML, from, to int) {
+ total := bytes.Count(src, []byte{'\n'})
+ if len(src) > 0 && src[len(src)-1] != '\n' {
+ total++
+ }
+ end := n.End
+ if end < n.Start {
+ end = n.Start
+ }
+ if n.Start < 1 || total == 0 {
+ return "", 0, 0
+ }
+ f := &File{Path: path, Cov: cov, Total: total, Notes: []*Note{n}, NoteAt: map[int]int{}}
+ for l := n.Start; l <= end; l++ {
+ f.NoteAt[l] = 0
+ }
+ from, to = n.Start-excerptContext, end+excerptContext
+ if from < 1 {
+ from = 1
+ }
+ if to > total {
+ to = total
+ }
+ if from > total {
+ return "", 0, 0
+ }
+ return f.emit(src, hl.classify(path, src), hl.classNames, from, to), from, to
}
// emitLine writes one line's spans, merging runs of the same class across
@@ -307,16 +389,45 @@ type paint struct {
cls int16
}
-// classify is safe for concurrent use: the shared per-language query is
-// immutable, and parser and cursor are per-call. Extensions no grammar claims
-// come back unclassified rather than guessed at.
+// classify paints a file. Extensions no grammar claims come back unclassified
+// rather than guessed at.
+func (h *highlighter) classify(path string, src []byte) []int16 {
+ return h.classifyWith(h.byExt[strings.ToLower(filepath.Ext(path))], src)
+}
+
+// fenceAliases maps the fence languages that are not already one of the
+// extensions in grammars. Most are ("go", "rs", "py", "ts", "json", "bash"),
+// so the grammar table does the bulk of the lookup on its own.
+var fenceAliases = map[string]string{
+ "golang": ".go", "rust": ".rs", "python": ".py", "python3": ".py",
+ "javascript": ".js", "node": ".js", "typescript": ".ts",
+ "c++": ".cc", "shell": ".sh", "zsh": ".sh", "console": ".sh",
+ "jsonc": ".json", "json5": ".json",
+}
+
+// classifyLang paints a fenced code block, whose language arrives as a name
+// rather than a path. Unknown or absent languages come back unclassified, the
+// same as an extension no grammar claims.
+func (h *highlighter) classifyLang(lang string, src []byte) []int16 {
+ if h == nil {
+ return make([]int16, len(src))
+ }
+ name := strings.ToLower(strings.TrimSpace(lang))
+ l := h.byExt["."+name]
+ if l == nil {
+ l = h.byExt[fenceAliases[name]]
+ }
+ return h.classifyWith(l, src)
+}
+
+// classifyWith is safe for concurrent use: the shared per-language query is
+// immutable, and parser and cursor are per-call.
//
// Matches arrive already filtered by their #eq?/#match?/#any-of? predicates —
// go-tree-sitter evaluates those in QueryMatches.Next — so every capture here
// is one the query really meant.
-func (h *highlighter) classify(path string, src []byte) []int16 {
+func (h *highlighter) classifyWith(l *language, src []byte) []int16 {
classes := make([]int16, len(src))
- l := h.byExt[strings.ToLower(filepath.Ext(path))]
if l == nil {
return classes
}
diff --git a/vrsite/serve.go b/vrsite/serve.go
index c6e1ef1..31217af 100644
--- a/vrsite/serve.go
+++ b/vrsite/serve.go
@@ -47,6 +47,7 @@ type server struct {
tpl *template.Template
hl *highlighter
theme, light *Theme
+ themes *themeSet
tx *transcripts
mu sync.Mutex
@@ -64,12 +65,16 @@ type server struct {
// scopeAll is the key of the unscoped model — the whole log, every change.
const scopeAll = "*"
-func newServer(logPath, repo, title, user string, theme, light *Theme) *server {
+func newServer(logPath, repo, title, user string, theme, light *Theme, themes *themeSet) *server {
+ if themes == nil {
+ themes = &themeSet{}
+ }
+ hl := newHighlighter(theme, light)
sv := &server{logPath: logPath, repo: repo, title: title, user: user,
- session: "web:" + user, theme: theme, light: light,
- tpl: newTemplates(), models: map[string]*Site{}, trees: map[string][]*File{},
+ session: "web:" + user, theme: theme, light: light, themes: themes,
+ tpl: newTemplates(hl), models: map[string]*Site{}, trees: map[string][]*File{},
rendered: map[string]*File{}, tx: newTranscripts(defaultTranscriptRoots())}
- sv.hl = newHighlighter(theme, light)
+ sv.hl = hl
sv.reload(true)
return sv
}
@@ -332,6 +337,7 @@ func (sv *server) handler() http.Handler {
mux.HandleFunc("GET /tree", sv.treeFrag)
mux.HandleFunc("GET /code/{path...}", sv.codePage)
mux.HandleFunc("GET /notes", sv.notesPage)
+ mux.HandleFunc("GET /note/{id}", sv.notePage)
mux.HandleFunc("GET /notes/list", sv.notesList)
mux.HandleFunc("POST /notes", sv.addNote)
mux.HandleFunc("GET /activity", sv.activity)
@@ -341,6 +347,8 @@ func (sv *server) handler() http.Handler {
mux.HandleFunc("GET /jj/op", sv.jjOp)
mux.HandleFunc("GET /change/{id}", sv.changePage)
mux.HandleFunc("GET /style.css", sv.styleCSS)
+ mux.HandleFunc("GET /themes.css", sv.themeAsset)
+ mux.HandleFunc("GET /themes.js", sv.themeAsset)
mux.HandleFunc("GET /app.js", sv.asset)
mux.HandleFunc("GET /htmx.min.js", sv.asset)
return mux
@@ -406,6 +414,75 @@ func (sv *server) notesPage(w http.ResponseWriter, r *http.Request) {
sv.render(w, p)
}
+// notePage is one note on its own — the permalink. It is looked up in the
+// whole-log model, never the scoped one, so a link handed to someone else
+// opens whatever change they arrive on.
+//
+// The page then reads at the change the note was written at, because that is
+// where its line numbers mean anything; ?rev= still overrides, for a reader
+// asking what those lines say now.
+func (sv *server) notePage(w http.ResponseWriter, r *http.Request) {
+ full, _, _ := sv.snap()
+ n := full.NoteByID(atoiDefault(r.PathValue("id"), 0))
+ if n == nil {
+ http.Error(w, "no note with that id in this log", http.StatusNotFound)
+ return
+ }
+ p, site, err := sv.newPage(r, "note", noteTitle(n)+" — "+sv.title)
+ if err != nil {
+ p.Err = err.Error()
+ sv.render(w, p)
+ return
+ }
+ p.Note, p.Thread, p.Current = n, full.NoteThread(n.ID), n.File
+ if p.Rev == "" && n.Change != "" && n.Change != p.ChangeID {
+ if row, rerr := changeBrief(sv.repo, n.Change); rerr == nil {
+ p.ChangeID, p.Commit, p.ChangeInfo = row.ID, row.Commit, &row
+ if row.ID != p.Default {
+ p.Rev = n.Change
+ }
+ site = sv.rescope(p)
+ } else {
+ p.ExErr = "the change this note was written at is no longer in the repository"
+ }
+ }
+ if n.File != "" && n.Start > 0 && p.ExErr == "" {
+ var cov []uint8
+ if bf, ok := site.Files[n.File]; ok {
+ cov = bf.Cov
+ }
+ src, ferr := fileShow(sv.repo, p.Commit, n.File)
+ switch {
+ case ferr != nil:
+ p.ExErr = "not present at this change"
+ case bytes.IndexByte(src, 0) >= 0:
+ p.ExErr = "binary file — not rendered"
+ default:
+ p.Excerpt, p.ExFrom, p.ExTo = excerptFor(n.File, src, cov, sv.hl, n)
+ }
+ }
+ sv.render(w, p)
+}
+
+// noteTitle names a note the way a browser tab and a link preview should: the
+// lines it is about, or its id when it is pinned to nothing.
+func noteTitle(n *Note) string {
+ if n.File == "" {
+ return "note #" + strconv.Itoa(n.ID)
+ }
+ name := n.File
+ if i := strings.LastIndex(name, "/"); i >= 0 {
+ name = name[i+1:]
+ }
+ if n.Start > 0 {
+ name += ":" + strconv.Itoa(n.Start)
+ if n.End > n.Start {
+ name += "-" + strconv.Itoa(n.End)
+ }
+ }
+ return name
+}
+
func (sv *server) notesList(w http.ResponseWriter, r *http.Request) {
p, site, _ := sv.newPage(r, "notes", "")
p.Filter = filterFrom(r)
@@ -444,7 +521,17 @@ func (sv *server) addNote(w http.ResponseWriter, r *http.Request) {
}
// a note belongs to the change its writer was reading, which is the one
// the form carries — not the working copy, which may have moved on since
- _, _, def := sv.snap()
+ full, _, def := sv.snap()
+ replyText := strings.TrimSpace(r.FormValue("reply_to"))
+ replyTo := 0
+ if replyText != "" {
+ var rerr error
+ replyTo, rerr = strconv.Atoi(replyText)
+ if rerr != nil || replyTo < 1 || full.NoteByID(replyTo) == nil {
+ http.Error(w, "reply target is not a note in this log", http.StatusBadRequest)
+ return
+ }
+ }
rev := strings.TrimSpace(r.FormValue("rev"))
target := strings.TrimSpace(r.FormValue("change"))
for _, fallback := range []string{rev, def, "@"} {
@@ -470,7 +557,8 @@ func (sv *server) addNote(w http.ResponseWriter, r *http.Request) {
Time: time.Now().Format(time.RFC3339), Op: "note",
Session: sv.session, Agent: sv.user, Dir: sv.repo,
Rev: rev, Change: change, File: strings.TrimSpace(r.FormValue("file")),
- Start: start, End: end, Type: strings.TrimSpace(r.FormValue("kind")), Text: text,
+ Start: start, End: end, ReplyTo: replyTo,
+ Type: strings.TrimSpace(r.FormValue("kind")), Text: text,
}
sv.mu.Lock()
@@ -639,6 +727,18 @@ func (sv *server) styleCSS(w http.ResponseWriter, r *http.Request) {
b.WriteTo(w)
}
+// themeAsset serves the two files the theme picker needs. Both are written
+// once at startup — the machine's themes do not change while we watch a log.
+func (sv *server) themeAsset(w http.ResponseWriter, r *http.Request) {
+ body, ctype := sv.themes.JS, "text/javascript; charset=utf-8"
+ if strings.HasSuffix(r.URL.Path, ".css") {
+ body, ctype = sv.themes.CSS, "text/css; charset=utf-8"
+ }
+ w.Header().Set("Content-Type", ctype)
+ w.Header().Set("Cache-Control", "max-age=300")
+ w.Write(body)
+}
+
func (sv *server) asset(w http.ResponseWriter, r *http.Request) {
name := strings.TrimPrefix(r.URL.Path, "/")
b, err := tfs.ReadFile("assets/" + name)
@@ -651,12 +751,14 @@ func (sv *server) asset(w http.ResponseWriter, r *http.Request) {
w.Write(b)
}
-func serve(addr, logPath, repo, title, user string, roots []string, theme, light *Theme) {
- sv := newServer(logPath, repo, title, user, theme, light)
+func serve(addr, logPath, repo, title, user string, roots []string, theme, light *Theme, themes *themeSet) {
+ sv := newServer(logPath, repo, title, user, theme, light, themes)
sv.tx = newTranscripts(roots)
site, _, def := sv.snap()
fmt.Printf("vrsite: http://%s — %d notes, %d agents, repo %s at %s\n",
addr, len(site.Notes), len(site.Agents), repo, short(def))
+ fmt.Printf("vrsite: themes — %s dark, %s light, %d more to pick from (%d zed, %d helix)\n",
+ theme.Name, light.Name, len(sv.themes.List), sv.themes.count("zed"), sv.themes.count("helix"))
fmt.Printf("vrsite: comments are appended to %s; the repo is never written to\n", logPath)
fmt.Printf("vrsite: session transcripts read from %s\n", strings.Join(sv.tx.roots, ", "))
if err := http.ListenAndServe(addr, sv.handler()); err != nil {
diff --git a/vrsite/static.go b/vrsite/static.go
index 7d31b1d..b299975 100644
--- a/vrsite/static.go
+++ b/vrsite/static.go
@@ -13,6 +13,7 @@ import (
"os/exec"
"path/filepath"
"runtime"
+ "strconv"
"strings"
"sync"
"time"
@@ -31,7 +32,7 @@ func run(dir string, name string, args ...string) []byte {
return out
}
-func buildSite(logPath, repoAbs, out, title string, theme, light *Theme) {
+func buildSite(logPath, repoAbs, out, title string, theme, light *Theme, themes *themeSet) {
start := time.Now()
entries := readLog(logPath)
ranked := buildModel(title, entries, "").Ranked
@@ -80,7 +81,7 @@ func buildSite(logPath, repoAbs, out, title string, theme, light *Theme) {
fatal("tar: %v", err)
}
- writeSite(site, out, tmp)
+ writeSite(site, out, tmp, themes)
stubs := 0
for _, f := range site.All {
if f.Stub != "" {
@@ -92,8 +93,12 @@ func buildSite(logPath, repoAbs, out, title string, theme, light *Theme) {
time.Since(start).Round(time.Millisecond), out)
}
-func writeSite(site *Site, out, srcDir string) {
- tpl := newTemplates()
+func writeSite(site *Site, out, srcDir string, themes *themeSet) {
+ if themes == nil {
+ themes = &themeSet{}
+ }
+ hl := newHighlighter(site.Theme, site.Light)
+ tpl := newTemplates(hl)
if err := os.MkdirAll(out, 0o755); err != nil {
fatal("%v", err)
}
@@ -113,7 +118,6 @@ func writeSite(site *Site, out, srcDir string) {
}
// phase A: content — coverage percentages must exist before any tree
// or index below is written
- hl := newHighlighter(site.Theme, site.Light)
each(func(f *File) {
src, err := os.ReadFile(filepath.Join(srcDir, f.Path))
switch {
@@ -142,6 +146,13 @@ func writeSite(site *Site, out, srcDir string) {
fatal("%v", err)
}
}
+ // the picker travels with the export: a <script src>/<link> pair loads
+ // from the filesystem, which is why the menu is a script and not JSON
+ for name, b := range map[string][]byte{"themes.css": themes.CSS, "themes.js": themes.JS} {
+ if err := os.WriteFile(filepath.Join(out, name), b, 0o644); err != nil {
+ fatal("%v", err)
+ }
+ }
// shared tree fragment: the only nav tree, fetched by every page; "@@" is
// replaced with the page root by app.js, since the fragment is fetched
@@ -196,6 +207,35 @@ func writeSite(site *Site, out, srcDir string) {
render(dst, &page{Kind: "file", Title: f.Path + " — " + site.Title,
Root: strings.Repeat("../", depth), Current: f.Path, File: f})
})
+
+ // phase C: a page per note, so a permalink handed to someone else opens
+ // in the export exactly as it does in the live app. The source is read
+ // again rather than kept from phase A: an export is a batch job, and the
+ // alternative is holding every file in memory for the sake of a few
+ // hundred excerpts.
+ if err := os.MkdirAll(filepath.Join(out, "note"), 0o755); err != nil {
+ fatal("%v", err)
+ }
+ for _, n := range site.Notes {
+ p := &page{Kind: "note", Title: noteTitle(n) + " — " + site.Title,
+ Root: "../", Current: n.File, Note: n, Thread: site.NoteThread(n.ID)}
+ if n.File != "" && n.Start > 0 {
+ src, err := os.ReadFile(filepath.Join(srcDir, n.File))
+ switch {
+ case err != nil:
+ p.ExErr = "not present in the archived commit"
+ case bytes.IndexByte(src, 0) >= 0:
+ p.ExErr = "binary file — not rendered"
+ default:
+ var cov []uint8
+ if f, ok := site.Files[n.File]; ok {
+ cov = f.Cov
+ }
+ p.Excerpt, p.ExFrom, p.ExTo = excerptFor(n.File, src, cov, hl, n)
+ }
+ }
+ render(filepath.Join(out, "note", strconv.Itoa(n.ID)+".html"), p)
+ }
}
func mustCreate(path string) *os.File {
diff --git a/vrsite/templates/frag-note.tmpl b/vrsite/templates/frag-note.tmpl
index 0c3182c..3b04e11 100644
--- a/vrsite/templates/frag-note.tmpl
+++ b/vrsite/templates/frag-note.tmpl
@@ -1,19 +1,26 @@
{{define "note"}}{{$n := .N}}{{$p := .P}}{{$away := and $n.Change $p.ChangeID (ne $n.Change $p.ChangeID)}}
<article class="note am{{$n.Agent.Bit}}{{if $away}} away{{end}}" id="note-{{$n.ID}}"
- data-agent="{{$n.Agent.Name}}" data-model="{{$n.Model}}" data-session="{{$n.Session}}"
- data-kind="{{$n.Kind}}" data-file="{{$n.File}}" data-date="{{if not $n.At.IsZero}}{{$n.At.Format "2006-01-02"}}{{end}}"
- data-start="{{$n.Start}}" data-end="{{$n.End}}">
+ data-agent="{{$n.Agent.Name}}" data-model="{{$n.Model}}" data-session="{{$n.Session}}"
+ data-kind="{{$n.Kind}}" data-file="{{$n.File}}" data-date="{{if not $n.At.IsZero}}{{$n.At.Format "2006-01-02"}}{{end}}"
+ data-start="{{$n.Start}}" data-end="{{$n.End}}" data-reply-to="{{$n.ReplyTo}}">
<header>
{{if $n.File}}<a href="{{$p.NoteHref $n}}" title="{{$n.File}}">{{base $n.File}}{{lines $n}}</a>{{end}}
{{if $n.Kind}}<em>{{$n.Kind}}</em>{{end}}
<b class="who">{{$n.Agent.Short}}</b>
</header>
- <p>{{$n.Text}}</p>
+ <div class="body">{{md $n.Text $p}}</div>
<footer class="meta">
+ {{with $p.PermaHref $n}}<a class="perma" href="{{.}}"
+ title="permalink — this note on its own page, with the code it is about">#{{$n.ID}}</a>
+ {{else}}<span class="perma self">#{{$n.ID}}</span>{{end}}
{{if not $n.At.IsZero}}<time datetime="{{$n.At.Format "2006-01-02T15:04:05Z07:00"}}" title="{{$n.Ago}}">{{$n.When}}</time>{{end}}
{{if $n.Model}}<span class="model">{{$n.Model}}{{if $n.Reason}}/{{$n.Reason}}{{end}}</span>{{end}}
- {{if $n.Session}}<code class="sess" title="{{$n.Session}}">{{short $n.Session}}</code>{{end}}
- {{with $p.ConvHref $n}}<a class="conv" href="{{.}}" title="open the conversation this note was written in">conversation</a>{{end}}
+ {{if $n.Session}}<code class="sess" title="{{$n.Session}}">{{short $n.Session}}</code>{{end}}
+ {{if $n.ReplyTo}}{{with $p.ReplyHref $n}}<a class="parent" href="{{.}}"
+ title="the note this explicitly replies to">reply to #{{$n.ReplyTo}}</a>{{else}}<span class="parent">reply to #{{$n.ReplyTo}}</span>{{end}}{{end}}
+ {{with $p.ThreadHref $n}}<a class="relations" href="{{.}}" title="notes replying to or mentioning this note">
+ {{if $n.Replies}}↳ {{len $n.Replies}} repl{{if eq (len $n.Replies) 1}}y{{else}}ies{{end}}{{end}}{{if and $n.Replies $n.Mentions}} · {{end}}{{if $n.Mentions}}↶ {{len $n.Mentions}} mention{{if ne (len $n.Mentions) 1}}s{{end}}{{end}}</a>{{end}}
+ {{with $p.ConvHref $n}}<a class="conv" href="{{.}}" title="open the conversation this note was written in">conversation</a>{{end}}
{{if $n.Change}}
{{if $p.Live}}<a class="atchg{{if $away}} away{{end}}" href="{{$p.Href "change" $n.Change}}"
title="{{if $away}}written at another change{{else}}written at this change{{end}}"><code>{{short $n.Change}}</code></a>
@@ -30,6 +37,44 @@
{{define "note-added"}}{{if .File}}<span id="notecount" hx-swap-oob="true">{{len .File.Notes}}</span>{{end}}{{range .Notes}}{{template "note" (dict "N" . "P" $)}}{{end}}{{end}}
+{{/* A child-note form appears below every full card in a focused thread. It
+ deliberately uses a normal POST: one append and one redirect rebuild the
+ entire DAG from the log, including shared mention paths. */}}
+{{define "replyform"}}{{$n := .N}}{{$p := .P}}
+<details class="replybox">
+ <summary>reply to #{{$n.ID}}</summary>
+ <form class="notform replyform" method="post" action="{{$p.Href "notes"}}">
+ <input type="hidden" name="reply_to" value="{{$n.ID}}">
+ <input type="hidden" name="rev" value="{{$p.Rev}}">
+ <input type="hidden" name="change" value="{{$p.ChangeID}}">
+ <input type="hidden" name="scope" value="all">
+ <input type="hidden" name="back" value="{{$p.Href "note" (printf "%d" $p.Note.ID)}}#discussion">
+ <textarea name="text" rows="2" placeholder="reply… (⌘/ctrl+enter to post)" aria-label="reply to note {{$n.ID}}" required></textarea>
+ <div class="row end">
+ <span class="meta">as <b>{{$p.User}}</b> at <code title="{{$p.ChangeID}}">{{short $p.ChangeID}}</code> → the log</span>
+ <button type="submit">reply</button>
+ </div>
+ </form>
+</details>
+{{end}}
+
+{{/* Recursive at arbitrary depth. A DAG join/cycle is a reference rather than
+ a second card, which keeps ids unique and recursion finite. */}}
+{{define "threadnode"}}{{$x := .Node}}{{$p := .P}}
+<div class="threadnode via-{{$x.Via}}">
+ <div class="threadedge" aria-label="{{$x.Via}}"><span>{{if eq $x.Via "reply"}}↳{{else}}↶{{end}}</span></div>
+ <div class="threadcontent">
+ {{if $x.Reference}}
+ <p class="threadref meta">{{$x.Via}} also reaches <a href="{{$p.Href "note" (printf "%d" $x.Note.ID)}}">note #{{$x.Note.ID}}</a></p>
+ {{else}}
+ {{template "note" (dict "N" $x.Note "P" $p)}}
+ {{if $p.Live}}{{template "replyform" (dict "N" $x.Note "P" $p)}}{{end}}
+ {{if $x.Children}}<div class="threadchildren">{{range $x.Children}}{{template "threadnode" (dict "Node" . "P" $p)}}{{end}}</div>{{end}}
+ {{end}}
+ </div>
+</div>
+{{end}}
+
{{/* the add-a-comment form; live pages only, since it writes to the log */}}
{{define "noteform"}}
<form class="notform" hx-post="{{.Href "notes"}}" hx-target="#notelist" hx-swap="afterbegin"
diff --git a/vrsite/templates/layout.tmpl b/vrsite/templates/layout.tmpl
index 9bc9ddd..268b5b6 100644
--- a/vrsite/templates/layout.tmpl
+++ b/vrsite/templates/layout.tmpl
@@ -4,12 +4,20 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}}</title>
+<link rel="stylesheet" href="{{.Href "asset" "style.css"}}">
<script>
+// the theme is worn before the first paint. A theme that is not one of the
+// two the site was built with lives in themes.css, so pull that in too —
+// from here, after style.css, so its :root block is the one that wins.
{const d = document.documentElement, t = localStorage.getItem("vrsite-theme");
if (t && t !== "system") d.dataset.theme = t;
+ if (t && !/^(system|dark|light)$/.test(t)) {
+ const l = document.createElement("link");
+ l.rel = "stylesheet"; l.id = "themescss"; l.href = "{{.Href "asset" "themes.css"}}";
+ document.head.append(l);
+ }
d.dataset.mode = localStorage.getItem("vrsite-mode") || "trace";}
</script>
-<link rel="stylesheet" href="{{.Href "asset" "style.css"}}">
{{if .Live}}<script src="{{.Href "asset" "htmx.min.js"}}"></script>{{end}}
<script src="{{.Href "asset" "app.js"}}" defer></script>
</head>
@@ -35,11 +43,20 @@
<a href="{{.Href "files"}}">files</a>{{if .Live}} ·
<a href="{{.Href "jj"}}">jj</a>{{end}}
</span>
- <select id="themesel" aria-label="color scheme">
- <option value="system">system</option>
- <option value="dark">dark</option>
- <option value="light">light</option>
- </select>
+ <div class="themepick" id="themepick"
+ data-js="{{.Href "asset" "themes.js"}}" data-css="{{.Href "asset" "themes.css"}}"
+ data-dark="{{.Site.Theme.Name}}" data-light="{{.Site.Light.Name}}"
+ data-darkid="{{.Site.Theme.ID}}" data-lightid="{{.Site.Light.ID}}">
+ <button type="button" id="themebtn" aria-haspopup="listbox" aria-expanded="false"
+ title="theme — every one installed here (T)">theme</button>
+ <div class="thememenu" id="thememenu" hidden>
+ <input id="themefind" type="search" placeholder="filter themes…" aria-label="filter themes"
+ role="combobox" aria-controls="themelist" aria-autocomplete="list"
+ autocomplete="off" spellcheck="false">
+ <ul id="themelist" role="listbox" aria-label="themes"></ul>
+ <p class="meta hint">↑↓ preview · enter keeps · esc puts it back</p>
+ </div>
+ </div>
</footer>
</nav>
@@ -47,6 +64,7 @@
{{if .Err}}<p class="err">{{.Err}}</p>{{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}}
+<header class="filehead">
+ <h1>{{if $n.File}}{{base $n.File}}<span class="lns">{{lines $n}}</span>{{else}}Note{{end}}</h1>
+ <p class="meta">
+ note <b>#{{$n.ID}}</b> by <b>{{$n.Agent.Short}}</b>{{if $n.Kind}} · <em class="kind">{{$n.Kind}}</em>{{end}}
+ {{if not $n.At.IsZero}} · <time datetime="{{$n.At.Format "2006-01-02T15:04:05Z07:00"}}">{{$n.When}}</time>
+ <span class="ago">({{$n.Ago}})</span>{{end}}
+ {{if $n.Change}} · read at
+ {{if .Live}}<a href="{{.Href "change" $n.Change}}"><code>{{short $n.Change}}</code></a>
+ {{else}}<code title="{{$n.Change}}">{{short $n.Change}}</code>{{end}}
+ {{end}}
+ </p>
+ {{if ne $n.File (base $n.File)}}<p class="meta"><code>{{$n.File}}</code></p>{{end}}
+</header>
+
+<section class="notepage">
+ <div class="onenote">{{template "note" (dict "N" $n "P" $)}}</div>
+
+ {{$open := .HasCode $n.File}}
+ {{if .Excerpt}}
+ <p class="meta exhead">lines {{.ExFrom}}–{{.ExTo}} of <code>{{$n.File}}</code>{{if $open}} ·
+ <a href="{{.NoteHref $n}}">open the whole file →</a>{{end}}</p>
+ <div class="codewrap excerpt">{{.Excerpt}}</div>
+ {{else if .ExErr}}
+ <p class="meta stub">{{.ExErr}}</p>
+ {{else if $n.File}}
+ <p class="meta hint">this note is about the whole file{{if $open}} —
+ <a href="{{.NoteHref $n}}">open <code>{{base $n.File}}</code> →</a>{{end}}</p>
+ {{else}}
+ <p class="meta hint">this note is not pinned to a file</p>
+ {{end}}
+
+ {{if .Live}}{{template "replyform" (dict "N" $n "P" $)}}{{end}}
+
+ <section class="discussion" id="discussion">
+ <h2>Replies &amp; mentions
+ <span class="meta">{{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}}</span>
+ </h2>
+ {{if .Thread.Children}}
+ <div class="threadchildren rootchildren">{{range .Thread.Children}}{{template "threadnode" (dict "Node" . "P" $)}}{{end}}</div>
+ {{else}}
+ <p class="meta hint">no notes reply to or mention this one yet</p>
+ {{end}}
+ </section>
+
+ <p class="meta more"><a href="{{.Href "notes"}}">← every note in the log</a></p>
+</section>
+{{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 ""
- }
- 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"`
+ return false
}
- 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)
+ }
+ }
+}