Compare commits
2 Commits
0060c024d5
...
next
| Author | SHA1 | Date | |
|---|---|---|---|
| f7c7f92e27 | |||
| 852a11eec2 |
6
TODO.md
6
TODO.md
@@ -22,6 +22,12 @@ files, so merging it also closes most compliance gaps.
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-08-10: every interactive control now meets the 44x44 CSS px minimum tap
|
||||
target (`.pin-btn`, `#interval-select`, the debug-log label and, on narrow
|
||||
viewports, `#pause-btn`). The pin button's hit area grows via matching
|
||||
negative margins, so its layout footprint and row density are unchanged
|
||||
- 2026-08-10: per-host status line wraps below the 768px breakpoint instead of
|
||||
forcing horizontal page scroll at 320px
|
||||
- 2026-08-09: `Dockerfile.backend` reworked to the mandated Go multistage
|
||||
lint-stage pattern: separate `lint` stage on the hash-pinned
|
||||
`golangci/golangci-lint` image, `COPY --from=lint` stage dependency,
|
||||
|
||||
59
src/main.js
59
src/main.js
@@ -537,6 +537,12 @@ class SparklineRenderer {
|
||||
|
||||
// --- UI Renderer -------------------------------------------------------------
|
||||
|
||||
// The per-host status line must stay wrappable: its populated content is
|
||||
// wider than the host column at a 320px viewport, and `whitespace-nowrap`
|
||||
// here overflows the element and forces the whole document to scroll
|
||||
// horizontally.
|
||||
const STATUS_TEXT_CLASS = "status-text text-xs text-right col-span-2 mt-5";
|
||||
|
||||
function hostRowHTML(host, index, showPin = true) {
|
||||
const pinColor = host.pinned
|
||||
? "text-blue-500"
|
||||
@@ -562,7 +568,7 @@ function hostRowHTML(host, index, showPin = true) {
|
||||
<span class="text-gray-500">---</span>
|
||||
</div>
|
||||
<a href="${host.url}" target="_blank" rel="noopener" class="text-xs text-gray-500 truncate block col-span-2 -mt-2">${host.url}</a>
|
||||
<div class="status-text text-xs text-gray-500 whitespace-nowrap text-right col-span-2 mt-5" data-host="${index}">waiting...</div>
|
||||
<div class="${STATUS_TEXT_CLASS} text-gray-500" data-host="${index}">waiting...</div>
|
||||
</div>
|
||||
<div class="flex-grow sparkline-container rounded overflow-hidden border border-gray-700/30">
|
||||
<canvas class="sparkline-canvas w-full" data-host="${index}" height="${CONFIG.canvasHeight}"></canvas>
|
||||
@@ -671,7 +677,7 @@ function buildUI(state) {
|
||||
</p>
|
||||
<p class="mt-2"><a href="https://git.eeqj.de/sneak/netwatch/commit/${__COMMIT_FULL__}" target="_blank" rel="noopener" class="text-gray-600 hover:text-gray-400">${__COMMIT_HASH__}</a></p>
|
||||
<p class="mt-2">
|
||||
<label class="cursor-pointer">
|
||||
<label class="debug-toggle-label cursor-pointer">
|
||||
<input type="checkbox" id="debug-toggle" class="mr-1">
|
||||
<span>Debug log</span>
|
||||
</label>
|
||||
@@ -688,6 +694,26 @@ function buildUI(state) {
|
||||
|
||||
// --- UI Updaters -------------------------------------------------------------
|
||||
|
||||
// Renders `min 1ms / med 2ms / avg 3ms / max 4ms`. Each label, value and
|
||||
// trailing separator is one unbreakable unit, so wrapping only ever happens
|
||||
// between stats and a wrapped line never starts with a separator.
|
||||
function statusStatsHTML(stats) {
|
||||
return stats
|
||||
.map(([label, value], i) => {
|
||||
const sep =
|
||||
i < stats.length - 1
|
||||
? ` <span class="text-gray-500">/</span>`
|
||||
: "";
|
||||
return (
|
||||
`<span class="whitespace-nowrap">` +
|
||||
`<span class="text-gray-400">${label} </span>` +
|
||||
`<span class="${latencyClass(value, "online")}">${value}ms</span>` +
|
||||
`${sep}</span>`
|
||||
);
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function updateHostRow(host, index) {
|
||||
const latencyEl = document.querySelector(
|
||||
`.latency-value[data-host="${index}"]`,
|
||||
@@ -712,28 +738,22 @@ function updateHostRow(host, index) {
|
||||
const min = host.minLatency();
|
||||
const max = host.maxLatency();
|
||||
if (host.status === "online" && avg !== null) {
|
||||
statusEl.innerHTML =
|
||||
`<span class="text-gray-400">min </span><span class="${latencyClass(min, "online")}">${min}ms</span>` +
|
||||
` <span class="text-gray-500">/</span> ` +
|
||||
`<span class="text-gray-400">med </span><span class="${latencyClass(med, "online")}">${med}ms</span>` +
|
||||
` <span class="text-gray-500">/</span> ` +
|
||||
`<span class="text-gray-400">avg </span><span class="${latencyClass(avg, "online")}">${avg}ms</span>` +
|
||||
` <span class="text-gray-500">/</span> ` +
|
||||
`<span class="text-gray-400">max </span><span class="${latencyClass(max, "online")}">${max}ms</span>`;
|
||||
statusEl.className =
|
||||
"status-text text-xs whitespace-nowrap text-right col-span-2 mt-5";
|
||||
statusEl.innerHTML = statusStatsHTML([
|
||||
["min", min],
|
||||
["med", med],
|
||||
["avg", avg],
|
||||
["max", max],
|
||||
]);
|
||||
statusEl.className = STATUS_TEXT_CLASS;
|
||||
} else if (host.status === "offline") {
|
||||
statusEl.textContent = "unreachable";
|
||||
statusEl.className =
|
||||
"status-text text-xs text-red-400 whitespace-nowrap text-right col-span-2 mt-5";
|
||||
statusEl.className = `${STATUS_TEXT_CLASS} text-red-400`;
|
||||
} else if (host.status === "error") {
|
||||
statusEl.textContent = "timeout";
|
||||
statusEl.className =
|
||||
"status-text text-xs text-orange-400 whitespace-nowrap text-right col-span-2 mt-5";
|
||||
statusEl.className = `${STATUS_TEXT_CLASS} text-orange-400`;
|
||||
} else {
|
||||
statusEl.textContent = "connecting...";
|
||||
statusEl.className =
|
||||
"status-text text-xs text-gray-500 whitespace-nowrap text-right col-span-2 mt-5";
|
||||
statusEl.className = `${STATUS_TEXT_CLASS} text-gray-500`;
|
||||
}
|
||||
|
||||
SparklineRenderer.draw(canvas, host.history);
|
||||
@@ -1044,8 +1064,7 @@ function greyOutUI(state) {
|
||||
}
|
||||
if (statusEl) {
|
||||
statusEl.textContent = "paused";
|
||||
statusEl.className =
|
||||
"status-text text-xs text-gray-500 whitespace-nowrap text-right col-span-2 mt-5";
|
||||
statusEl.className = `${STATUS_TEXT_CLASS} text-gray-500`;
|
||||
}
|
||||
// Grey out the status dot
|
||||
const row = document.querySelector(`.host-row[data-index="${i}"]`);
|
||||
|
||||
@@ -14,6 +14,38 @@ body {
|
||||
ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
/* ---- Minimum tap targets ----------------------------------------------
|
||||
Every interactive control is at least 44x44 CSS px (Apple HIG, WCAG 2.2
|
||||
SC 2.5.5). Not scoped to a breakpoint or to `pointer: coarse`: a large
|
||||
phone in landscape is above the 768px breakpoint and still a touch
|
||||
device. */
|
||||
|
||||
/* The button grows to 44x44 while the negative margins keep its layout
|
||||
footprint at the 16x16 of the icon inside it, so row height and the
|
||||
icon's position are unchanged. */
|
||||
.pin-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
margin: -0.875rem;
|
||||
}
|
||||
|
||||
/* A select paints its own background and border, so it has to actually be
|
||||
44 tall rather than borrow the trick above. */
|
||||
#interval-select {
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
|
||||
/* The tappable target for #debug-toggle is the label wrapping it. */
|
||||
.debug-toggle-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
|
||||
.sparkline-container {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
@@ -38,9 +70,10 @@ body {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* Pause button: smaller on mobile */
|
||||
/* Pause button: smaller on mobile, but not below the tap-target floor */
|
||||
#pause-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
|
||||
#pause-btn svg {
|
||||
|
||||
Reference in New Issue
Block a user