summaryrefslogtreecommitdiff
path: root/vrsite/assets
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-01 16:18:19 -0300
committerGabriel Schneider <[email protected]>2026-08-01 18:21:40 -0300
commit151ea12251ed876aa87bb486da1bc337e25369e9 (patch)
tree0363d90b5df6fc8b17b1737ef2793ba3825ba4d4 /vrsite/assets
parent8ec4e135e318c7799b2576a8729493c063da4a97 (diff)
downloadnotevi-151ea12251ed876aa87bb486da1bc337e25369e9.tar.gz
notevi-151ea12251ed876aa87bb486da1bc337e25369e9.zip
Diffstat (limited to 'vrsite/assets')
-rw-r--r--vrsite/assets/app.js212
-rw-r--r--vrsite/assets/style.css.tmpl157
2 files changed, 354 insertions, 15 deletions
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; }
+}