mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
feat(web): UX polish — stable rerenders, RESTful view routes, unified history rows
- No-flicker polling: tree and heat refreshes compare fetched data and skip DOM rebuilds when nothing changed; in-place listing re-renders preserve scroll. - RESTful routes: /<project>/insights and /<project>/history[/<path>] replace query-param views; the reserved first segments survive refresh, deep links, and back/forward. Per-route scroll restore (manual scrollRestoration) returns the reader to where they were. - History rows unified: every entry (recent changes and full history) is a clickable row that opens the file — view/download version links removed; old versions remain reachable via the blob API until the time-travel viewer lands. Folder tree loses the per-dir history icon. - Hot path: danger rows use the amber accent, ⚠ trails the filename, and a tooltip explains the flag (reads/30d, days unchanged). - Frontend responses send Cache-Control: no-cache so upgrades never leave browsers on a stale embedded UI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P5cxPQdSGJnjXCYY9GeWXt
This commit is contained in:
co-authored by
Claude Fable 5
parent
7c1fc0a324
commit
87539e1ee7
@@ -383,6 +383,9 @@ func (s *Server) frontend(static fs.FS) http.HandlerFunc {
|
||||
files := http.FileServerFS(static)
|
||||
index, _ := fs.ReadFile(static, "index.html")
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// Embedded assets carry no modtime, so without this browsers cache
|
||||
// them heuristically and users see a stale frontend after upgrades.
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
upath := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
|
||||
// Reserved prefixes that fell through to the catch-all are genuine
|
||||
// 404s — don't mask a mistyped API/auth/share URL with the app shell.
|
||||
|
||||
+141
-46
@@ -64,6 +64,9 @@ async function postJSON(url, body) {
|
||||
|
||||
/* ---- boot ---- */
|
||||
async function boot() {
|
||||
// The app restores per-route scroll itself (scrollMemo); the browser's own
|
||||
// restoration would fight it on back/forward.
|
||||
if ("scrollRestoration" in history) history.scrollRestoration = "manual";
|
||||
try {
|
||||
serverConfig = await getJSON("/api/config");
|
||||
} catch { /* non-fatal */ }
|
||||
@@ -81,13 +84,13 @@ async function boot() {
|
||||
await loadOrgs();
|
||||
await loadProjects();
|
||||
updateAdminBar();
|
||||
const { project, path } = parseRoute();
|
||||
const route = parseRoute();
|
||||
// After accepting an invite, open a project in the org you just joined
|
||||
// rather than whatever happened to be first.
|
||||
const proj = projects.find((x) => x.id === project)
|
||||
const proj = projects.find((x) => x.id === route.project)
|
||||
|| (joinedOrgId && projects.find((x) => x.org === joinedOrgId))
|
||||
|| projects[0];
|
||||
if (proj) selectProject(proj, path);
|
||||
if (proj) selectProject(proj, route);
|
||||
else { $("vault-name").textContent = serverConfig.brand || serverConfig.volume || "BearDrive"; updateOrgBar(); showEmptyState(); }
|
||||
setInterval(loadProjects, 30000); // pick up new projects
|
||||
} else {
|
||||
@@ -146,12 +149,14 @@ async function loadProjects() {
|
||||
nav.appendChild(ul);
|
||||
}
|
||||
|
||||
function selectProject(p, path) {
|
||||
function selectProject(p, route) {
|
||||
currentProject = p;
|
||||
expanded = new Set(); // fresh collapse state for the new project's tree
|
||||
treeFirstLoad = true;
|
||||
heatMap = null; // the old project's heat means nothing here
|
||||
heatAt = 0;
|
||||
lastTreeJSON = "";
|
||||
lastHeatJSON = "";
|
||||
apiBase = "/api/p/" + p.id + "/";
|
||||
$("vault-name").textContent = p.name;
|
||||
document.title = p.name + " — BearDrive";
|
||||
@@ -166,13 +171,10 @@ function selectProject(p, path) {
|
||||
initUpload();
|
||||
initHistory();
|
||||
updateShareButton();
|
||||
refreshTree().then(() => {
|
||||
if (path) openPath(path);
|
||||
// Landing view: admins/owners get the Insights dashboard instead of an
|
||||
// empty "select a file" pane; members keep the placeholder.
|
||||
else if (canSeeInsights()) showInsights();
|
||||
});
|
||||
if (!path) pushURL("/" + p.id);
|
||||
// Landing view (applyRoute with an empty route): admins/owners get the
|
||||
// Insights dashboard instead of an empty "select a file" pane.
|
||||
refreshTree().then(() => applyRoute(route || {}));
|
||||
if (!route || (!route.path && !route.view)) pushURL("/" + p.id);
|
||||
}
|
||||
|
||||
/* ---- hub: organizations ---- */
|
||||
@@ -653,12 +655,28 @@ function refreshAll() {
|
||||
function encodePath(p) { return p.split("/").map(encodeURIComponent).join("/"); }
|
||||
function decodePath(p) { return p.split("/").map(decodeURIComponent).join("/"); }
|
||||
|
||||
/* Special views are RESTful routes under the project — the first segment
|
||||
after the project id is reserved when it names a view:
|
||||
/<project-id>/insights the Insights dashboard
|
||||
/<project-id>/history[/<path>] change feed (whole project / subtree / file)
|
||||
(Root-level files literally named "insights" or "history" lose the URL
|
||||
shortcut and remain reachable through the tree.) */
|
||||
const VIEW_ROUTES = new Set(["insights", "history"]);
|
||||
|
||||
function parseRoute() {
|
||||
const raw = location.pathname.replace(/^\/+/, "");
|
||||
if (serverConfig.mode !== "hub") return { path: raw ? decodePath(raw) : "" };
|
||||
const slash = raw.indexOf("/");
|
||||
if (slash === -1) return { project: raw, path: "" };
|
||||
return { project: raw.slice(0, slash), path: decodePath(raw.slice(slash + 1)) };
|
||||
const r = { project: raw.slice(0, slash), path: decodePath(raw.slice(slash + 1)) };
|
||||
const seg = r.path.indexOf("/");
|
||||
const head = seg === -1 ? r.path : r.path.slice(0, seg);
|
||||
if (VIEW_ROUTES.has(head)) {
|
||||
r.view = head;
|
||||
r.viewTarget = seg === -1 ? "" : r.path.slice(seg + 1).replace(/\/+$/, "");
|
||||
r.path = "";
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
/* The URL for a file within the current context. */
|
||||
@@ -670,21 +688,73 @@ function urlForPath(path) {
|
||||
return "/" + enc;
|
||||
}
|
||||
|
||||
/* The URL for a special view of the current project. */
|
||||
function urlForView(view, q) {
|
||||
let s = (serverConfig.mode === "hub" && currentProject ? "/" + currentProject.id : "") + "/" + view;
|
||||
if (view === "history" && q) {
|
||||
const target = "path" in q ? q.path : (q.prefix || "").replace(/\/+$/, "");
|
||||
if (target) s += "/" + encodePath(target);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/* Dispatch a parsed route to the right view (after the tree has loaded). */
|
||||
function applyRoute(r) {
|
||||
if (r.view === "insights") return showInsights(false);
|
||||
if (r.view === "history") {
|
||||
// The route stores one target; the tree says whether it is a folder
|
||||
// (subtree feed) or a file (version list).
|
||||
const t = r.viewTarget || "";
|
||||
const hq = !t ? { prefix: "" } : dirIndex.has(t) ? { prefix: t + "/" } : { path: t };
|
||||
return showHistory(hq, false);
|
||||
}
|
||||
if (r.path) return openPath(r.path);
|
||||
if (canSeeInsights()) showInsights("replace");
|
||||
}
|
||||
|
||||
/* Per-route scroll positions of the content pane, so back/forward returns
|
||||
to where the user was (e.g. deep in a folder's change feed). */
|
||||
const scrollMemo = new Map();
|
||||
let routeKey = location.pathname + location.search;
|
||||
let pendingScroll = null; // set by popstate, consumed by the next render
|
||||
function rememberScroll() { scrollMemo.set(routeKey, $("content").scrollTop); }
|
||||
function applyPendingScroll(final) {
|
||||
if (pendingScroll == null) return;
|
||||
$("content").scrollTo({ top: pendingScroll, behavior: "instant" });
|
||||
if (final) pendingScroll = null;
|
||||
}
|
||||
|
||||
/* Push a route without reloading, skipping a no-op that would just stack a
|
||||
duplicate history entry (e.g. when boot opens the file already in the URL). */
|
||||
function pushURL(url) {
|
||||
if (location.pathname === url) return;
|
||||
if (location.pathname + location.search === url) return;
|
||||
rememberScroll();
|
||||
history.pushState(null, "", url);
|
||||
routeKey = url;
|
||||
}
|
||||
/* Replace the current entry (landing views that shouldn't stack). */
|
||||
function replaceURL(url) {
|
||||
history.replaceState(null, "", url);
|
||||
routeKey = url;
|
||||
}
|
||||
function syncURL(path) { pushURL(urlForPath(path)); }
|
||||
|
||||
/* ---- tree ---- */
|
||||
let lastTreeJSON = ""; // skip re-rendering when nothing changed (no flicker)
|
||||
async function refreshTree() {
|
||||
if (serverConfig.mode === "hub" && !currentProject) return;
|
||||
let root;
|
||||
try {
|
||||
root = await getJSON(apiBase + "tree");
|
||||
} catch { return; } // keep the last good tree
|
||||
// The 15s poll usually returns the same tree — rebuilding the DOM anyway
|
||||
// makes the sidebar (and any open folder listing) visibly flicker.
|
||||
const json = JSON.stringify(root);
|
||||
if (json === lastTreeJSON) {
|
||||
refreshHeat();
|
||||
return;
|
||||
}
|
||||
lastTreeJSON = json;
|
||||
flatFiles = [];
|
||||
dirIndex = new Map();
|
||||
const kids = root.children || [];
|
||||
@@ -709,6 +779,7 @@ async function refreshTree() {
|
||||
/* ---- read heat ----
|
||||
30-day read counts per path from the heat API (hub only). Counts only —
|
||||
the server never says who read what. Fetched lazily alongside the tree. */
|
||||
let lastHeatJSON = "";
|
||||
async function refreshHeat(force) {
|
||||
if (!(serverConfig.mode === "hub" && currentProject)) return;
|
||||
if (!(serverConfig.reads && serverConfig.reads.enabled)) return;
|
||||
@@ -718,6 +789,9 @@ async function refreshHeat(force) {
|
||||
try {
|
||||
out = await getJSON(apiBase + "heat?days=30");
|
||||
} catch { return; } // keep the last good heat
|
||||
const json = JSON.stringify(out.entries || {});
|
||||
if (json === lastHeatJSON) return; // unchanged: no re-render, no flicker
|
||||
lastHeatJSON = json;
|
||||
heatMap = out.entries || {};
|
||||
if (currentPath && dirIndex.has(currentPath) && $("content").querySelector(".dirlist")) {
|
||||
renderFolderListing(currentPath);
|
||||
@@ -789,14 +863,6 @@ function renderNode(n) {
|
||||
li.appendChild(row);
|
||||
if (n.dir) {
|
||||
dirIndex.set(n.path, n);
|
||||
if (serverConfig.mode === "hub") {
|
||||
const hist = document.createElement("span");
|
||||
hist.className = "dir-history";
|
||||
hist.innerHTML = svgIcon("hist");
|
||||
hist.title = "Folder history";
|
||||
hist.onclick = (e) => { e.stopPropagation(); showHistory({ prefix: n.path + "/" }); };
|
||||
row.appendChild(hist);
|
||||
}
|
||||
li.appendChild(renderChildren(n.children || []));
|
||||
const open = expanded.has(n.path);
|
||||
if (!open) li.classList.add("collapsed");
|
||||
@@ -907,6 +973,7 @@ function openFolder(p) {
|
||||
$("download").hidden = true;
|
||||
$("more-btn").hidden = !(serverConfig.mode === "hub" && currentProject);
|
||||
updateShareButton();
|
||||
if (pendingScroll == null) $("content").scrollTop = 0; // fresh navigation starts at the top
|
||||
renderFolderListing(p);
|
||||
}
|
||||
|
||||
@@ -914,6 +981,11 @@ function renderFolderListing(p) {
|
||||
const node = dirIndex.get(p);
|
||||
if (!node) return;
|
||||
const content = $("content");
|
||||
// Scroll intent: a back/forward restore wins; an in-place refresh (tree or
|
||||
// heat poll re-render) keeps the reader where they are; openFolder resets
|
||||
// to the top before calling us.
|
||||
const keepTop = pendingScroll != null ? pendingScroll : content.scrollTop;
|
||||
pendingScroll = null;
|
||||
content.className = "view";
|
||||
content.innerHTML = "";
|
||||
const wrap = el(content, "div", "dirlist");
|
||||
@@ -933,7 +1005,8 @@ function renderFolderListing(p) {
|
||||
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);
|
||||
if (keepTop) content.scrollTo({ top: keepTop, behavior: "instant" });
|
||||
renderFolderHistory(wrap, p, keepTop);
|
||||
return;
|
||||
}
|
||||
const list = el(wrap, "div", "dl-items");
|
||||
@@ -963,13 +1036,14 @@ function renderFolderListing(p) {
|
||||
row.onclick = () => openPath(c.path);
|
||||
row.onkeydown = (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); row.click(); } };
|
||||
}
|
||||
renderFolderHistory(wrap, p);
|
||||
if (keepTop) content.scrollTo({ top: keepTop, behavior: "instant" });
|
||||
renderFolderHistory(wrap, p, keepTop);
|
||||
}
|
||||
|
||||
/* 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) {
|
||||
function renderFolderHistory(wrap, p, restoreTop) {
|
||||
if (!(serverConfig.mode === "hub" && currentProject)) return;
|
||||
const sec = el(wrap, "div", "dl-history");
|
||||
getJSON(apiBase + "history?prefix=" + encodeURIComponent(p + "/") + "&n=20").then((out) => {
|
||||
@@ -980,11 +1054,16 @@ function renderFolderHistory(wrap, p) {
|
||||
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 + "/" });
|
||||
// The feed adds height after the listing rendered; a restored scroll
|
||||
// position (back/forward) may only fit now.
|
||||
if (restoreTop) $("content").scrollTo({ top: restoreTop, behavior: "instant" });
|
||||
}).catch(() => sec.remove());
|
||||
}
|
||||
|
||||
/* ---- file pane ---- */
|
||||
async function openFile(p) {
|
||||
const restoreTop = pendingScroll != null ? pendingScroll : 0;
|
||||
pendingScroll = null;
|
||||
currentPath = p;
|
||||
syncURL(p);
|
||||
markActive();
|
||||
@@ -1029,6 +1108,7 @@ async function openFile(p) {
|
||||
content.innerHTML = `<div class="empty"></div>`;
|
||||
content.querySelector(".empty").textContent = "Could not load file: " + err.message;
|
||||
}
|
||||
content.scrollTo({ top: restoreTop, behavior: "instant" });
|
||||
}
|
||||
|
||||
function showMeta(doc) {
|
||||
@@ -1159,8 +1239,13 @@ function canSeeInsights() {
|
||||
|
||||
let insightsDevices = null; // /heat?by=device breakdown, fetched per Insights open
|
||||
|
||||
async function showInsights() {
|
||||
async function showInsights(push = true) {
|
||||
if (!canSeeInsights()) return;
|
||||
// A real route (?v=insights): refresh, deep links, and back/forward work.
|
||||
if (push === "replace") replaceURL(urlForView("insights"));
|
||||
else if (push) pushURL(urlForView("insights"));
|
||||
const restoreTop = pendingScroll != null ? pendingScroll : 0;
|
||||
pendingScroll = null;
|
||||
await refreshHeat(true);
|
||||
insightsDevices = null;
|
||||
try {
|
||||
@@ -1175,6 +1260,7 @@ async function showInsights() {
|
||||
const content = $("content");
|
||||
content.className = "view";
|
||||
renderInsights(content, "all");
|
||||
content.scrollTo({ top: restoreTop, behavior: "instant" });
|
||||
}
|
||||
|
||||
/* SVG element helper for the insights charts. */
|
||||
@@ -1345,8 +1431,10 @@ function renderHotPath(wrap, pts, lens) {
|
||||
const row = el(list, "div", "in-hp-row");
|
||||
row.tabIndex = 0;
|
||||
row.setAttribute("role", "button");
|
||||
row.title = p.path + (p.danger ? " — hot + stale" : "");
|
||||
el(row, "span", "in-hp-name" + (p.danger ? " danger" : ""), (p.danger ? "⚠ " : "") + p.path);
|
||||
row.title = p.danger
|
||||
? `${p.reads} read${p.reads === 1 ? "" : "s"}/30d · unchanged ${Math.round(p.days)}d — review this file`
|
||||
: p.path;
|
||||
el(row, "span", "in-hp-name" + (p.danger ? " danger" : ""), p.path + (p.danger ? " ⚠" : ""));
|
||||
const barw = el(row, "span", "in-hp-bar");
|
||||
// Split of the lens reads: pure lenses are single-color by definition.
|
||||
const aFrac = lens === "agent" ? 1 : lens === "human" ? 0 : (p.total ? p.agent / p.total : 0);
|
||||
@@ -1474,7 +1562,11 @@ function currentProjectOrNull() {
|
||||
return serverConfig.mode === "hub" ? currentProject : null;
|
||||
}
|
||||
|
||||
async function showHistory(q) {
|
||||
async function showHistory(q, push = true) {
|
||||
// A real route (?v=history&path=|prefix=): refresh and back/forward work.
|
||||
if (push) pushURL(urlForView("history", q));
|
||||
const restoreTop = pendingScroll != null ? pendingScroll : 0;
|
||||
pendingScroll = null;
|
||||
const content = $("content");
|
||||
const qs = "path" in q
|
||||
? "path=" + encodeURIComponent(q.path)
|
||||
@@ -1500,6 +1592,7 @@ async function showHistory(q) {
|
||||
}
|
||||
for (const e of out.entries || []) wrap.appendChild(historyEntryRow(e));
|
||||
content.appendChild(wrap);
|
||||
content.scrollTo({ top: restoreTop, behavior: "instant" });
|
||||
}
|
||||
|
||||
/* One change as a row: what happened (added / edited / deleted), to which
|
||||
@@ -1515,7 +1608,7 @@ function historyEntryRow(e) {
|
||||
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>`;
|
||||
`<div class="hmeta"><span class="hwho"></span><span class="hdev"></span><span class="hsize"></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;
|
||||
@@ -1523,17 +1616,6 @@ function historyEntryRow(e) {
|
||||
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";
|
||||
@@ -1554,6 +1636,7 @@ function historyEntryRow(e) {
|
||||
note.setAttribute("role", "button");
|
||||
note.title = "Show full note";
|
||||
note.onclick = (ev) => {
|
||||
ev.stopPropagation(); // expanding a note is not a navigation
|
||||
if (ev.target.tagName === "A") return;
|
||||
const open = note.classList.toggle("open");
|
||||
note.title = open ? "Collapse note" : "Show full note";
|
||||
@@ -1562,8 +1645,17 @@ function historyEntryRow(e) {
|
||||
note.onkeydown = (ev) => { if (ev.key === "Enter" || ev.key === " ") { ev.preventDefault(); note.click(); } };
|
||||
row.appendChild(note);
|
||||
}
|
||||
// The whole row opens the file it names (deleted files have nothing to
|
||||
// open). Old versions stay retrievable via the blob API; a proper version
|
||||
// viewer is the planned time-travel phase.
|
||||
const p = e.path;
|
||||
row.querySelector(".hpath").onclick = () => showHistory({ path: p });
|
||||
if (kind !== "delete") {
|
||||
row.classList.add("clickable");
|
||||
row.tabIndex = 0;
|
||||
row.setAttribute("role", "button");
|
||||
row.onclick = (ev) => { if (ev.target.tagName !== "A") openFile(p); };
|
||||
row.onkeydown = (ev) => { if (ev.key === "Enter" || ev.key === " ") { ev.preventDefault(); openFile(p); } };
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
@@ -1913,12 +2005,15 @@ $("sb-backdrop").addEventListener("click", closeSidebarOnMobile);
|
||||
/* Back/forward: re-resolve the route from the URL. selectProject/openFile
|
||||
dedup against the current URL, so replaying it here never stacks history. */
|
||||
window.addEventListener("popstate", () => {
|
||||
const { project, path } = parseRoute();
|
||||
if (serverConfig.mode === "hub" && project && (!currentProject || currentProject.id !== project)) {
|
||||
const proj = projects.find((x) => x.id === project);
|
||||
if (proj) { selectProject(proj, path || null); return; }
|
||||
rememberScroll(); // routeKey still names the route we are leaving
|
||||
routeKey = location.pathname + location.search;
|
||||
pendingScroll = scrollMemo.has(routeKey) ? scrollMemo.get(routeKey) : null;
|
||||
const r = parseRoute();
|
||||
if (serverConfig.mode === "hub" && r.project && (!currentProject || currentProject.id !== r.project)) {
|
||||
const proj = projects.find((x) => x.id === r.project);
|
||||
if (proj) { selectProject(proj, r); return; }
|
||||
}
|
||||
if (path && path !== currentPath) openPath(path);
|
||||
applyRoute(r);
|
||||
});
|
||||
|
||||
boot();
|
||||
|
||||
@@ -137,14 +137,6 @@ button, input, a.btn { font-family: inherit; }
|
||||
#tree .file .chev { visibility: hidden; }
|
||||
#tree li.collapsed > ul { display: none; }
|
||||
#tree li.collapsed > .row .chev { transform: rotate(-90deg); }
|
||||
.dir-history {
|
||||
margin-left: auto; flex: none; visibility: hidden;
|
||||
width: 24px; height: 24px; border-radius: 5px;
|
||||
display: inline-flex; align-items: center; justify-content: center; color: var(--text-ghost);
|
||||
}
|
||||
.dir-history .ico { width: 14px; height: 14px; }
|
||||
#tree li > .row:hover .dir-history { visibility: visible; }
|
||||
.dir-history:hover { color: var(--text); background: var(--hover); }
|
||||
|
||||
/* org bar */
|
||||
#orgbar { display: flex; align-items: center; gap: 9px; padding: 9px 12px 9px 14px; border-top: 1px solid var(--border); }
|
||||
@@ -279,6 +271,8 @@ button, input, a.btn { font-family: inherit; }
|
||||
.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; }
|
||||
.hentry.clickable { cursor: pointer; }
|
||||
.hentry.clickable:hover { background: var(--hover); }
|
||||
.dl-more { margin-top: 10px; }
|
||||
|
||||
/* ---- insights (read×write matrix) ---- */
|
||||
@@ -312,7 +306,7 @@ button, input, a.btn { font-family: inherit; }
|
||||
.in-hp-row:last-child { border-bottom: none; }
|
||||
.in-hp-row:hover { background: var(--hover); }
|
||||
.in-hp-name { flex: 0 0 300px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12.5px; color: var(--text); }
|
||||
.in-hp-name.danger { color: #e07070; }
|
||||
.in-hp-name.danger { color: var(--accent); }
|
||||
.in-hp-bar { flex: 1; display: flex; height: 10px; border-radius: 3px; overflow: hidden; }
|
||||
.in-hp-agent { background: var(--accent); }
|
||||
.in-hp-human { background: #5b8def; }
|
||||
@@ -344,9 +338,6 @@ button, input, a.btn { font-family: inherit; }
|
||||
.hmeta { display: flex; gap: 14px; margin-top: 4px; padding-left: 23px; font-size: 12px; color: var(--text-dim); }
|
||||
.hdev, .hsize { color: var(--text-faint); }
|
||||
.hsize { font-variant-numeric: tabular-nums; }
|
||||
.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; cursor: pointer; }
|
||||
.hnote:hover { color: var(--text); }
|
||||
.hnote.open { white-space: normal; overflow-wrap: anywhere; }
|
||||
|
||||
Reference in New Issue
Block a user