feat(web): selectable folders with listing + per-folder change feed

Folders in the file tree are now selectable: the row opens the folder
(chevron still folds) and the main pane lists its contents, with clickable
breadcrumbs, folder URLs/deep links, palette entries, and uploads landing
inside the selected folder. In hub mode the listing includes a Recent
changes feed for the subtree; the history API now classifies puts as
add|edit (replayed over all ops before filtering) so entries are badged
added/edited/deleted, and op notes render under entries with URLs
linkified.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P5cxPQdSGJnjXCYY9GeWXt
This commit is contained in:
Snow Lee
2026-07-11 10:56:39 -07:00
co-authored by Claude Fable 5
parent ba595b9d45
commit 06bedabfd6
5 changed files with 268 additions and 42 deletions
+20 -2
View File
@@ -20,7 +20,7 @@ import (
// HistoryEntry is one change as the history API reports it.
type HistoryEntry struct {
Time string `json:"time"`
Kind string `json:"kind"` // put | delete
Kind string `json:"kind"` // add | edit | delete
Path string `json:"path"`
Size int64 `json:"size,omitempty"`
Blob string `json:"blob,omitempty"` // sha256; fetch via the blob endpoint
@@ -59,6 +59,24 @@ func (s *Server) handleHistory(v *volume, w http.ResponseWriter, r *http.Request
return
}
journal.Sort(all)
// A put is an "add" when the path didn't exist just before it (first
// version, or first after a delete), an "edit" otherwise. Existence is
// replayed over ALL ops in journal order, before any path/prefix filter,
// so a filtered view classifies the same as the full feed.
kinds := make([]string, len(all))
exists := make(map[string]bool, len(all))
for i, op := range all {
switch {
case op.Kind == journal.KindDelete:
kinds[i] = "delete"
exists[op.Path] = false
case exists[op.Path]:
kinds[i] = "edit"
default:
kinds[i] = "add"
exists[op.Path] = true
}
}
entries := make([]HistoryEntry, 0, n)
for i := len(all) - 1; i >= 0 && len(entries) < n; i-- { // newest first
op := all[i]
@@ -73,7 +91,7 @@ func (s *Server) handleHistory(v *volume, w http.ResponseWriter, r *http.Request
dev = DeviceInfo{ID: op.Device, Name: op.DeviceName}
}
entries = append(entries, HistoryEntry{
Time: op.Time.UTC().Format("2006-01-02T15:04:05Z"), Kind: op.Kind,
Time: op.Time.UTC().Format("2006-01-02T15:04:05Z"), Kind: kinds[i],
Path: op.Path, Size: op.Size, Blob: op.Blob,
User: op.User, UserName: op.UserName, Author: op.Author,
Device: dev, Note: op.Note,
+8
View File
@@ -72,6 +72,10 @@ func TestHistoryAPI(t *testing.T) {
if newest.Size != int64(len("v2 longer")) || oldest.Size != int64(len("v1")) {
t.Fatalf("order wrong: %+v", out.Entries)
}
// puts are classified: first version = add, later versions = edit
if oldest.Kind != "add" || newest.Kind != "edit" {
t.Fatalf("kinds = %q, %q; want add, edit", oldest.Kind, newest.Kind)
}
if newest.User != "alice@x.io" || newest.UserName != "Alice" {
t.Fatalf("user = %+v", newest)
}
@@ -94,6 +98,10 @@ func TestHistoryAPI(t *testing.T) {
if out.Entries[0].Kind != "delete" || out.Entries[0].Path != "notes/other.md" {
t.Fatalf("newest notes/ entry = %+v, want the delete", out.Entries[0])
}
// the put that created other.md is an add, even in the filtered view
if out.Entries[1].Kind != "add" || out.Entries[1].Path != "notes/other.md" {
t.Fatalf("entry before the delete = %+v, want other.md's add", out.Entries[1])
}
// a device the registry never saw falls back to the op's own info
if out.Entries[0].Device.Name != "dev2" {
t.Fatalf("unknown device fallback = %+v", out.Entries[0].Device)
+205 -39
View File
@@ -3,6 +3,7 @@
const $ = (id) => document.getElementById(id);
let flatFiles = []; // [{path, name}] for wikilink resolution
let dirIndex = new Map(); // dir path → tree node, for folder listings
let currentPath = null;
let expanded = new Set(); // dir paths that are open (folders start closed)
let treeFirstLoad = true; // apply the "lone root folder opens" rule once per project
@@ -92,7 +93,7 @@ async function boot() {
initUpload();
await refreshTree();
const { path } = parseRoute();
if (path) openFile(path);
if (path) openPath(path);
}
setInterval(refreshTree, 15000); // pick up synced changes
}
@@ -161,7 +162,7 @@ function selectProject(p, path) {
initUpload();
initHistory();
updateShareButton();
refreshTree().then(() => { if (path) openFile(path); });
refreshTree().then(() => { if (path) openPath(path); });
if (!path) pushURL("/" + p.id);
}
@@ -676,6 +677,7 @@ async function refreshTree() {
root = await getJSON(apiBase + "tree");
} catch { return; } // keep the last good tree
flatFiles = [];
dirIndex = new Map();
const kids = root.children || [];
// First render of a project's tree: every folder starts closed, except a
// lone root folder — opening it spares the user a single shut folder.
@@ -688,6 +690,10 @@ async function refreshTree() {
nav.innerHTML = "";
nav.appendChild(renderChildren(kids));
markActive();
// A folder listing shows live tree data — keep it in step with the tree.
if (currentPath && dirIndex.has(currentPath) && $("content").querySelector(".dirlist")) {
renderFolderListing(currentPath);
}
}
function renderChildren(children) {
@@ -719,6 +725,7 @@ function renderNode(n) {
row.onkeydown = (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); row.click(); } };
li.appendChild(row);
if (n.dir) {
dirIndex.set(n.path, n);
if (serverConfig.mode === "hub") {
const hist = document.createElement("span");
hist.className = "dir-history";
@@ -731,12 +738,21 @@ function renderNode(n) {
const open = expanded.has(n.path);
if (!open) li.classList.add("collapsed");
row.setAttribute("aria-expanded", String(open));
row.onclick = () => {
const toggle = () => {
li.classList.toggle("collapsed");
const isCollapsed = li.classList.contains("collapsed");
isCollapsed ? expanded.delete(n.path) : expanded.add(n.path);
row.setAttribute("aria-expanded", String(!isCollapsed));
};
// The chevron only folds; the row selects the folder (opens it in the
// tree and lists its contents in the main pane). Clicking the folder
// whose listing is already showing folds/unfolds it, like a plain tree;
// from any other view (file, history) it brings the listing back.
chev.onclick = (e) => { e.stopPropagation(); toggle(); };
row.onclick = () => {
if (currentPath === n.path && $("content").querySelector(".dirlist")) { toggle(); return; }
openFolder(n.path);
};
} else {
flatFiles.push({ path: n.path, name: n.name });
row.onclick = () => openFile(n.path);
@@ -780,6 +796,122 @@ function revealInTree(p) {
if (row) row.scrollIntoView({ block: "center" });
}
/* ---- breadcrumb ----
Every ancestor segment is a link to that folder's listing; the last
segment is the current page. */
function setCrumb(p) {
const c = $("crumb");
c.innerHTML = "";
const parts = p.split("/");
let acc = "";
parts.forEach((seg, i) => {
acc = acc ? acc + "/" + seg : seg;
if (i) el(c, "span", "crumb-sep", "/");
const last = i === parts.length - 1;
const s = el(c, "span", last ? null : "crumb-seg", seg);
if (!last) {
const target = acc;
s.title = target;
s.onclick = () => { if (dirIndex.has(target)) openFolder(target); };
}
});
}
/* ---- folder pane ---- */
/* Open a path of either kind — a route or link doesn't know which it is
until the tree has loaded. */
function openPath(p) {
if (dirIndex.has(p)) openFolder(p);
else openFile(p);
}
/* Selecting a folder: unfold it in the tree, highlight it, and list what's
inside it in the main pane. */
function openFolder(p) {
if (!dirIndex.has(p)) return;
currentPath = p;
syncURL(p);
expandTo(p);
expanded.add(p); // the selected folder itself opens, not just its ancestors
applyTreeExpansion();
markActive();
const row = document.querySelector(`#tree .row[data-path="${CSS.escape(p)}"]`);
if (row) row.scrollIntoView({ block: "nearest" });
closeSidebarOnMobile();
setCrumb(p);
$("meta").textContent = "";
$("download").hidden = true;
$("more-btn").hidden = !(serverConfig.mode === "hub" && currentProject);
updateShareButton();
renderFolderListing(p);
}
function renderFolderListing(p) {
const node = dirIndex.get(p);
if (!node) return;
const content = $("content");
content.className = "view";
content.innerHTML = "";
const wrap = el(content, "div", "dirlist");
const head = el(wrap, "h1", "dl-title");
const hicon = el(head, "span", "dl-title-icon");
hicon.innerHTML = svgIcon("folder");
el(head, "span", null, node.name);
const kids = (node.children || []).slice()
.sort((a, b) => (b.dir - a.dir) || a.name.localeCompare(b.name));
const dirs = kids.filter((c) => c.dir).length;
const files = kids.length - dirs;
const counts = [];
if (dirs) counts.push(dirs + (dirs === 1 ? " folder" : " folders"));
if (files) counts.push(files + (files === 1 ? " file" : " files"));
el(wrap, "p", "dl-sub", counts.join(" · ") || "Empty folder");
if (!kids.length) {
el(wrap, "div", "dl-empty", "Nothing in this folder yet.");
renderFolderHistory(wrap, p);
return;
}
const list = el(wrap, "div", "dl-items");
for (const c of kids) {
const row = el(list, "div", "dl-row");
row.tabIndex = 0;
row.setAttribute("role", "button");
row.title = c.path;
const icon = el(row, "span", "ticon");
icon.innerHTML = svgIcon(c.dir ? "folder" : "doc");
el(row, "span", "dl-name", c.name);
let meta = "";
if (c.dir) {
const n = (c.children || []).length;
meta = n + (n === 1 ? " item" : " items");
} else {
meta = [c.size ? humanSize(c.size) : "", c.time ? new Date(c.time).toLocaleDateString() : ""]
.filter(Boolean).join(" · ");
}
el(row, "span", "dl-meta", meta);
row.onclick = () => openPath(c.path);
row.onkeydown = (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); row.click(); } };
}
renderFolderHistory(wrap, p);
}
/* The folder's change feed, straight from the journals: files added, edited,
and deleted anywhere under it, newest first. Hub-only — a plain-folder
viewer has no journals to read. */
function renderFolderHistory(wrap, p) {
if (!(serverConfig.mode === "hub" && currentProject)) return;
const sec = el(wrap, "div", "dl-history");
getJSON(apiBase + "history?prefix=" + encodeURIComponent(p + "/") + "&n=20").then((out) => {
const entries = out.entries || [];
if (!entries.length) return sec.remove();
el(sec, "h3", "dl-h3", "Recent changes");
const list = el(sec, "div", "history dl-hlist");
for (const e of entries) list.appendChild(historyEntryRow(e));
const more = el(sec, "button", "ai-btn dl-more", "Full history");
more.onclick = () => showHistory({ prefix: p + "/" });
}).catch(() => sec.remove());
}
/* ---- file pane ---- */
async function openFile(p) {
currentPath = p;
@@ -787,7 +919,7 @@ async function openFile(p) {
markActive();
revealInTree(p);
closeSidebarOnMobile();
$("crumb").textContent = p.split("/").join(" / ");
setCrumb(p);
updateShareButton();
const dl = $("download");
dl.href = apiBase + "download?path=" + encodeURIComponent(p);
@@ -916,7 +1048,8 @@ function join(dir, rel) {
(rendered, sandboxed), no account needed. Always the latest content. */
function updateShareButton() {
const btn = $("share-btn");
btn.hidden = !(serverConfig.mode === "hub" && currentProject && currentPath);
// Shares are per-file; a selected folder has nothing to mint.
btn.hidden = !(serverConfig.mode === "hub" && currentProject && currentPath && !dirIndex.has(currentPath));
btn.onclick = async () => {
try {
const r = await fetch(apiBase + "shares", {
@@ -941,7 +1074,11 @@ function updateShareButton() {
function initHistory() {
const btn = $("history-btn");
btn.hidden = !(serverConfig.mode === "hub" && currentProjectOrNull());
btn.onclick = () => showHistory(currentPath ? { path: currentPath } : { prefix: "" });
btn.onclick = () => {
if (!currentPath) return showHistory({ prefix: "" });
if (dirIndex.has(currentPath)) return showHistory({ prefix: currentPath + "/" });
showHistory({ path: currentPath });
};
}
function currentProjectOrNull() {
@@ -972,39 +1109,63 @@ async function showHistory(q) {
if (!out.entries || out.entries.length === 0) {
wrap.innerHTML = `<div class="empty">No history yet.</div>`;
}
for (const e of out.entries || []) {
const row = document.createElement("div");
row.className = "hentry " + e.kind;
const who = e.user_name ? `${e.user_name} <${e.user}>` : (e.user || e.author || "unknown");
const dev = [e.device.name || e.device.id, e.device.os, e.device.ip].filter(Boolean).join(" · ");
const when = new Date(e.time).toLocaleString();
row.innerHTML =
`<div class="hline"><span class="hkind"></span><span class="hpath"></span><span class="htime"></span></div>` +
`<div class="hmeta"><span class="hwho"></span><span class="hdev"></span><span class="hsize"></span><span class="hact"></span></div>`;
row.querySelector(".hkind").innerHTML = svgIcon(e.kind === "delete" ? "x" : "dot");
row.querySelector(".hpath").textContent = e.path;
row.querySelector(".htime").textContent = when;
row.querySelector(".hwho").textContent = who;
row.querySelector(".hdev").textContent = dev;
row.querySelector(".hsize").textContent = e.size ? humanSize(e.size) : "";
if (e.kind !== "delete" && e.blob) {
const view = document.createElement("a");
view.textContent = "view";
view.href = apiBase + "blob?sha=" + e.blob + "&name=" + encodeURIComponent(e.path);
view.target = "_blank";
const dl = document.createElement("a");
dl.textContent = "download";
dl.href = view.href + "&download=1";
dl.setAttribute("download", e.path.split("/").pop());
row.querySelector(".hact").append(view, " ", dl);
}
const p = e.path;
row.querySelector(".hpath").onclick = () => showHistory({ path: p });
wrap.appendChild(row);
}
for (const e of out.entries || []) wrap.appendChild(historyEntryRow(e));
content.appendChild(wrap);
}
/* One change as a row: what happened (added / edited / deleted), to which
file, by whom, from where — with view/download of that exact version. */
const KIND_ICON = { add: "plus", edit: "edit", delete: "x" };
const KIND_LABEL = { add: "added", edit: "edited", delete: "deleted" };
function historyEntryRow(e) {
const kind = e.kind === "put" ? "edit" : e.kind; // older servers report raw "put" ops
const row = document.createElement("div");
row.className = "hentry " + kind;
const who = e.user_name ? `${e.user_name} <${e.user}>` : (e.user || e.author || "unknown");
const dev = [e.device.name || e.device.id, e.device.os, e.device.ip].filter(Boolean).join(" · ");
const when = new Date(e.time).toLocaleString();
row.innerHTML =
`<div class="hline"><span class="hkind"></span><span class="hpath"></span><span class="htag"></span><span class="htime"></span></div>` +
`<div class="hmeta"><span class="hwho"></span><span class="hdev"></span><span class="hsize"></span><span class="hact"></span></div>`;
row.querySelector(".hkind").innerHTML = svgIcon(KIND_ICON[kind] || "dot");
row.querySelector(".hpath").textContent = e.path;
row.querySelector(".htag").textContent = KIND_LABEL[kind] || kind;
row.querySelector(".htime").textContent = when;
row.querySelector(".hwho").textContent = who;
row.querySelector(".hdev").textContent = dev;
row.querySelector(".hsize").textContent = e.size ? humanSize(e.size) : "";
if (kind !== "delete" && e.blob) {
const view = document.createElement("a");
view.textContent = "view";
view.href = apiBase + "blob?sha=" + e.blob + "&name=" + encodeURIComponent(e.path);
view.target = "_blank";
const dl = document.createElement("a");
dl.textContent = "download";
dl.href = view.href + "&download=1";
dl.setAttribute("download", e.path.split("/").pop());
row.querySelector(".hact").append(view, " ", dl);
}
if (e.note) {
const note = document.createElement("div");
note.className = "hnote";
// Linkify http(s) URLs (e.g. a Claude session link); everything else
// stays plain text — notes are user/agent input, never markup.
for (const tok of e.note.split(/(https?:\/\/\S+)/)) {
if (/^https?:\/\//.test(tok)) {
const a = document.createElement("a");
a.href = tok; a.textContent = tok; a.target = "_blank"; a.rel = "noopener";
note.appendChild(a);
} else if (tok) {
note.append(tok);
}
}
row.appendChild(note);
}
const p = e.path;
row.querySelector(".hpath").onclick = () => showHistory({ path: p });
return row;
}
function humanSize(n) {
if (n < 1024) return n + " B";
const units = ["KB", "MB", "GB", "TB"];
@@ -1029,8 +1190,10 @@ function initUpload() {
const file = input.files[0];
input.value = "";
if (!file) return;
const dir = currentPath && currentPath.includes("/")
? currentPath.slice(0, currentPath.lastIndexOf("/")) : "";
// A selected folder receives the upload; a selected file means "next to it".
const dir = !currentPath ? ""
: dirIndex.has(currentPath) ? currentPath
: currentPath.includes("/") ? currentPath.slice(0, currentPath.lastIndexOf("/")) : "";
const dest = dir ? dir + "/" + file.name : file.name;
const status = $("meta");
try {
@@ -1204,6 +1367,9 @@ function paletteCandidates() {
if (serverConfig.auth && serverConfig.auth.enabled) {
add("power", "Sign out", "action", () => { location.href = "/auth/logout"; });
}
for (const d of dirIndex.keys()) {
add("folder", d, "folder", () => openFolder(d));
}
for (const f of flatFiles) {
add("doc", f.path, "file", () => openFile(f.path));
}
@@ -1343,7 +1509,7 @@ window.addEventListener("popstate", () => {
const proj = projects.find((x) => x.id === project);
if (proj) { selectProject(proj, path || null); return; }
}
if (path && path !== currentPath) openFile(path);
if (path && path !== currentPath) openPath(path);
});
boot();
+1
View File
@@ -33,6 +33,7 @@
<symbol id="i-download" viewBox="0 0 24 24"><path d="M12 4v11m0 0 4-4m-4 4-4-4M5 19h14"/></symbol>
<symbol id="i-upload" viewBox="0 0 24 24"><path d="M12 20V9m0 0 4 4m-4-4-4 4M5 5h14"/></symbol>
<symbol id="i-dot" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4.5" fill="currentColor" stroke="none"/></symbol>
<symbol id="i-edit" viewBox="0 0 24 24"><path d="M4 20l1.2-4.2L16.6 4.4a2 2 0 0 1 2.9 2.9L8.2 18.8z"/></symbol>
<symbol id="i-enter" viewBox="0 0 24 24"><path d="M9 10 4 15l5 5"/><path d="M20 4v7a4 4 0 0 1-4 4H4"/></symbol>
<symbol id="i-menu" viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16"/></symbol>
<symbol id="i-dots" viewBox="0 0 24 24"><circle cx="5" cy="12" r="1.4" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.4" fill="currentColor" stroke="none"/><circle cx="19" cy="12" r="1.4" fill="currentColor" stroke="none"/></symbol>
+34 -1
View File
@@ -174,6 +174,9 @@ button, input, a.btn { font-family: inherit; }
.icon-btn { display: none; width: 34px; height: 34px; border: none; background: transparent; color: var(--text-dim); cursor: pointer; border-radius: 7px; align-items: center; justify-content: center; }
.icon-btn:hover { color: var(--text); background: var(--hover); }
#crumb { font-size: 12.5px; color: var(--text); font-weight: 500; letter-spacing: -.01em; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#crumb .crumb-seg { color: var(--text-dim); cursor: pointer; }
#crumb .crumb-seg:hover { color: var(--accent-bright); }
#crumb .crumb-sep { color: var(--text-ghost); margin: 0 5px; }
#meta { flex: 1; min-width: 0; font-size: 12px; color: var(--text-faint); text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.btn {
@@ -251,6 +254,27 @@ button, input, a.btn { font-family: inherit; }
.tg-desc { font-size: 12px; color: var(--text-faint); margin-top: 3px; line-height: 1.5; }
.admin-item.toggle input { width: 16px; height: 16px; margin-top: 2px; accent-color: var(--accent); flex: none; }
/* ---- folder listing ---- */
.dirlist { max-width: 704px; margin: 0 auto; }
.dl-title { display: flex; align-items: center; gap: 10px; font-size: 21px; font-weight: 640; letter-spacing: -.02em; margin: 0 0 4px; color: #f4f6f9; }
.dl-title-icon { display: flex; color: var(--accent); }
.dl-title-icon .ico { width: 20px; height: 20px; }
.dl-sub { color: var(--text-faint); font-size: 12.5px; margin: 0 0 18px; }
.dl-items { border: 1px solid var(--border); border-radius: var(--r-card); overflow: hidden; background: var(--bg-side); }
.dl-row { display: flex; align-items: center; gap: 11px; padding: 10px 14px; border-bottom: 1px solid var(--border); cursor: pointer; }
.dl-row:last-child { border-bottom: none; }
.dl-row:hover { background: var(--hover); }
.dl-row .ticon { flex: none; display: flex; color: var(--text-ghost); }
.dl-row .ticon .ico { width: 16px; height: 16px; }
.dl-row:hover .ticon { color: var(--text-faint); }
.dl-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13.5px; color: var(--text); }
.dl-meta { flex: none; font-size: 12px; color: var(--text-faint); font-variant-numeric: tabular-nums; }
.dl-empty { padding: 24px 14px; color: var(--text-faint); font-size: 13px; border: 1px dashed var(--border); border-radius: var(--r-card); text-align: center; }
.dl-h3 { margin: 28px 0 8px; font-size: 10.5px; text-transform: uppercase; letter-spacing: .07em; color: var(--text-ghost); font-weight: 600; }
.dl-hlist { border: 1px solid var(--border); border-radius: var(--r-card); background: var(--bg-side); overflow: hidden; max-width: none; }
.dl-hlist .hentry:last-child { border-bottom: none; }
.dl-more { margin-top: 10px; }
/* ---- history ---- */
.history { max-width: 860px; }
.hentry { padding: 11px 12px; border-bottom: 1px solid var(--border); }
@@ -258,7 +282,12 @@ button, input, a.btn { font-family: inherit; }
.hline { display: flex; gap: 10px; align-items: center; }
.hkind { display: inline-flex; color: var(--add); }
.hkind .ico { width: 13px; height: 13px; }
.hentry.edit .hkind { color: var(--accent); }
.hentry.delete .hkind { color: var(--del); }
.htag { flex: none; font-size: 10px; text-transform: uppercase; letter-spacing: .06em; font-weight: 600; padding: 1px 6px; border-radius: 4px; }
.hentry.add .htag { color: var(--add); background: rgba(76,195,138,.12); }
.hentry.edit .htag { color: var(--accent-bright); background: var(--glow); }
.hentry.delete .htag { color: #ff8b8b; background: rgba(242,109,109,.12); }
.hpath { font-weight: 500; cursor: pointer; color: var(--text); font-size: 13px; }
.hpath:hover { color: var(--accent-bright); }
.htime { margin-left: auto; color: var(--text-faint); font-size: 12px; font-variant-numeric: tabular-nums; }
@@ -268,6 +297,10 @@ button, input, a.btn { font-family: inherit; }
.hact { margin-left: auto; }
.hact a { color: var(--accent-bright); text-decoration: none; margin-left: 10px; }
.hact a:hover { text-decoration: underline; }
.hnote { margin-top: 4px; padding-left: 23px; font-size: 12px; color: var(--text-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hnote::before { content: " "; color: var(--text-ghost); }
.hnote a { color: var(--accent-bright); text-decoration: none; }
.hnote a:hover { text-decoration: underline; }
/* ---- command palette ---- */
#palette-overlay { position: fixed; inset: 0; background: rgba(6,7,9,.62); backdrop-filter: blur(3px); display: flex; justify-content: center; align-items: flex-start; padding-top: 12vh; z-index: 100; }
@@ -330,7 +363,7 @@ button, input, a.btn { font-family: inherit; }
#invite-btn { min-height: 40px; padding: 0 14px; }
#org-name { min-height: 44px; }
.nav-add { min-width: 44px; min-height: 44px; }
.markdown, .admin, .onboard, .history { max-width: 100%; }
.markdown, .admin, .onboard, .history, .dirlist { max-width: 100%; }
.markdown table, pre.plain { display: block; overflow-x: auto; max-width: 100%; }
.ob-row { flex-direction: column; }
}