summaryrefslogtreecommitdiff
path: root/vrsite/assets/app.js
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-02 23:43:08 -0300
committerGabriel Schneider <[email protected]>2026-08-03 09:54:39 -0300
commit5ec72f8723d795d0b02d7e9ebb9f555f0c7e6a2e (patch)
treef12f4b700fff4ff6f7e818d2a542672ba0b8d76e /vrsite/assets/app.js
parenta9263daee9413c5eff3c1f1bebcd224442fd8685 (diff)
downloadnotevi-5ec72f8723d795d0b02d7e9ebb9f555f0c7e6a2e.tar.gz
notevi-5ec72f8723d795d0b02d7e9ebb9f555f0c7e6a2e.zip
rebrand to notevi: one CLI over the jj sidecar
vr and vrsite become a single binary. vrsite/ folds into a web package in one module (0x4200.cafe/notevi); "notevi web" serves and exports exactly what vrsite did, and "notevi read/grep/note/query" is unchanged. The sidecar is renamed with it: notevi_log, notevi-log.jsonl, and the description "private: notevi log". The pre-rebrand names are still recognized, so an old repository opens and reads; it is renamed in place on the first write, or up front with "notevi migrate DIR...". That rename cannot be a single mv inside jj run. jj only auto-tracks a *new* file in the run working copy below a size limit it does not take from the command line, so writing a whole log under a name the change has never held is silently dropped while jj reports success. ensureLogFile creates the file empty first and lets every later byte be a modification of a tracked file, which snapshots at any size; that also fixes the same latent bug when importing a large legacy vr-log.jsonl. Adds a bem-te-vi mark (favicon and nav brand) and a README. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Diffstat (limited to 'vrsite/assets/app.js')
-rw-r--r--vrsite/assets/app.js461
1 files changed, 0 insertions, 461 deletions
diff --git a/vrsite/assets/app.js b/vrsite/assets/app.js
deleted file mode 100644
index 854959a..0000000
--- a/vrsite/assets/app.js
+++ /dev/null
@@ -1,461 +0,0 @@
-// app.js — the client half of vrsite. Everything here works in the static
-// export too; htmx only takes over the parts that need a server (filters,
-// comments, live activity, jj queries).
-"use strict";
-
-const root = document.documentElement;
-const live = root.dataset.live === "1";
-
-// ── focus: trace or notes, never both ─────────────────────────────────────
-// Coverage and notes want the same pixels (line background, gutter color,
-// tree markers), so the whole UI switches between them instead of layering.
-function setMode(m) {
- root.dataset.mode = m;
- localStorage.setItem("vrsite-mode", m);
- for (const b of document.querySelectorAll(".mode"))
- b.setAttribute("aria-pressed", String(b.dataset.mode === m));
-}
-for (const b of document.querySelectorAll(".mode"))
- b.addEventListener("click", () => setMode(b.dataset.mode));
-setMode(root.dataset.mode || "trace");
-
-const typing = e =>
- /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName) || e.target.isContentEditable;
-
-// The project hub is fully server-derived; filtering is deliberately local so
-// choosing a project does not create another piece of server state.
-const projectFind = document.getElementById("projectfind");
-if (projectFind) {
- const apply = () => {
- const q = projectFind.value.trim().toLowerCase();
- for (const card of document.querySelectorAll(".project-card"))
- card.hidden = !!q && !(card.dataset.project || "").toLowerCase().includes(q);
- };
- projectFind.addEventListener("input", apply);
- projectFind.addEventListener("search", apply);
- projectFind.addEventListener("keydown", e => {
- if (e.key === "Escape") { projectFind.value = ""; apply(); projectFind.blur(); }
- if (e.key === "Enter") {
- const first = document.querySelector(".project-card:not([hidden]) a.project-action");
- if (first) first.click();
- }
- });
-}
-
-document.addEventListener("keydown", e => {
- if (e.metaKey || e.ctrlKey || e.altKey || typing(e)) return;
- if (e.key === "t") setMode(root.dataset.mode === "notes" ? "trace" : "notes");
- if (e.key === "/") {
- const f = document.getElementById("filefind");
- if (f) { e.preventDefault(); f.focus(); f.select(); }
- }
-});
-
-// ── theme ─────────────────────────────────────────────────────────────────
-// 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();
- }
- });
-}
-
-// ── nav tree ──────────────────────────────────────────────────────────────
-const nav = document.getElementById("nav");
-const tree = document.getElementById("tree");
-const state = JSON.parse(localStorage.getItem("vrsite-tree") || "{}");
-
-// The file tree can get out of the way without losing repository context or
-// the navigation footer. Keep the choice across page loads in this browser.
-const fileList = document.getElementById("filelist");
-const fileToggle = document.getElementById("filetoggle");
-if (fileList && fileToggle) {
- const setFilesHidden = hidden => {
- root.dataset.files = hidden ? "hidden" : "shown";
- fileToggle.setAttribute("aria-expanded", String(!hidden));
- fileToggle.textContent = hidden ? "show files" : "hide files";
- localStorage.setItem("vrsite-files-hidden", hidden ? "1" : "0");
- };
- setFilesHidden(localStorage.getItem("vrsite-files-hidden") === "1");
- fileToggle.addEventListener("click", () => setFilesHidden(root.dataset.files !== "hidden"));
-}
-
-if (nav && tree) {
- const base = nav.dataset.root, cur = nav.dataset.current;
- fetch(nav.dataset.tree).then(r => r.text()).then(t => {
- tree.innerHTML = t.replaceAll('href="@@', 'href="' + base);
- for (const d of tree.querySelectorAll("details"))
- if (d.dataset.path in state) d.open = !!state[d.dataset.path];
- if (cur)
- for (const a of tree.querySelectorAll("a.file"))
- if (a.dataset.path === cur) {
- a.setAttribute("aria-current", "page");
- for (let e = a.parentElement; e && e !== tree; e = e.parentElement)
- if (e.tagName === "DETAILS") e.open = true;
- a.scrollIntoView({block: "center"});
- break;
- }
- tree.addEventListener("toggle", e => {
- if (tree.classList.contains("filtering")) return; // filter drives open state
- state[e.target.dataset.path] = e.target.open ? 1 : 0;
- localStorage.setItem("vrsite-tree", JSON.stringify(state));
- }, true);
- }).catch(() => {
- tree.innerHTML = '<p class="meta">tree needs http — use the <a href="' +
- base + 'files.html">file index</a></p>';
- });
-}
-
-// ── find a file ───────────────────────────────────────────────────────────
-const find = document.getElementById("filefind");
-if (find && tree) {
- const apply = () => {
- const q = find.value.trim().toLowerCase();
- tree.classList.toggle("filtering", q !== "");
- for (const a of tree.querySelectorAll("a.file")) {
- const hit = !q || a.dataset.path.toLowerCase().includes(q);
- a.closest("li").hidden = !hit;
- a.classList.toggle("hit", !!q && hit);
- }
- for (const d of tree.querySelectorAll("details")) {
- const any = d.querySelector("li:not([hidden])");
- d.hidden = !!q && !any;
- d.open = q ? !!any : !!state[d.dataset.path];
- }
- };
- find.addEventListener("input", apply);
- find.addEventListener("search", apply);
- find.addEventListener("keydown", e => {
- if (e.key === "Escape") { find.value = ""; apply(); find.blur(); }
- if (e.key === "Enter") {
- const first = tree.querySelector("li:not([hidden]) a.file");
- if (first) first.click();
- }
- });
-}
-
-// ── code lines: jump to a note, pick a range to comment on ────────────────
-const codeLines = document.querySelectorAll("ol.code li");
-const nstart = document.getElementById("nstart"), nend = document.getElementById("nend");
-let anchor = 0;
-
-function lineNo(li) { return parseInt(li.id.slice(1), 10) || 0; }
-
-function markSelection(a, b) {
- const lo = Math.min(a, b), hi = Math.max(a, b);
- for (const li of codeLines) {
- const n = lineNo(li);
- li.classList.toggle("sel", n >= lo && n <= hi);
- }
- if (nstart) nstart.value = lo;
- if (nend) nend.value = hi > lo ? hi : "";
-}
-
-for (const li of codeLines) {
- li.addEventListener("click", e => {
- const note = li.dataset.note && document.getElementById(li.dataset.note);
- if (note && root.dataset.mode === "notes") {
- note.scrollIntoView({behavior: "smooth", block: "center"});
- note.classList.remove("flash"); void note.offsetWidth; note.classList.add("flash");
- }
- if (!live || root.dataset.mode !== "notes") return;
- const n = lineNo(li);
- if (e.shiftKey && anchor) markSelection(anchor, n);
- else { anchor = n; markSelection(n, n); }
- });
-}
-
-// ⌘/ctrl+enter posts the note form from inside the textarea
-for (const ta of document.querySelectorAll(".notform textarea"))
- ta.addEventListener("keydown", e => {
- if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) ta.form.requestSubmit();
- });
-
-// ── conversation ──────────────────────────────────────────────────────────
-// A session page opened from a note lands on that note, flashed the same way
-// clicking a noted line flashes it.
-const conv = document.querySelector(".conv[data-anchor]");
-if (conv && conv.dataset.anchor) {
- const here = document.getElementById(conv.dataset.anchor);
- if (here) {
- here.scrollIntoView({block: "start"});
- here.classList.remove("flash"); void here.offsetWidth; here.classList.add("flash");
- }
-}
-
-// The trip back: a note in a conversation opens the code it was written
-// about. The card is the target, so aim anywhere on it — except at the links
-// it already carries.
-const goTo = w => { location.href = w.dataset.href; };
-for (const w of document.querySelectorAll(".notewrap[data-href]")) {
- w.addEventListener("click", e => { if (!e.target.closest("a")) goTo(w); });
- w.addEventListener("keydown", e => {
- if (e.key === "Enter" || e.key === " ") { e.preventDefault(); goTo(w); }
- });
-}
-
-// ── arriving at a note ────────────────────────────────────────────────────
-// ?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");
- const card = document.getElementById("note-" + want);
- if (card) {
- // the card's own range, not the lines' data-note: a line belongs to the
- // first note that claimed it, so overlapping notes would mark nothing
- const from = +card.dataset.start, to = +card.dataset.end || +card.dataset.start;
- let first = null;
- for (let n = from; n > 0 && n <= to; n++) {
- const li = document.getElementById("L" + n);
- if (!li) continue;
- li.classList.add("focus");
- first = first || li;
- }
- // after the browser has had its own go at the #L anchor
- if (first) requestAnimationFrame(() => first.scrollIntoView({block: "center"}));
- card.scrollIntoView({block: "nearest"});
- card.classList.remove("flash"); void card.offsetWidth; card.classList.add("flash");
- }
-}
-
-// ── note filters ──────────────────────────────────────────────────────────
-// Live pages let the server filter (htmx); the static export filters the
-// rendered list in place, so the same form works either way.
-const filters = document.getElementById("notefilters");
-if (filters) {
- const count = document.getElementById("notecount");
- const val = n => (filters.elements[n] ? filters.elements[n].value.trim().toLowerCase() : "");
- const applyLocal = () => {
- const text = val("text"), file = val("file"), agent = val("agent"),
- model = val("model"), session = val("session"), kind = val("kind"),
- from = val("from"), to = val("to");
- let shown = 0;
- for (const art of document.querySelectorAll("#notelist .note")) {
- // 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)) &&
- (!agent || d.agent === filters.elements.agent.value) &&
- (!model || d.model === filters.elements.model.value) &&
- (!session || d.session === filters.elements.session.value) &&
- (!kind || d.kind === filters.elements.kind.value) &&
- (!from || (d.date && d.date >= from)) &&
- (!to || (d.date && d.date <= to));
- art.hidden = !ok;
- if (ok) shown++;
- }
- if (count) count.textContent = shown;
- };
- if (!live) {
- filters.addEventListener("input", applyLocal);
- filters.addEventListener("change", applyLocal);
- filters.addEventListener("submit", e => { e.preventDefault(); applyLocal(); });
- }
- const clear = document.getElementById("clearfilters");
- if (clear) clear.addEventListener("click", () => {
- filters.reset();
- for (const el of filters.elements) if (el.name) el.value = "";
- if (live) window.htmx && htmx.trigger(filters, "change");
- else applyLocal();
- });
-}