summaryrefslogtreecommitdiff
path: root/web/templates
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 /web/templates
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 'web/templates')
-rw-r--r--web/templates/frag-activity.tmpl36
-rw-r--r--web/templates/frag-change.tmpl41
-rw-r--r--web/templates/frag-jj.tmpl33
-rw-r--r--web/templates/frag-note.tmpl105
-rw-r--r--web/templates/layout.tmpl94
-rw-r--r--web/templates/page-change.tmpl38
-rw-r--r--web/templates/page-file.tmpl20
-rw-r--r--web/templates/page-files.tmpl13
-rw-r--r--web/templates/page-index.tmpl56
-rw-r--r--web/templates/page-jj.tmpl25
-rw-r--r--web/templates/page-note.tmpl54
-rw-r--r--web/templates/page-notes.tmpl52
-rw-r--r--web/templates/page-session.tmpl70
-rw-r--r--web/templates/projects.tmpl113
14 files changed, 750 insertions, 0 deletions
diff --git a/web/templates/frag-activity.tmpl b/web/templates/frag-activity.tmpl
new file mode 100644
index 0000000..071c556
--- /dev/null
+++ b/web/templates/frag-activity.tmpl
@@ -0,0 +1,36 @@
+{{/* Who is reading the codebase right now, straight off the log tail. */}}
+{{define "activity"}}
+{{if .Compact}}
+ <div class="actlist">
+ {{range .Sessions}}
+ <div class="act{{if .Live}} on{{end}}" title="{{.Agent}} · {{.Model}} · {{.Dir}}">
+ {{if and $.Live .ID}}<a class="chip am{{.Bit}}" href="{{$.Href "session" .ID}}">{{.Short}}</a>
+ {{else}}<span class="chip am{{.Bit}}">{{.Short}}</span>{{end}}
+ <span class="what">{{.LastOp}}</span>
+ <span class="when">{{.Ago}}</span>
+ </div>
+ {{end}}
+ {{if not .Sessions}}<p class="meta hint">nothing logged yet</p>{{end}}
+ </div>
+{{else}}
+ <table class="acttable">
+ <thead><tr><th>agent</th><th>model</th><th>session</th><th>last op</th><th>when</th><th>r/g/n</th><th>dir</th></tr></thead>
+ <tbody>
+ {{range .Sessions}}
+ <tr class="{{if .Live}}on{{end}}">
+ <td><span class="chip am{{.Bit}}">{{.Short}}</span>{{if .Live}}<span class="pulse" title="active">●</span>{{end}}</td>
+ <td>{{.Model}}{{if .Reason}}<span class="meta">/{{.Reason}}</span>{{end}}</td>
+ <td>{{if and $.Live .ID}}<a href="{{$.Href "session" .ID}}" title="{{.ID}}"><code>{{short .ID}}</code></a>
+ {{else}}<code title="{{.ID}}">{{short .ID}}</code>{{end}}</td>
+ <td class="what">{{.LastOp}}</td>
+ <td>{{.Ago}}</td>
+ <td class="meta">{{.Reads}}/{{.Greps}}/{{.Notes}}</td>
+ <td class="meta dir" title="{{.Dir}}">{{base .Dir}}</td>
+ </tr>
+ {{end}}
+ </tbody>
+ </table>
+ {{if not .Sessions}}<p class="meta hint">nothing logged yet</p>{{end}}
+ <p class="meta">live = active in the last {{.Window}}</p>
+{{end}}
+{{end}}
diff --git a/web/templates/frag-change.tmpl b/web/templates/frag-change.tmpl
new file mode 100644
index 0000000..b33d746
--- /dev/null
+++ b/web/templates/frag-change.tmpl
@@ -0,0 +1,41 @@
+{{/* The change everything on screen is read at, and how wide the view is.
+ Every page carries it, because every read, note and file listing below
+ is relative to it. */}}
+{{define "changebar"}}
+<section class="changebar{{if .Rev}} away{{end}}" aria-label="current change">
+ <div class="cid">
+ <span class="lbl">change</span>
+ {{if .Live}}<a href="{{.Href "change" .ChangeID}}" title="{{.ChangeID}}"><code>{{short .ChangeID}}</code></a>
+ {{else}}<code title="{{.ChangeID}}">{{short .ChangeID}}</code>{{end}}
+ {{with .ChangeInfo}}{{if .Flags}}<span class="flags">{{.Flags}}</span>{{end}}{{end}}
+ </div>
+ {{with .ChangeInfo}}
+ <p class="desc" title="{{.Desc}}">{{if .Desc}}{{.Desc}}{{else}}(no description){{end}}</p>
+ {{end}}
+
+ <p class="counts">
+ {{if eq .Scope "all"}}
+ <b>{{.Site.NotesTotal}}</b> notes across every change{{if .Here}} · {{.Here}} here{{end}}
+ {{else if .ScopeEmpty}}
+ nothing recorded at this change — showing all <b>{{.Site.NotesTotal}}</b>
+ {{else}}
+ <b>{{.Here}}</b> notes here{{if .Elsewhere}} · <span class="away">{{.Elsewhere}} elsewhere</span>{{end}}
+ {{end}}
+ </p>
+
+ {{if .Live}}
+ <div class="scope" role="group" aria-label="what this view covers">
+ <a href="{{.ScopeHref "change"}}"{{if eq .Scope "change"}} class="on" aria-current="true"{{end}}
+ title="only what was recorded at this change">this change</a>
+ <a href="{{.ScopeHref "all"}}"{{if eq .Scope "all"}} class="on" aria-current="true"{{end}}
+ title="every change in the log">all</a>
+ </div>
+ <form class="revjump" method="get" action="{{.JumpPath}}">
+ <input type="search" name="rev" value="{{.Rev}}" placeholder="@, id, revset…"
+ aria-label="read at another change" autocomplete="off" spellcheck="false">
+ {{if eq .Scope "all"}}<input type="hidden" name="scope" value="all">{{end}}
+ </form>
+ {{if .Rev}}<p class="back"><a href="{{.BackHref}}">← back to <code>{{short .Default}}</code>, where the log points</a></p>{{end}}
+ {{end}}
+</section>
+{{end}}
diff --git a/web/templates/frag-jj.tmpl b/web/templates/frag-jj.tmpl
new file mode 100644
index 0000000..61040dc
--- /dev/null
+++ b/web/templates/frag-jj.tmpl
@@ -0,0 +1,33 @@
+{{/* jj query results. Live-only, so routes are absolute. */}}
+{{define "jjrows"}}
+{{if .Err}}<p class="err">{{.Err}}</p>{{end}}
+{{if .Rows}}
+<div class="jjlog">
+ {{range .Rows}}
+ {{if .Blank}}<div class="jjrow blank"><span class="graph">{{.Graph}}</span></div>
+ {{else if .Op}}
+ <div class="jjrow">
+ <span class="graph">{{.Graph}}</span>
+ <a class="id" href="{{$.Root}}jj?at_op={{.ID}}" title="run the commit log as of this operation">{{.Short}}</a>
+ <span class="when">{{.When}}</span>
+ <span class="who">{{.Author}}</span>
+ <span class="desc">{{.Desc}}</span>
+ <span class="flags">{{.End}}</span>
+ </div>
+ {{else}}
+ <div class="jjrow">
+ <span class="graph">{{.Graph}}</span>
+ <a class="id" href="{{$.Root}}change/{{.ID}}" title="{{.ID}}">{{.Short}}</a>
+ <code class="commit">{{.CommitShort}}</code>
+ <span class="when">{{.When}}</span>
+ <span class="who">{{.Author}}</span>
+ {{if .Refs}}<span class="refs">{{.Refs}}</span>{{end}}
+ {{if .Flags}}<span class="flags">{{.Flags}}</span>{{end}}
+ <a class="desc" href="{{$.Root}}files?rev={{.ID}}" title="browse the tree at this change">{{if .Desc}}{{.Desc}}{{else}}(no description){{end}}</a>
+ </div>
+ {{end}}
+ {{end}}
+</div>
+{{else if not .Err}}<p class="meta hint">no revisions matched</p>{{end}}
+{{if .AtOp}}<p class="meta">as of operation <code>{{short .AtOp}}</code> · <a href="{{.Root}}jj?kind=op">back to the op log</a></p>{{end}}
+{{end}}
diff --git a/web/templates/frag-note.tmpl b/web/templates/frag-note.tmpl
new file mode 100644
index 0000000..3b04e11
--- /dev/null
+++ b/web/templates/frag-note.tmpl
@@ -0,0 +1,105 @@
+{{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-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>
+ <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}}
+ {{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>
+ {{else}}<code class="atchg{{if $away}} away{{end}}">{{short $n.Change}}</code>{{end}}
+ {{end}}
+ </footer>
+</article>
+{{end}}
+
+{{define "notelist"}}{{range .Notes}}{{template "note" (dict "N" . "P" $)}}{{end}}{{if not .Notes}}<p class="meta hint empty">no notes match these filters</p>{{end}}{{end}}
+
+{{/* htmx responses: the list plus an out-of-band count update */}}
+{{define "notelist-oob"}}<span id="notecount" hx-swap-oob="true">{{len .Notes}}</span>{{template "notelist" .}}{{end}}
+
+{{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"
+ hx-on::after-request="if(event.detail.successful){this.reset();{{if eq .Kind "notes"}}htmx.trigger('#notefilters','change');{{end}}}"
+ method="post" action="{{.Href "notes"}}">
+ <input type="hidden" name="rev" value="{{.Rev}}">
+ <input type="hidden" name="change" value="{{.ChangeID}}">
+ <input type="hidden" name="scope" value="{{.Scope}}">
+ <input type="hidden" name="back" value="{{if .File}}{{.Href "code" .File.Path}}{{else}}{{.Href "notes"}}{{end}}">
+ <div class="row">
+ {{if .File}}
+ <input type="hidden" name="file" value="{{.File.Path}}">
+ <span class="meta on">on <code>{{base .File.Path}}</code></span>
+ {{else}}
+ <input type="search" name="file" placeholder="file (optional)" list="filelist" autocomplete="off">
+ {{end}}
+ <input type="number" id="nstart" name="start" min="1" placeholder="from" aria-label="first line">
+ <input type="number" id="nend" name="end" min="1" placeholder="to" aria-label="last line">
+ <input type="text" name="kind" placeholder="kind" aria-label="kind" list="kindlist" size="6">
+ </div>
+ <textarea name="text" rows="3" placeholder="comment… (⌘/ctrl+enter to post)" required></textarea>
+ <div class="row end">
+ <span class="meta">as <b>{{.User}}</b> at <code title="{{.ChangeID}}">{{short .ChangeID}}</code> → the log</span>
+ <button type="submit">add note</button>
+ </div>
+</form>
+<datalist id="kindlist">{{range .Facets.Kinds}}<option value="{{.}}">{{end}}</datalist>
+{{end}}
diff --git a/web/templates/layout.tmpl b/web/templates/layout.tmpl
new file mode 100644
index 0000000..969cae6
--- /dev/null
+++ b/web/templates/layout.tmpl
@@ -0,0 +1,94 @@
+<!doctype html>
+<html lang="en"{{if .Live}} data-live="1"{{end}}>
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>{{.Title}}</title>
+<link rel="icon" type="image/svg+xml" href="{{.Href "asset" "bemtevi.svg"}}">
+<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("notevi-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("notevi-mode") || "trace";
+ d.dataset.files = localStorage.getItem("notevi-files-hidden") === "1" ? "hidden" : "shown";}
+</script>
+{{if .Live}}<script src="{{.Href "asset" "htmx.min.js"}}"></script>{{end}}
+<script src="{{.Href "asset" "app.js"}}" defer></script>
+</head>
+<body{{if eq .Kind "file"}} class="with-aside"{{end}} data-kind="{{.Kind}}">
+
+<nav aria-label="repository files" id="nav" data-root="{{.Root}}" data-current="{{.Current}}" data-tree="{{.Href "tree"}}">
+ <header>
+ <a class="brand" href="{{.Href ""}}"><img class="mark" src="{{.Href "asset" "bemtevi.svg"}}" alt="" width="20" height="20">{{.Site.Title}}</a>
+ <div class="modes" role="group" aria-label="focus">
+ <button type="button" class="mode" data-mode="trace" title="reading coverage (t)">trace</button>
+ <button type="button" class="mode" data-mode="notes" title="notes and comments (t)">notes</button>
+ </div>
+ </header>
+ {{template "changebar" .}}
+ {{if .Live}}
+ <div id="activity" class="activity" hx-get="{{.Href "activity"}}?compact=1" hx-trigger="load, every 3s" hx-swap="innerHTML"></div>
+ {{end}}
+ <button type="button" id="filetoggle" aria-controls="filelist" aria-expanded="true">hide files</button>
+ <div id="filelist">
+ <input id="filefind" class="find" type="search" placeholder="find file…" aria-label="find file" autocomplete="off" spellcheck="false">
+ <div id="tree"><p class="meta hint">loading tree…</p></div>
+ </div>
+ <footer>
+ <span>
+ {{if and .Live (ne .Root "/")}}<a href="/">projects</a> · {{end}}
+ <a href="{{.Href "notes"}}">notes ({{len .Site.Notes}})</a> ·
+ <a href="{{.Href "files"}}">files</a>{{if .Live}} ·
+ <a href="{{.Href "jj"}}">jj</a>{{end}}
+ </span>
+ <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>
+
+<main>
+{{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" .}}
+{{else if eq .Kind "session"}}{{template "page-session" .}}
+{{else}}{{template "page-index" .}}{{end}}
+</main>
+
+{{if eq .Kind "file"}}
+<aside aria-label="notes" class="notes-only">
+ <header class="asidehead">
+ <b>Notes (<span id="notecount">{{len .File.Notes}}</span>)</b>
+ {{if .Live}}<span class="meta">click a line to attach</span>{{end}}
+ </header>
+ {{if .Live}}{{template "noteform" .}}{{end}}
+ <div id="notelist">{{range .File.Notes}}{{template "note" (dict "N" . "P" $)}}{{end}}</div>
+ {{if not .File.Notes}}<p class="meta hint">no notes on this file yet</p>{{end}}
+</aside>
+{{end}}
+
+</body>
+</html>
diff --git a/web/templates/page-change.tmpl b/web/templates/page-change.tmpl
new file mode 100644
index 0000000..8641da7
--- /dev/null
+++ b/web/templates/page-change.tmpl
@@ -0,0 +1,38 @@
+{{define "page-change"}}
+{{$c := .Change}}
+{{$p := .}}
+<header class="filehead">
+ <h1>{{$c.Row.Short}} <span class="meta">{{$c.Row.Flags}}</span></h1>
+ <p class="meta">
+ commit <code>{{$c.Row.CommitShort}}</code> · {{$c.Row.Author}} · {{$c.Row.When}}
+ {{if $c.Row.Refs}} · <span class="refs">{{$c.Row.Refs}}</span>{{end}} ·
+ <a href="{{.Href "files" }}">browse the tree here</a>
+ </p>
+</header>
+
+<section>
+ {{if $c.Desc}}<pre class="desc">{{$c.Desc}}</pre>{{else}}<p class="meta hint">(no description set)</p>{{end}}
+
+ {{if $c.Parents}}
+ <p class="meta">parents:
+ {{range $c.Parents}}<a href="{{$p.Href "change" .ID}}"><code>{{.Short}}</code></a> {{.Desc}} {{end}}
+ </p>
+ {{end}}
+
+ <h2>Changed files <span class="meta">{{$c.Stat}}</span></h2>
+ {{if $c.Files}}
+ <table class="changed">
+ <tbody>
+ {{range $c.Files}}
+ <tr><td class="st st-{{.Status}}">{{.Status}}</td>
+ <td><a href="{{$p.Href "code" .Path}}">{{.Path}}</a></td></tr>
+ {{end}}
+ </tbody>
+ </table>
+ {{else}}<p class="meta hint">no file changes</p>{{end}}
+
+ <h2>Notes recorded at this change <span class="meta">{{len .Notes}}</span></h2>
+ <div class="notecols">{{range .Notes}}{{template "note" (dict "N" . "P" $)}}{{end}}</div>
+ {{if not .Notes}}<p class="meta hint">nothing in the log was written while this change was checked out</p>{{end}}
+</section>
+{{end}}
diff --git a/web/templates/page-file.tmpl b/web/templates/page-file.tmpl
new file mode 100644
index 0000000..39a8c44
--- /dev/null
+++ b/web/templates/page-file.tmpl
@@ -0,0 +1,20 @@
+{{define "page-file"}}
+<header class="filehead">
+ <h1>{{.File.Path}}</h1>
+ <p class="meta">
+ {{if .File.Traced}}
+ {{range .Site.Agents}}{{if band $.File.Mask .Bit}}<span class="chip am{{.Bit}}">{{.Short}}</span> {{end}}{{end}}
+ <span class="trace-only"><data value="{{.File.Pct}}">{{.File.Pct}}% read</data> ·
+ {{.File.Reads}} reads · {{.File.Greps}} greps · </span>
+ <span class="notes-only">{{len .File.Notes}} note{{if ne (len .File.Notes) 1}}s{{end}} · </span>
+ {{else}}not traced · {{end}}change
+ {{if .Live}}<a href="{{.Href "change" .File.Change}}"><code>{{short .File.Change}}</code></a>{{else}}<code>{{short .File.Change}}</code>{{end}}
+ </p>
+ {{if .File.Patterns}}<p class="meta greps trace-only">grepped: {{range .File.Patterns}}<code>{{.}}</code> {{end}}</p>{{end}}
+</header>
+{{if .File.Stub}}
+<p class="meta stub">{{.File.Stub}}</p>
+{{else}}
+<div class="codewrap">{{.File.Body}}</div>
+{{end}}
+{{end}}
diff --git a/web/templates/page-files.tmpl b/web/templates/page-files.tmpl
new file mode 100644
index 0000000..3f158fe
--- /dev/null
+++ b/web/templates/page-files.tmpl
@@ -0,0 +1,13 @@
+{{define "page-files"}}
+<header class="filehead">
+ <h1>Files</h1>
+ <p class="meta">
+ everything at change <code title="{{.ChangeID}}">{{short .ChangeID}}</code> ·
+ <span class="trace-only">traced files carry a coverage bar{{if eq .Scope "change"}}, from reads at this change{{end}}</span>
+ <span class="notes-only">◆ marks files with notes</span>
+ </p>
+</header>
+<section class="filetree">
+{{.Tree}}
+</section>
+{{end}}
diff --git a/web/templates/page-index.tmpl b/web/templates/page-index.tmpl
new file mode 100644
index 0000000..93cad6b
--- /dev/null
+++ b/web/templates/page-index.tmpl
@@ -0,0 +1,56 @@
+{{define "page-index"}}
+<header class="filehead">
+ <h1>{{.Site.Title}}</h1>
+ <p class="meta">
+ {{len .Files}} files at <code title="{{.ChangeID}}">{{short .ChangeID}}</code> ·
+ {{len .Site.Order}} traced, {{len .Site.Notes}} notes
+ {{if or (eq .Scope "all") .ScopeEmpty}}<span class="away">across every change</span>{{else}}here{{end}} ·
+ theme {{.Site.Theme.Name}}
+ </p>
+</header>
+
+{{if .Live}}
+<section>
+ <h2>Live <span class="meta">— agents reading this codebase</span></h2>
+ <div id="activity-full" hx-get="{{.Href "activity"}}" hx-trigger="load, every 3s" hx-swap="innerHTML">
+ <p class="meta hint">…</p>
+ </div>
+</section>
+{{end}}
+
+<section class="trace-only">
+ <h2>Agents</h2>
+ <table>
+ <thead><tr><th>agent</th><th>model</th><th>effort</th><th>session</th><th>reads</th><th>greps</th><th>notes</th></tr></thead>
+ <tbody>
+ {{range .Site.Agents}}
+ <tr><td><span class="chip am{{.Bit}}">{{.Short}}</span></td>
+ <td>{{.Model}}</td><td>{{.Reason}}</td>
+ <td>{{if and $.Live .Session}}<a href="{{$.Href "session" .Session}}" title="{{.Session}}"><code>{{short .Session}}</code></a>
+ {{else}}<code title="{{.Session}}">{{short .Session}}</code>{{end}}</td>
+ <td>{{.Reads}}</td><td>{{.Greps}}</td><td>{{.Notes}}</td></tr>
+ {{end}}
+ </tbody>
+ </table>
+</section>
+
+<section class="trace-only">
+ <h2>Coverage</h2>
+ <table>
+ <thead><tr><th>file</th><th>read</th><th>lines</th><th>notes</th></tr></thead>
+ <tbody>
+ {{range .Site.Order}}{{with index $.Site.Files .}}
+ <tr><td><a href="{{$.Href "code" .Path}}">{{.Path}}</a></td>
+ <td><data class="bar am{{.Mask}}" value="{{.Pct}}" style="--pct:{{.Pct}}%">{{.Pct}}%</data></td>
+ <td>{{.Covered}}/{{.Total}}</td><td>{{len .Notes}}</td></tr>
+ {{end}}{{end}}
+ </tbody>
+ </table>
+</section>
+
+<section class="notes-only">
+ <h2>Latest notes <span class="meta">— <a href="{{.Href "notes"}}">all {{len .Site.Notes}}, with filters</a></span></h2>
+ <div class="notecols">{{range .Notes}}{{template "note" (dict "N" . "P" $)}}{{end}}</div>
+ {{if not .Notes}}<p class="meta hint">no notes in the log yet</p>{{end}}
+</section>
+{{end}}
diff --git a/web/templates/page-jj.tmpl b/web/templates/page-jj.tmpl
new file mode 100644
index 0000000..24df596
--- /dev/null
+++ b/web/templates/page-jj.tmpl
@@ -0,0 +1,25 @@
+{{define "page-jj"}}
+<header class="filehead">
+ <h1>jj</h1>
+ <p class="meta">
+ read-only queries against the repo — <code>--ignore-working-copy</code>, inline templates, nothing written to disk
+ </p>
+</header>
+
+<section class="jjpage">
+ <form class="jjform" method="get" action="{{.Href "jj"}}"
+ hx-get="{{.Href "jj-log"}}" hx-target="#jjout" hx-swap="innerHTML" hx-indicator="#jjbusy">
+ <input type="hidden" name="kind" value="log">
+ <input type="search" name="revset" value="{{.JJ.Revset}}" placeholder="revset — e.g. @ | ancestors(trunk(), 10) | description(glob:'*fix*')" autocomplete="off" spellcheck="false">
+ <input type="number" name="limit" value="{{.JJ.Limit}}" min="1" max="1000" aria-label="limit" title="limit">
+ <button type="submit">jj log</button>
+ <button type="button" hx-get="{{.Href "jj-op"}}" hx-target="#jjout" hx-include="closest form" hx-indicator="#jjbusy" title="operation log">op log</button>
+ <span id="jjbusy" class="busy">·</span>
+ </form>
+ <p class="meta hint">
+ change ids open the change · descriptions browse the tree at that change ·
+ op ids re-run the query <code>--at-operation</code>, which is how you see the repo as it was
+ </p>
+ <div id="jjout">{{template "jjrows" .JJ}}</div>
+</section>
+{{end}}
diff --git a/web/templates/page-note.tmpl b/web/templates/page-note.tmpl
new file mode 100644
index 0000000..8cd0e64
--- /dev/null
+++ b/web/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/web/templates/page-notes.tmpl b/web/templates/page-notes.tmpl
new file mode 100644
index 0000000..ebd5865
--- /dev/null
+++ b/web/templates/page-notes.tmpl
@@ -0,0 +1,52 @@
+{{define "facet"}}<select name="{{.Name}}" aria-label="{{.Label}}">
+ <option value="">{{.Label}}</option>
+ {{range .Values}}<option value="{{.}}"{{if eq . $.Sel}} selected{{end}}>{{if $.Short}}{{short .}}…{{else}}{{.}}{{end}}</option>
+ {{end}}</select>{{end}}
+
+{{define "page-notes"}}
+<header class="filehead">
+ <h1>Notes</h1>
+ <p class="meta">
+ {{if eq .Scope "all"}}
+ all {{.Site.NotesTotal}} in the log, every change
+ {{else if .ScopeEmpty}}
+ all {{.Site.NotesTotal}} in the log — nothing was recorded at <code>{{short .ChangeID}}</code>
+ {{else}}
+ {{len .Site.Notes}} written at <code title="{{.ChangeID}}">{{short .ChangeID}}</code>{{if .Elsewhere}} ·
+ {{if .Live}}<a href="{{.ScopeHref "all"}}">{{.Elsewhere}} more at other changes</a>
+ {{else}}{{.Elsewhere}} more at other changes are not in this export{{end}}{{end}}
+ {{end}}
+ {{if .Live}} · anything you add here is appended to the private log sidecar at this change — the project working copy is never touched{{end}}
+ </p>
+</header>
+
+<section class="notespage">
+ {{if .Live}}
+ <details class="addnote">
+ <summary>add a note</summary>
+ {{template "noteform" .}}
+ </details>
+ {{end}}
+
+ <form class="filters" id="notefilters" method="get" action="{{.Href "notes"}}"
+ {{if .Live}}hx-get="{{.Href "notes-list"}}" hx-target="#notelist" hx-swap="innerHTML"
+ hx-trigger="input delay:250ms, change, search" hx-indicator="#notesbusy"{{end}}>
+ <input type="search" name="text" value="{{.Filter.Text}}" placeholder="search note text…" autocomplete="off">
+ <input type="search" name="file" value="{{.Filter.File}}" placeholder="file path…" list="filelist" autocomplete="off">
+ {{template "facet" (dict "Name" "agent" "Label" "any agent" "Values" .Facets.Agents "Sel" .Filter.Agent)}}
+ {{template "facet" (dict "Name" "model" "Label" "any model" "Values" .Facets.Models "Sel" .Filter.Model)}}
+ {{template "facet" (dict "Name" "session" "Label" "any session" "Values" .Facets.Sessions "Sel" .Filter.Session "Short" true)}}
+ {{template "facet" (dict "Name" "kind" "Label" "any kind" "Values" .Facets.Kinds "Sel" .Filter.Kind)}}
+ <span class="dates">
+ <label class="datef">from <input type="date" name="from" value="{{.Filter.From}}"></label>
+ <label class="datef">to <input type="date" name="to" value="{{.Filter.To}}"></label>
+ </span>
+ <button type="submit" class="nohx">filter</button>
+ <button type="button" id="clearfilters">clear</button>
+ </form>
+ <datalist id="filelist">{{range .Facets.Files}}<option value="{{.}}">{{end}}</datalist>
+
+ <p class="meta count"><span id="notesbusy" class="busy">·</span><span id="notecount">{{len .Notes}}</span> notes shown</p>
+ <div id="notelist">{{template "notelist" .}}</div>
+</section>
+{{end}}
diff --git a/web/templates/page-session.tmpl b/web/templates/page-session.tmpl
new file mode 100644
index 0000000..1155b67
--- /dev/null
+++ b/web/templates/page-session.tmpl
@@ -0,0 +1,70 @@
+{{/* One session's conversation, with its notevi log woven in by time. */}}
+{{define "page-session"}}
+{{$c := .Conv}}
+<header class="filehead">
+ <h1>
+ {{if $c.Agent}}<span class="chip am{{$c.Bit}}">{{$c.Agent}}</span> {{end}}
+ <code title="{{$c.Session}}">{{short $c.Session}}</code>
+ </h1>
+ <p class="meta">
+ {{if $c.Model}}{{$c.Model}} · {{end}}{{$c.Reads}} reads · {{$c.Greps}} greps · {{$c.Notes}} notes
+ {{if $c.Dir}} · <span title="{{$c.Dir}}">{{base $c.Dir}}</span>{{end}}
+ {{if $c.Harness}} · {{$c.Harness}}{{end}}
+ </p>
+ {{if $c.Path}}
+ <p class="meta"><code>{{$c.Path}}</code></p>
+ {{else}}
+ <p class="meta hint">no transcript found for this session — searched
+ {{if $c.Roots}}{{range $i, $r := $c.Roots}}{{if $i}}, {{end}}<code>{{$r}}</code>{{end}}{{else}}nowhere: <code>-transcripts</code> is empty{{end}} ·
+ showing the notevi log alone</p>
+ {{end}}
+ {{if $c.Err}}<p class="err">{{$c.Err}}</p>{{end}}
+</header>
+
+<section class="convpage">
+ {{if $c.Windowed}}
+ <p class="meta">
+ moments {{$c.From}}–{{$c.To}} of {{$c.Total}}{{if $c.Anchor}}, around the note{{end}} ·
+ {{if $c.Incomplete}}the rest is beyond what one page renders{{else}}<a href="{{$c.AllHref}}">show the whole conversation</a>{{end}}
+ </p>
+ {{end}}
+
+ <div class="conv" data-anchor="{{$c.Anchor}}">
+ {{range $c.Turns}}
+ {{if .Note}}
+ <div class="turn t-note{{if .Here}} here{{end}}">
+ <span class="role">note</span><time>{{.Clock}}</time>
+ {{if .Note.File}}
+ <div class="notewrap" data-href="{{$.NoteHref .Note}}" tabindex="0" role="link"
+ title="open this note on the code it is about">{{template "note" (dict "N" .Note "P" $)}}</div>
+ {{else}}
+ <div class="notewrap">{{template "note" (dict "N" .Note "P" $)}}</div>
+ {{end}}
+ </div>
+ {{else if eq .Role "notevi"}}
+ <div class="turn t-notevi">
+ <span class="role">notevi</span><time>{{.Clock}}</time>
+ {{if .File}}<a class="what" href="{{$.Href "code" .File}}">{{.Text}}</a>
+ {{else}}<span class="what">{{.Text}}</span>{{end}}
+ </div>
+ {{else if eq .Role "tool"}}
+ <div class="turn t-tool">
+ <span class="role">{{.Tool}}</span><time>{{.Clock}}</time>
+ <code class="what">{{.Detail}}</code>
+ </div>
+ {{else if eq .Role "result"}}
+ <div class="turn t-result{{if eq .Kind "error"}} bad{{end}}">
+ <span class="role">→{{if .Tool}} {{.Tool}}{{end}}</span><time></time>
+ <code class="what">{{.Detail}}</code>
+ </div>
+ {{else}}
+ <div class="turn t-say t-{{.Role}}">
+ <span class="role">{{.Role}}</span><time>{{.Clock}}</time>
+ <p>{{.Text}}</p>
+ </div>
+ {{end}}
+ {{end}}
+ {{if not $c.Turns}}<p class="meta hint">nothing recorded for this session</p>{{end}}
+ </div>
+</section>
+{{end}}
diff --git a/web/templates/projects.tmpl b/web/templates/projects.tmpl
new file mode 100644
index 0000000..ce75454
--- /dev/null
+++ b/web/templates/projects.tmpl
@@ -0,0 +1,113 @@
+{{define "projects"}}<!doctype html>
+<html lang="en" data-live="1">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>{{.Title}}</title>
+<link rel="icon" type="image/svg+xml" href="/bemtevi.svg">
+<link rel="stylesheet" href="/style.css">
+<script>
+// Match project pages: wear the saved theme before first paint, and load the
+// installed-theme stylesheet now only when that saved choice requires it.
+{const d = document.documentElement, t = localStorage.getItem("notevi-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 = "/themes.css";
+ document.head.append(l);
+ }
+ d.dataset.mode = localStorage.getItem("notevi-mode") || "trace";}
+</script>
+<script src="/app.js" defer></script>
+</head>
+<body class="projects">
+<nav aria-label="projects" id="nav">
+ <header><a class="brand" href="/"><img class="mark" src="/bemtevi.svg" alt="" width="20" height="20">{{.Title}}</a></header>
+ <p class="meta">one process · every local notevi sidecar</p>
+ <input id="projectfind" class="find" type="search" placeholder="find project…" aria-label="find project" autocomplete="off" spellcheck="false">
+ <form method="post" action="/projects/refresh" class="project-refresh">
+ <button type="submit">refresh repositories</button>
+ </form>
+ <p class="meta">scans <code>{{.ScanRoot}}</code></p>
+ <p class="meta">last refresh {{.Scanned}}</p>
+ <footer>
+ <span>selection and scan results stay in memory</span>
+ <div class="themepick" id="themepick"
+ data-js="/themes.js" data-css="/themes.css"
+ data-dark="{{.Theme.Name}}" data-light="{{.Light.Name}}"
+ data-darkid="{{.Theme.ID}}" data-lightid="{{.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>
+
+<main class="projects-main">
+ <header class="filehead">
+ <h1>Projects</h1>
+ <p class="meta">Choose a notevi log, start one in a jj repository, or colocate jj with an existing Git repository.</p>
+ </header>
+ {{if .Message}}<p class="ok project-message">{{.Message}}</p>{{end}}
+ {{if .Err}}<p class="err project-message">{{.Err}}</p>{{end}}
+
+ <section>
+ <h2>Logs <span class="meta">— {{len .Active}} repositories with a private sidecar</span></h2>
+ <div class="project-grid">
+ {{range .Active}}
+ <article class="project-card" data-project="{{.Name}} {{.Relative}}">
+ <header><h3><a href="/p/{{.ID}}/">{{.Name}}</a></h3><span class="project-state active">log</span></header>
+ <p class="project-path" title="{{.Path}}"><code>{{.Relative}}</code></p>
+ <p class="meta">{{.Entries}} entries · {{.Notes}} notes · {{.Agents}} agents · {{.Updated}}</p>
+ {{if gt (len .Workspaces) 1}}<p class="meta">{{len .Workspaces}} jj workspaces share this log</p>{{end}}
+ {{if .Err}}<p class="err">{{.Err}}</p>{{end}}
+ <a class="project-action" href="/p/{{.ID}}/">open project</a>
+ </article>
+ {{end}}
+ {{if not .Active}}<p class="meta hint">no notevi sidecars found yet</p>{{end}}
+ </div>
+ </section>
+
+ <section>
+ <h2>Ready to start <span class="meta">— {{len .Ready}} jj repositories without a notevi sidecar</span></h2>
+ <div class="project-grid">
+ {{range .Ready}}
+ <article class="project-card" data-project="{{.Name}} {{.Relative}}">
+ <header><h3>{{.Name}}</h3><span class="project-state">jj</span></header>
+ <p class="project-path" title="{{.Path}}"><code>{{.Relative}}</code></p>
+ {{if gt (len .Workspaces) 1}}<p class="meta">{{len .Workspaces}} workspaces</p>{{end}}
+ {{if .Err}}<p class="err">{{.Err}}</p>{{end}}
+ <form method="post" action="/projects/{{.ID}}/start">
+ <button type="submit">start private sidecar</button>
+ </form>
+ </article>
+ {{end}}
+ {{if not .Ready}}<p class="meta hint">every detected jj repository already has a sidecar</p>{{end}}
+ </div>
+ </section>
+
+ <section>
+ <h2>Git repositories <span class="meta">— {{len .Git}} without jj metadata</span></h2>
+ <p class="meta">Initializing adds colocated <code>.jj</code> metadata; it does not create a sidecar until you choose “start private sidecar”.</p>
+ <div class="project-grid">
+ {{range .Git}}
+ <article class="project-card" data-project="{{.Name}} {{.Relative}}">
+ <header><h3>{{.Name}}</h3><span class="project-state git">git</span></header>
+ <p class="project-path" title="{{.Path}}"><code>{{.Relative}}</code></p>
+ <form method="post" action="/git/{{.ID}}/init">
+ <button type="submit">initialize colocated jj</button>
+ </form>
+ </article>
+ {{end}}
+ {{if not .Git}}<p class="meta hint">no Git-only repositories detected</p>{{end}}
+ </div>
+ </section>
+</main>
+</body>
+</html>{{end}}