Compare commits

...

6 Commits

Author SHA1 Message Date
240635d30c Merge branch 'entry-content-visibility'
Some checks failed
Publish Docker Image / build-and-push (push) Failing after 3s
2026-05-06 19:39:01 +00:00
47303d6812 perf: skip render of off-screen log cells via content-visibility
With logs up to ~25 000 entries, eagerly painting every grid cell
caused multi-second freezes on page load. Add content-visibility:
auto to the line-number and content cells so the browser defers
their layout / paint until they scroll into view.

The rule lives on the cells (not on .entry itself) because .entry
uses display: contents and produces no box of its own.
contain-intrinsic-size: auto 1.5em lets the browser remember
measured heights after first paint and uses ~one line tall as the
initial placeholder for never-seen cells.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 19:39:01 +00:00
aeef6bc0cd Merge branch 'auto-fold-on-load'
Some checks failed
Publish Docker Image / build-and-push (push) Failing after 4s
2026-05-06 19:31:31 +00:00
415324f340 feat: apply smart fold automatically on page load
Logs with errors now open already smart-folded — every entry within
±25 of an error stays visible, gaps collapse into draggable bars.
Folds can only be expanded individually via per-bar click or
drag; the previous "toggle to unfold everything" path is gone. The
header error-count chip becomes informational only (cursor and
pointer-events stripped so it no longer reads as interactive).

Removed the dead toggleErrors / uncollapseAllErrors / "toggled"
state plumbing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 19:31:31 +00:00
21aaeac884 Merge branch 'errors-fold-drag'
Some checks failed
Publish Docker Image / build-and-push (push) Failing after 4s
Smart fold-around-errors with drag-to-reveal handle. Default ±25
context per error, draggable bars between gaps, click-to-reveal-25
fallback.
2026-05-06 19:27:59 +00:00
4d0d98c604 feat: smart fold around errors with drag-to-reveal handle
Replaces the all-or-nothing "Errors only" collapse with a contextual
fold: ±25 entries around every error stay visible, and runs of
non-error entries with no nearby error collapse into a fold bar.

Each fold bar is draggable. Vertical pointer drag on the bar reveals
or re-hides lines from the top of the hidden range, ~6 px per line.
A click without drag reveals the next 25 lines. When the run is
fully revealed, the bar removes itself. Buttons / scroll behaviour
are unchanged; the existing "X errors" toggle in the header is the
entry point and still un-collapses everything on second click.

Visual: foldable bars get a faint horizontal hatch (suggesting
compressed content), an ns-resize cursor, and a hover/dragging
state that intensifies the hatch toward --accent. The grip-lines
icon flanks the line-count to call out the affordance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 19:27:59 +00:00
2 changed files with 229 additions and 73 deletions

View File

@@ -950,6 +950,24 @@ main {
width: 100%; width: 100%;
} }
/*
* Skip layout / paint of off-screen log cells until they scroll into
* view. With logs running up to ~25 000 entries the eager render of
* every cell is what causes the multi-second freeze on initial load.
*
* `.entry` itself is `display: contents` (no box), so the rule has to
* land on the actual grid items it produces. `contain-intrinsic-size:
* auto 1.5em` lets the browser remember measured heights after first
* paint and falls back to ~one line tall as the placeholder for
* never-seen cells. Multi-line stack traces correct on first pass; the
* scroll position adjusts once.
*/
.log-inner .line-number-container,
.log-inner .line-content {
content-visibility: auto;
contain-intrinsic-size: auto 1.5em;
}
.log-inner .entry.entry-error .line-content, .log-inner .entry.entry-error .line-content,
.log-inner .entry.entry-error .line-number-container{ .log-inner .entry.entry-error .line-number-container{
background-color: var(--error-bg); background-color: var(--error-bg);
@@ -1088,6 +1106,82 @@ main {
color: var(--accent); color: var(--accent);
} }
/*
* Foldable variant — generated by the smart "errors + 25 context" toggle in
* log.js. The bar is draggable: vertical pointer drag reveals or re-hides
* lines from the top of the hidden range. Visual cues:
* - ns-resize cursor announces the drag direction.
* - Faint horizontal hatch hints at compressed content underneath.
* - Hover and drag states intensify the hatch toward --accent so the
* bar reads as an interactive separator, not decorative chrome.
*/
.collapsed-lines-foldable {
cursor: ns-resize;
user-select: none;
-webkit-user-select: none;
}
.collapsed-lines-foldable .collapsed-lines-count {
background:
repeating-linear-gradient(
to bottom,
transparent 0,
transparent 3px,
color-mix(in srgb, var(--border) 60%, transparent) 3px,
color-mix(in srgb, var(--border) 60%, transparent) 4px
),
var(--surface);
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
transition:
background-color 0.12s ease,
color 0.12s ease,
border-color 0.12s ease,
box-shadow 0.12s ease;
}
.collapsed-lines-foldable:hover .collapsed-lines-count,
.collapsed-lines-dragging .collapsed-lines-count {
background:
repeating-linear-gradient(
to bottom,
transparent 0,
transparent 3px,
color-mix(in srgb, var(--accent) 35%, transparent) 3px,
color-mix(in srgb, var(--accent) 35%, transparent) 4px
),
var(--accent-bg);
border-top-color: color-mix(in srgb, var(--accent) 60%, transparent);
border-bottom-color: color-mix(in srgb, var(--accent) 60%, transparent);
color: var(--accent);
}
.collapsed-lines-foldable:hover .collapsed-lines-count i,
.collapsed-lines-dragging .collapsed-lines-count i {
color: var(--accent);
}
.collapsed-lines-dragging {
cursor: grabbing;
}
.collapsed-lines-dragging .collapsed-lines-count {
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--accent) 70%, transparent),
0 6px 16px color-mix(in srgb, var(--accent) 25%, transparent);
}
/*
* The header error-count chip is informational only — the smart fold is
* applied automatically on page load and folds can only be expanded
* individually. Strip the .btn cursor / hover affordance so the chip
* doesn't read as interactive.
*/
#error-toggle {
cursor: default;
pointer-events: none;
}
.log-inner .level { .log-inner .level {
display: block; display: block;
white-space: pre-wrap; white-space: pre-wrap;

View File

@@ -47,93 +47,155 @@ function scrollToHeight(top, smoothScrollLimit = 10000) {
window.scrollTo({left: 0, top, behavior}); window.scrollTo({left: 0, top, behavior});
} }
/* error collapse toggle */ /*
const toggleErrorsButton = document.getElementById("error-toggle"); * Smart fold around errors.
if (toggleErrorsButton) { *
toggleErrorsButton.addEventListener("click", toggleErrors); * Every entry within ±ERROR_WINDOW_SIZE of an error stays visible; runs of
} * non-error entries with no nearby errors collapse into a draggable fold
* bar. Vertical drag on the bar progressively reveals or re-hides lines;
* a click without drag reveals the next DEFAULT_CLICK_REVEAL lines.
*
* The fold is applied automatically on page load when the log contains
* errors. Folds can only be expanded individually — there is no
* "unfold everything" path. The error-count chip in the header is purely
* informational; it is not interactive.
*/
const ERROR_WINDOW_SIZE = 25;
const PIXELS_PER_LINE_DRAG = 6;
const DEFAULT_CLICK_REVEAL = 25;
const DRAG_PIXEL_THRESHOLD = 3;
function toggleErrors() { applySmartFold();
if (toggleErrorsButton.classList.contains("toggled")) {
toggleErrorsButton.classList.remove("toggled"); function applySmartFold() {
uncollapseAllErrors(); const lines = Array.from(document.querySelectorAll('.log-inner > .entry'));
const total = lines.length;
if (!total) return;
// Pass 1: mark entries within ±ERROR_WINDOW_SIZE of any error. If the
// log has no errors, every entry stays visible (we never produce one
// giant fold spanning the whole log).
const mustShow = new Array(total).fill(false);
let sawError = false;
for (let i = 0; i < total; i++) {
if (lines[i].classList.contains("entry-error")) {
sawError = true;
const lo = Math.max(0, i - ERROR_WINDOW_SIZE);
const hi = Math.min(total - 1, i + ERROR_WINDOW_SIZE);
for (let j = lo; j <= hi; j++) mustShow[j] = true;
}
}
if (!sawError) return;
// Pass 2: hide unmarked entries; emit a fold bar at the START of each run.
let runEntries = [];
const flushRun = () => {
if (!runEntries.length) return;
const bar = createFoldBar(runEntries);
runEntries[0].insertAdjacentElement("beforebegin", bar);
runEntries = [];
};
for (let i = 0; i < total; i++) {
if (!mustShow[i]) {
lines[i].style.display = "none";
runEntries.push(lines[i]);
} else { } else {
toggleErrorsButton.classList.add("toggled"); flushRun();
collapseAllErrors();
} }
}
flushRun();
} }
function collapseAllErrors() { function createFoldBar(entries) {
let firstNoErrorLine = false; const bar = document.createElement("div");
let lines = document.querySelectorAll('.log-inner > .entry'); bar.classList.add("collapsed-lines", "collapsed-lines-foldable");
let totalLines = lines.length; bar.appendChild(document.createElement("div")); // line-number column slot
for (const [i, line] of lines.entries()) {
let lineNumber = line.querySelector(".line-number").innerHTML;
if (line.classList.contains("entry-no-error")) {
line.style.display = "none";
if (firstNoErrorLine === false) { const count = document.createElement("div");
firstNoErrorLine = lineNumber; count.classList.add("collapsed-lines-count");
} bar.appendChild(count);
if (i + 1 === totalLines && firstNoErrorLine) { bar._hiddenEntries = entries;
line.insertAdjacentElement("afterend", generateCollapsedLines(firstNoErrorLine, lineNumber)); bar._revealedCount = 0;
} renderFoldLabel(bar);
} else { attachFoldDragHandler(bar);
if (firstNoErrorLine) { return bar;
line.insertAdjacentElement("beforebegin", generateCollapsedLines(firstNoErrorLine, lineNumber - 1));
firstNoErrorLine = false;
}
}
}
} }
function uncollapseAllErrors() { function renderFoldLabel(bar) {
document.querySelectorAll('.entry-no-error').forEach(line => line.style.removeProperty("display")); const entries = bar._hiddenEntries;
document.querySelectorAll('.collapsed-lines').forEach(collapsed => collapsed.remove()); const total = entries.length;
const revealed = bar._revealedCount;
const remaining = total - revealed;
const count = bar.querySelector(".collapsed-lines-count");
count.replaceChildren();
if (remaining <= 0) return;
const firstHiddenLine = parseInt(entries[revealed].querySelector(".line-number").innerHTML, 10);
const lastHiddenLine = parseInt(entries[total - 1].querySelector(".line-number").innerHTML, 10);
const word = remaining === 1 ? "line" : "lines";
const grip = document.createElement("i");
grip.className = "fa-solid fa-grip-lines";
count.appendChild(grip);
count.append(` ${remaining} ${word} hidden · ${firstHiddenLine}${lastHiddenLine} · drag to reveal `);
count.appendChild(grip.cloneNode());
} }
function handleCollapsedClick(e) { function applyFoldReveal(bar, n) {
let collapsed = e.currentTarget; const entries = bar._hiddenEntries;
let positionElement = document.getElementById(`L${parseInt(collapsed.dataset.end) + 1}`); n = Math.max(0, Math.min(entries.length, n));
let position; if (n === bar._revealedCount) return;
if (positionElement) { for (let i = 0; i < entries.length; i++) {
position = positionElement.getBoundingClientRect().top - window.scrollY; if (i < n) entries[i].style.removeProperty("display");
else entries[i].style.display = "none";
} }
for (let i = parseInt(collapsed.dataset.start); i <= parseInt(collapsed.dataset.end); i++) { bar._revealedCount = n;
document.getElementById(`L${i}`).parentElement.parentElement.style.removeProperty("display"); renderFoldLabel(bar);
} }
if (positionElement) {
window.scrollTo({ function attachFoldDragHandler(bar) {
left: 0, let dragging = false;
top: positionElement.getBoundingClientRect().top - position - collapsed.offsetHeight, let dragStartY = 0;
behavior: "instant" let dragStartReveal = 0;
let didDrag = false;
bar.addEventListener("pointerdown", (e) => {
if (e.button !== 0) return;
dragging = true;
didDrag = false;
dragStartY = e.clientY;
dragStartReveal = bar._revealedCount;
bar.setPointerCapture(e.pointerId);
bar.classList.add("collapsed-lines-dragging");
e.preventDefault();
}); });
bar.addEventListener("pointermove", (e) => {
if (!dragging) return;
const dy = e.clientY - dragStartY;
if (Math.abs(dy) >= DRAG_PIXEL_THRESHOLD) didDrag = true;
const target = dragStartReveal + Math.round(dy / PIXELS_PER_LINE_DRAG);
applyFoldReveal(bar, target);
});
const finish = (e) => {
if (!dragging) return;
dragging = false;
try { bar.releasePointerCapture(e.pointerId); } catch {}
bar.classList.remove("collapsed-lines-dragging");
if (!didDrag) {
// Plain click: reveal the next chunk of lines.
applyFoldReveal(bar, bar._revealedCount + DEFAULT_CLICK_REVEAL);
} }
collapsed.remove(); if (bar._revealedCount >= bar._hiddenEntries.length) {
} bar.remove();
}
function generateCollapsedLines(start, end) { };
let count = end - start + 1; bar.addEventListener("pointerup", finish);
let string = count === 1 ? "line" : "lines"; bar.addEventListener("pointercancel", finish);
let collapsedRow = document.createElement("div");
collapsedRow.classList.add("collapsed-lines");
collapsedRow.dataset.start = start;
collapsedRow.dataset.end = end;
collapsedRow.appendChild(document.createElement("div"));
collapsedRow.addEventListener("click", handleCollapsedClick);
let collapsedLinesCount = document.createElement("div");
collapsedLinesCount.classList.add("collapsed-lines-count");
let icon = document.createElement("i");
icon.classList.add("fa-solid", "fa-angle-up");
collapsedLinesCount.appendChild(icon);
collapsedLinesCount.append(` ${count} ${string} `);
collapsedLinesCount.append(icon.cloneNode());
collapsedRow.appendChild(collapsedLinesCount);
return collapsedRow;
} }
/* convert timestamps */ /* convert timestamps */