Author SHA1 Message Date
sneak 485aa4194a Merge branch 'main' into feature/mobile-detection-message
check / check (push) Successful in 24s
2026-07-25 17:49:06 +07:00
user a279cf8583 feat: add mobile viewport detection with friendly unavailable message
check / check (push) Failing after 3m26s
Detect mobile viewport (window.innerWidth < 768) at startup and show a
centered 'Not yet available on mobile' message instead of the full
monitoring UI. All polling, gateway detection, and network requests are
skipped entirely on mobile viewports.

Desktop behavior is completely unchanged — the mobile check is the very
first thing in init() and returns early before any other setup runs.
2026-03-16 21:18:55 -07:00
13 changed files with 184 additions and 315 deletions
+1 -24
View File
@@ -1,27 +1,4 @@
# OS
.DS_Store
Thumbs.db
# Editors
*.swp
*.swo
*~
*.bak
.idea/
.vscode/
*.sublime-*
# Node
node_modules/
# Environment / secrets
.env
.env.*
*.pem
*.key
# Build output
dist/
# Logs
.DS_Store
*.log
+2 -5
View File
@@ -3,12 +3,9 @@ FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e3
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
RUN apk add --no-cache git make
RUN apk add --no-cache git
COPY . .
# make check runs script/check (test + lint + fmt-check); its test step
# is the production yarn build, so this both produces dist/ and gates the
# image on lint/fmt-check/test regressions, not merely a broken build.
RUN make check
RUN yarn build
# nginx:stable-alpine as of 2026-02-22
FROM nginx@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab
+8 -33
View File
@@ -1,50 +1,25 @@
# Lint stage — fast feedback on formatting and lint issues. The
# golangci/golangci-lint image ships Go, gofmt, make and the linter, so
# nothing is installed here.
# golangci/golangci-lint:v2.7.2 (2026-08-09)
FROM golangci/golangci-lint@sha256:5d6d5c70a61f1356adfd9dd6316ce286799fefc9d743421356ff1b00842368ba AS lint
WORKDIR /src
COPY backend/go.mod backend/go.sum ./
RUN go mod download
COPY backend/ .
RUN make fmt-check
RUN make lint
# Build stage
# golang:1.25-alpine (2026-02-27)
FROM golang:1.25-alpine@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder
RUN apk add --no-cache make
RUN apk add --no-cache git make gcc musl-dev
WORKDIR /src
# Force BuildKit to run the lint stage before proceeding. BuildKit runs
# stages in parallel by default; without this no-op copy a lint failure
# would not gate compilation.
COPY --from=lint /src/go.sum /dev/null
# golangci-lint v2.7.2 (2026-02-27)
RUN CGO_ENABLED=0 go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@9f61b0f53f80672872fced07b6874397c3ed197b
WORKDIR /repo/backend
COPY backend/go.mod backend/go.sum ./
RUN go mod download
COPY .git /repo/.git
COPY backend/ .
RUN make test
RUN make check
RUN make build
# The build is driven through the Makefile so there is exactly one
# definition of the build command; it expands to
# CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.Version=... -X main.Buildarch=..."
# VERSION is handed over in the environment rather than as a make
# variable so it still reaches the build if the target is ever turned
# into a shim around a script.
ARG VERSION=dev
RUN VERSION="${VERSION}" make build
# Runtime stage
# alpine:3.23 (2026-02-27)
FROM alpine:3.23@sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f2198c3f659
RUN apk add --no-cache ca-certificates
COPY --from=builder /src/netwatch-server /usr/local/bin/netwatch-server
COPY --from=builder /repo/backend/netwatch-server /usr/local/bin/netwatch-server
EXPOSE 8080
ENTRYPOINT ["netwatch-server"]
+5
View File
@@ -125,6 +125,9 @@ dist/
false outage)
- Clickable service URLs
- Canvas-based sparkline rendering with devicePixelRatio scaling
- Mobile detection: viewports narrower than 768px show a friendly "not yet
available on mobile" message instead of the monitoring UI (no polling or
network requests on mobile)
- Zero runtime dependencies: all resources bundled into build artifacts
## Deployment
@@ -148,6 +151,8 @@ properties.
## Limitations
- **Mobile**: Viewports below 768px wide show a static "not yet available"
message. The full monitoring UI requires a desktop-width browser.
- **CORS**: Some hosts may block cross-origin HEAD requests. The app uses
`no-cors` mode which allows the request but provides opaque responses. Latency
is still measurable based on request timing.
-26
View File
@@ -22,32 +22,6 @@ files, so merging it also closes most compliance gaps.
# Completed Steps
- 2026-09-21: shutdown lifecycle correctness. The process now shuts down through
fx instead of `os.Exit`, so every component's `OnStop` runs and buffered
reports are flushed to disk on `SIGTERM` — previously a full flush window of
telemetry was silently lost on every restart. The `http.Server` is now built
before its serving goroutine starts, so shutdown can no longer race or
nil-deref it; a listen failure exits non-zero via `fx.Shutdowner`; `reportbuf`
`OnStop` is idempotent; and `writeTimeout` now exceeds the chi per-request
budget so that budget is actually reachable. Dead `startupTime`, `exitCode`,
and `cancelFunc` fields were removed
- 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,
`CGO_ENABLED=0` static build driven by `ARG VERSION`, and no more `COPY .git`
- 2026-08-09: dotfile compliance — lifted `backend/.editorconfig` to the repo
root so `root = true` covers the frontend too, and replaced `.gitignore` with
the org model (OS, editor, node, and environment/secrets sections) plus this
repo's `dist/` and `*.log`. `.env`, `.env.*`, `*.pem`, and `*.key` are now
ignored repo-wide, not just under `backend/`. Excluding `.git` from
`.dockerignore` stays deferred: both images read git metadata at build time
(`COPY .git` in `Dockerfile.backend`, `git rev-parse` in `vite.config.js`)
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile
shims, README Entrypoints section
- 2026-02-27: backend with buffered zstd-compressed report storage; CI workflow
+12 -9
View File
@@ -1,22 +1,25 @@
# VERSION is overridable (the Dockerfile passes its ARG VERSION in) and
# degrades to "dev" when git is unavailable or there is no .git — the
# build must not depend on the repository history being in the build
# context.
VERSION ?= $(shell { git describe --always --dirty; } 2>/dev/null || echo dev)
UNAME_S := $(shell uname -s)
VERSION := $(shell git describe --always --dirty)
BUILDARCH := $(shell uname -m)
BINARY := netwatch-server
GOLDFLAGS += -s -w
GOLDFLAGS += -X main.Version=$(VERSION)
GOLDFLAGS += -X main.Buildarch=$(BUILDARCH)
ifeq ($(UNAME_S),Darwin)
GOFLAGS := -ldflags "$(GOLDFLAGS)"
else
GOFLAGS = -ldflags "-linkmode external -extldflags -static $(GOLDFLAGS)"
endif
.PHONY: all build test lint fmt fmt-check check docker hooks run clean
all: build
build:
CGO_ENABLED=0 go build -trimpath -ldflags "$(GOLDFLAGS)" \
-o ./$(BINARY) ./cmd/netwatch-server/
build: ./$(BINARY)
./$(BINARY): $(shell find . -name '*.go' -type f) go.mod go.sum
go build -o $@ $(GOFLAGS) ./cmd/netwatch-server/
test:
timeout 30 go test ./...
-6
View File
@@ -45,7 +45,6 @@ type Buffer struct {
done chan struct{}
log *slog.Logger
mu sync.Mutex
stopOnce sync.Once
}
// New creates a Buffer and registers lifecycle hooks to
@@ -77,13 +76,8 @@ func New(
return nil
},
OnStop: func(_ context.Context) error {
// stopOnce makes OnStop idempotent: a second
// invocation must not close an already-closed channel
// (which would panic) or flush again.
b.stopOnce.Do(func() {
close(b.done)
b.flushLocked()
})
return nil
},
+5 -69
View File
@@ -1,77 +1,13 @@
package reportbuf_test
import (
"os"
"strings"
"testing"
"sneak.berlin/go/netwatch/internal/config"
"sneak.berlin/go/netwatch/internal/globals"
"sneak.berlin/go/netwatch/internal/logger"
"sneak.berlin/go/netwatch/internal/reportbuf"
"go.uber.org/fx"
"go.uber.org/fx/fxtest"
_ "sneak.berlin/go/netwatch/internal/reportbuf"
)
// TestFlushOnShutdown proves the flush-on-shutdown path: a
// report appended after start but before the periodic flush
// window must reach disk when the fx lifecycle stops. This is
// the exact case that silent data loss on restart used to
// destroy.
func TestFlushOnShutdown(t *testing.T) {
dir := t.TempDir()
t.Setenv("DATA_DIR", dir)
var buf *reportbuf.Buffer
app := fxtest.New(t,
fx.Provide(
globals.New,
logger.New,
config.New,
reportbuf.New,
),
fx.Populate(&buf),
)
app.RequireStart()
err := buf.Append(map[string]string{"probe": "shutdown"})
if err != nil {
t.Fatalf("append report: %v", err)
}
// RequireStop runs the reportbuf OnStop hook, which is the
// only code path that flushes buffered reports on shutdown.
app.RequireStop()
if !hasReportFile(t, dir) {
t.Fatal("no report file on disk after shutdown; " +
"the buffered report was lost")
}
}
func hasReportFile(t *testing.T, dir string) bool {
t.Helper()
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("read data dir: %v", err)
}
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".jsonl.zst") {
info, statErr := e.Info()
if statErr != nil {
t.Fatalf("stat %s: %v", e.Name(), statErr)
}
if info.Size() > 0 {
return true
}
}
}
return false
func TestImport(t *testing.T) {
t.Parallel()
// Compilation check — verifies the package parses
// and all imports resolve.
}
+10 -29
View File
@@ -5,58 +5,39 @@ import (
"fmt"
"net/http"
"time"
"go.uber.org/fx"
)
const (
readTimeout = 10 * time.Second
writeTimeout = 10 * time.Second
maxHeaderBytes = 1 << 20 // 1 MiB
// requestTimeout (routes.go) is the single per-request
// processing budget, enforced by chi's middleware.Timeout.
// writeTimeout must exceed that budget so a handler can write
// its 503 when the chi timeout fires; if it were shorter the
// server would abort the write first and the chi budget would
// be unreachable dead configuration.
writeTimeout = requestTimeout + 5*time.Second
)
// newHTTPServer constructs the http.Server. It performs no I/O
// and does not start listening.
func (s *Server) newHTTPServer() *http.Server {
func (s *Server) serveUntilShutdown() {
listenAddr := fmt.Sprintf(":%d", s.params.Config.Port)
return &http.Server{
s.httpServer = &http.Server{
Addr: listenAddr,
Handler: s,
MaxHeaderBytes: maxHeaderBytes,
ReadTimeout: readTimeout,
WriteTimeout: writeTimeout,
}
}
// listenAndServe runs the listener until the server is shut
// down. A genuine listen failure (not the expected
// ErrServerClosed from a clean shutdown) requests process
// shutdown through fx with a non-zero exit code, so the failure
// is visible to any supervisor.
func (s *Server) listenAndServe() {
s.SetupRoutes()
s.log.Info("http begin listen",
"listenaddr", s.httpServer.Addr,
"listenaddr", listenAddr,
"version", s.params.Globals.Version,
"buildarch", s.params.Globals.Buildarch,
)
err := s.httpServer.ListenAndServe()
if err == nil || errors.Is(err, http.ErrServerClosed) {
return
}
if err != nil && !errors.Is(err, http.ErrServerClosed) {
s.log.Error("listen error", "error", err)
shutdownErr := s.shutdowner.Shutdown(fx.ExitCode(1))
if shutdownErr != nil {
s.log.Error("request shutdown failed", "error", shutdownErr)
if s.cancelFunc != nil {
s.cancelFunc()
}
}
}
+71 -27
View File
@@ -1,14 +1,16 @@
// Package server provides the HTTP server lifecycle,
// including startup, routing, and graceful shutdown. The
// process lifetime is owned by fx: shutdown is requested
// through fx.Shutdowner so every component's OnStop hook runs
// in dependency order.
// including startup, routing, signal handling, and graceful
// shutdown.
package server
import (
"context"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"sneak.berlin/go/netwatch/internal/config"
"sneak.berlin/go/netwatch/internal/globals"
@@ -29,18 +31,19 @@ type Params struct {
Handlers *handlers.Handlers
Logger *logger.Logger
Middleware *middleware.Middleware
Shutdowner fx.Shutdowner
}
// Server is the top-level HTTP server orchestrator.
type Server struct {
cancelFunc context.CancelFunc
exitCode int
h *handlers.Handlers
httpServer *http.Server
log *slog.Logger
mw *middleware.Middleware
params Params
router *chi.Mux
shutdowner fx.Shutdowner
startupTime time.Time
}
// New creates a Server and registers lifecycle hooks for
@@ -54,25 +57,23 @@ func New(
s.mw = params.Middleware
s.h = params.Handlers
s.log = params.Logger.Get()
s.shutdowner = params.Shutdowner
lc.Append(fx.Hook{
OnStart: func(_ context.Context) error {
// Build the router and http.Server synchronously
// here, before spawning the serving goroutine, so
// httpServer is fully constructed by the time OnStop
// (or an early signal) can read it. fx guarantees
// OnStart returns before OnStop runs, so no
// synchronization or nil check is needed at shutdown.
s.SetupRoutes()
s.httpServer = s.newHTTPServer()
s.startupTime = time.Now().UTC()
go s.listenAndServe()
go func() { //nolint:contextcheck // fx OnStart ctx is startup-only; run() creates its own
s.run()
}()
return nil
},
OnStop: func(ctx context.Context) error {
return s.shutdown(ctx)
OnStop: func(_ context.Context) error {
if s.cancelFunc != nil {
s.cancelFunc()
}
return nil
},
})
@@ -87,17 +88,60 @@ func (s *Server) ServeHTTP(
s.router.ServeHTTP(w, r)
}
// shutdown gracefully stops the HTTP server within the
// deadline of the context fx provides for OnStop.
func (s *Server) shutdown(ctx context.Context) error {
err := s.httpServer.Shutdown(ctx)
if err != nil {
s.log.Error("server clean shutdown failed", "error", err)
func (s *Server) run() {
exitCode := s.serve()
os.Exit(exitCode)
}
return err
func (s *Server) serve() int {
var ctx context.Context //nolint:wsl // ctx must be declared before multi-assign
ctx, s.cancelFunc = context.WithCancel(
context.Background(),
)
go func() {
c := make(chan os.Signal, 1)
signal.Ignore(syscall.SIGPIPE)
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
sig := <-c
s.log.Info("signal received", "signal", sig)
if s.cancelFunc != nil {
s.cancelFunc()
}
}()
go func() {
s.serveUntilShutdown()
}()
<-ctx.Done()
s.cleanShutdown()
return s.exitCode
}
const shutdownTimeout = 5 * time.Second
func (s *Server) cleanShutdown() {
s.exitCode = 0
ctxShutdown, shutdownCancel := context.WithTimeout(
context.Background(),
shutdownTimeout,
)
defer shutdownCancel()
err := s.httpServer.Shutdown(ctxShutdown)
if err != nil {
s.log.Error(
"server clean shutdown failed",
"error", err,
)
}
s.log.Info("server stopped")
return nil
}
+55 -39
View File
@@ -537,12 +537,6 @@ 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"
@@ -568,7 +562,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_CLASS} text-gray-500" data-host="${index}">waiting...</div>
<div class="status-text text-xs text-gray-500 whitespace-nowrap text-right col-span-2 mt-5" 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>
@@ -677,7 +671,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="debug-toggle-label cursor-pointer">
<label class="cursor-pointer">
<input type="checkbox" id="debug-toggle" class="mr-1">
<span>Debug log</span>
</label>
@@ -694,26 +688,6 @@ 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}"]`,
@@ -738,22 +712,28 @@ function updateHostRow(host, index) {
const min = host.minLatency();
const max = host.maxLatency();
if (host.status === "online" && avg !== null) {
statusEl.innerHTML = statusStatsHTML([
["min", min],
["med", med],
["avg", avg],
["max", max],
]);
statusEl.className = STATUS_TEXT_CLASS;
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";
} else if (host.status === "offline") {
statusEl.textContent = "unreachable";
statusEl.className = `${STATUS_TEXT_CLASS} text-red-400`;
statusEl.className =
"status-text text-xs text-red-400 whitespace-nowrap text-right col-span-2 mt-5";
} else if (host.status === "error") {
statusEl.textContent = "timeout";
statusEl.className = `${STATUS_TEXT_CLASS} text-orange-400`;
statusEl.className =
"status-text text-xs text-orange-400 whitespace-nowrap text-right col-span-2 mt-5";
} else {
statusEl.textContent = "connecting...";
statusEl.className = `${STATUS_TEXT_CLASS} text-gray-500`;
statusEl.className =
"status-text text-xs text-gray-500 whitespace-nowrap text-right col-span-2 mt-5";
}
SparklineRenderer.draw(canvas, host.history);
@@ -1064,7 +1044,8 @@ function greyOutUI(state) {
}
if (statusEl) {
statusEl.textContent = "paused";
statusEl.className = `${STATUS_TEXT_CLASS} text-gray-500`;
statusEl.className =
"status-text text-xs text-gray-500 whitespace-nowrap text-right col-span-2 mt-5";
}
// Grey out the status dot
const row = document.querySelector(`.host-row[data-index="${i}"]`);
@@ -1145,11 +1126,46 @@ function handleResize(state) {
});
}
// --- Mobile Detection --------------------------------------------------------
const MOBILE_BREAKPOINT = 768;
function isMobileViewport() {
return window.innerWidth < MOBILE_BREAKPOINT;
}
function buildMobileUI() {
const app = document.getElementById("app");
app.innerHTML = `
<div class="mx-auto px-[5%] py-8">
<header class="mb-8">
<h1 class="text-3xl font-bold text-white"><a href="https://git.eeqj.de/sneak/netwatch" target="_blank" rel="noopener" class="underline decoration-dashed decoration-gray-500 underline-offset-4">NetWatch</a> by <a href="https://sneak.berlin" target="_blank" rel="noopener" class="text-blue-400 underline hover:text-blue-300">@sneak</a></h1>
<p class="text-gray-400 text-sm mt-2">Real-time network latency monitor</p>
</header>
<div class="flex items-center justify-center min-h-[60vh]">
<div class="bg-gray-800/70 border border-gray-700/50 rounded-lg p-8 max-w-md text-center">
<p class="text-4xl mb-4">📡</p>
<p class="text-xl font-semibold text-white mb-2">Not yet available on mobile</p>
<p class="text-gray-400 text-sm">NetWatch requires a wider viewport to display latency charts and monitoring data. Please visit on a desktop browser.</p>
</div>
</div>
<footer class="mt-8 text-center text-gray-600 text-xs">
<p><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>
</footer>
</div>`;
}
// --- Bootstrap ---------------------------------------------------------------
async function init() {
log.info("NetWatch starting");
if (isMobileViewport()) {
log.info("Mobile viewport detected — skipping monitoring");
buildMobileUI();
return;
}
// Probe common gateway IPs to find the local router
const gateway = await detectGateway();
const localHosts = [LOCAL_CPE];
+1 -34
View File
@@ -14,38 +14,6 @@ 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,
@@ -70,10 +38,9 @@ body {
gap: 0.75rem;
}
/* Pause button: smaller on mobile, but not below the tap-target floor */
/* Pause button: smaller on mobile */
#pause-btn {
padding: 0.5rem 1rem;
min-height: 2.75rem;
}
#pause-btn svg {