From e0e2562f0a09aae4eaf5a2a4f49b4ce7a501431d Mon Sep 17 00:00:00 2001 From: Mitchell-Johnson Date: Sat, 3 Oct 2026 11:28:44 -0700 Subject: [PATCH] Bug bash 6: the phone fits ZFS, Usage and journal lines; a snapshot's default name is local time; twelve smaller faults Found by a headless pass over every page on every machine at desktop and phone widths, then every interaction on the hub. BUGS.md has the list, what was checked and found fine, and the quality-of-life notes to discuss. Co-Authored-By: Claude Fable 5.1 --- BUGS.md | 120 +++++++++++++++++++++++++++++++++++++++++++ web/src/Files.tsx | 6 +-- web/src/Hardware.tsx | 2 +- web/src/Machines.tsx | 5 +- web/src/Services.tsx | 15 ++++-- web/src/Settings.tsx | 2 +- web/src/Shell.tsx | 3 +- web/src/Updates.tsx | 8 ++- web/src/ZFS.tsx | 7 ++- web/src/styles.css | 6 +++ 10 files changed, 160 insertions(+), 14 deletions(-) diff --git a/BUGS.md b/BUGS.md index 0c30e8e..7e775c6 100644 --- a/BUGS.md +++ b/BUGS.md @@ -608,3 +608,123 @@ swallows a failed WebSocket where Firefox writes it to the console. One thing it hands logs "can't establish a connection". Sitting still on a page produces none, the address is right, and the terminal belongs to the machine asked; `sockets.cjs` checks that ownership rather than the noise. + +# Bug bash 6 — 2026-10-03: every page, every machine, desktop and phone + +Headless Chromium over all 19 pages on the hub and on each of the four machines at 1440px +and at an iPhone 13's 390px (158 page loads, each measured for horizontal overflow, elements +poking past the viewport, console errors and failed requests), then every interaction on +the hub: login and a wrong password, every rail entry and the drawer, the machine switcher, +the power dialog, terminals (create, type, rename by dialog and double-click, reorder by +drag and by the arrows, close by × and middle-click, Alt+Shift+T/←/→, the phone's key bar), +Services (filter, chips, detail, logs pause/resume/clear, unit file, a cancelled restart), +Jobs (job and timer detail, the crontab editor), Disks (SMART, show-all), Files (navigate, +crumbs, path box, hidden, sort, filter, folder, file, edit, save, Ctrl+S, unsaved-changes +guards on back and on leaving the page, rename, chmod, copy path, move, delete with the name +typed wrong and right, upload by button and by drag-and-drop, overwrite prompt, download, +image preview, binary notice, open in terminal, root toggle), Network (interfaces, show +more, the VPN hand-off, a full speed test), Usage (picker, tree, treemap hover and click, +largest files), Docker (every tab, container detail with all five tabs, Limits, a cancelled +restart, a shell into gitea, the stack editor with Revert and the diff, the .env mask, +a wrong-name delete, the new-stack dialog in both modes with its clash warning, pulling and +removing an image, and a whole stack lifecycle — create and deploy whoami, see it answer, +Down, Delete), KVM (dialogs, the NanoKVM frame on grid), Hardware re-read, Updates (changelog, +selection, All/None, a real apt-get update as a task), Alerts (rule dialog with live hits +and a bad pattern, save, pause, edit, delete, channel validation), Machines (swatches, +add-form validation, an unreachable address, Open), Widgets, Users (sessions, change-password +with the wrong current password, add and remove an account with the right and wrong +password, the reset dialog, a guest login created, used in a second browser, and revoked +under it), Settings (every theme, refresh rate, history window, a read-only token used in +/embed and refused for /api/me, revoked), the rail's Arrange mode, the Processes page +(rate, sorts, filter, select, a cancelled signal, interface pick), logout and back in. Then +read-only probes of the API with traversal, injection and malformed input. Firefox could +not run here (the box lacks its GTK libraries). + +## Bugs — all ✅ fixed and verified by the script that found them + +1. ✅ **The ZFS tab was wider than a phone.** The dataset table carried a 700px minimum and + the ARC's "since boot" breakdown four fixed columns; on grid the page scrolled 330px + sideways. Both drop to three columns and the ellipsis below 800px. +2. ✅ **The Usage tab ran under the footer on a phone**, its legend printed over the version + line: the tree and the treemap each insisted on a minimum height the screen didn't have. + Below 800px the page scrolls, the tree 40vh and the map 60vh. +3. ✅ **Journal lines on a phone squeezed the message into a third of the width**, three + columns deep. Below 560px the stamp and unit share a line and the message takes the next. +4. ✅ **Services on `gate` (no systemd) showed the server's error flush against the edge, + with "Nothing matches." under it** as if a filter had hidden them. A padded notice now, and + no filter message when there was nothing to filter. +5. ✅ **ZFS "Snapshot now" offered a name seven hours wrong.** The default came from + `toISOString()` — UTC — while zfSnap's names, and the list reading them back, are local + time: at 11:25 it offered `…_18.25.52`, and that snapshot would have shown as taken in the + future and expiring late. Built from local fields now. +6. ✅ **"Adopt as a schedule" always ticked Recursive** — `/\s-\w*r/.test(cmd) || true`. +7. ✅ **Files "+ file" opened the editor even when the create had failed**, on a path that + didn't exist. It opens only after the create succeeds. +8. ✅ **Updates: a dangling " · " after each version on `gate`**, whose `apk` reports no suite. +9. ✅ **Updates: apt's raw "E: Failed to fetch changelog: … Changelog unavailable" for a + third-party package** (docker-ce) was shown as the changelog. Said plainly now. +10. ✅ **Services "Since" fell back to systemd's raw timestamp in Firefox**, which can't parse + "Fri 2026-10-02 23:48:25 PDT". The fields are read directly now. +11. ✅ **Hardware on `gate` led with a separator** — "· 4 threads · up to 2.70 GHz" — when the + core count is unknown. The parts are joined, not concatenated. +12. ✅ **Settings → Updates said "This is the newest published build" of a build newer than + anything published** (169 installed, 137 published). It says so instead. +13. ✅ **Escape did not close the phone's drawer**, alone among the overlays. +14. ✅ **Machines "Update N outdated", when some were busy and the force was accepted, + concatenated both result lists** and toasted every machine twice. + +## Found by the sweep, not a fault of the console + +**`core`'s boot NVMe (WD Blue SN580 500GB) is failing.** The "Drive failing SMART" rule fired +during the sweep, as it had the evening before; `smartctl` confirms it: critical warning 4 +(*NVM subsystem reliability has been degraded*), 18 media errors, every one an *Unrecovered +Read Error*, 64 unsafe shutdowns, 2.5 years powered on. The console did its job; the drive is +a `hamlet` matter (`docs/pending.md`). + +## Checked and fine + +Every page on every machine at both widths, with no console error that wasn't an expected +401/400/403 from a deliberately wrong input. Login lockout messages, the password eye. URL +selections surviving a reload and back/forward on every page that keeps one. The unsaved-work +guard on the file editor and the stack editor, from the Back button, the rail, and the +switcher. Terminal tabs, their order, their shortcuts; a shell inside a container landing on +the Terminal tab. The whole stack lifecycle, image pull and prune, container Limits. A guest +login kicked out at the moment of revocation. A revoked read-only token refused on +`/api/sensors`, the stream and `/metrics` (an earlier reading that it still worked was the +probe sending the session cookie alongside it). Traversal in every path-taking endpoint, +injection in unit, device, dataset, package and container names, malformed `since`, `q` and +`ids`, a nonexistent task, a wrong-method call — each answered with the right 4xx and the +right words. No session, no cookie: 401 on every API, locally and through NPM. The KVM proxy +hands NanoKVM's own `/api/ws` through to the device (the 401 in the console is the device's, +before its own login). + +## Quality of life — to discuss, not built + +1. **Treemap blank space.** Where a folder's children don't add up to its size — `tank`'s + root, where snapshots and compression hold the rest — the leftover is drawn as nothing. + An "unaccounted" tile would say what it is. +2. **Sessions pile up.** Every script, curl and headless browser that logs in leaves a row; + the hub had thirteen. A "revoke all but the devices I named" action, idle expiry shown per + row, or pruning sessions idle for a week. +3. **The Mac's GPU box takes half the Processes page** to say Asahi exposes no counters; a + GPU with nothing measurable could be one line. +4. **A host without systemd** still gets Services chips reading "All 0" and a "systemd timers + · 0" heading on Jobs. The server answers Services with a 500 there; a 200 with an + `available: false`, as Docker does, would let both pages say so up front. +5. **An alert whose sensor vanishes resolves silently.** If `smartctl` errors out, the + `smart.*.passed` sensor is omitted and a firing "Drive failing SMART" clears. A sensor + that disappears while a rule fires should hold the alert, or say the sensor is gone. +6. **Network "Past tests" rows are clipped** on desktop ("↓ 302 · ↑ 300 Mbps · 7.5 …") by the + four-column props grid. +7. **Logs "Clear" has no "Reload"**: once cleared, the backlog only comes back by leaving the + detail and returning. +8. **NanoKVM asks for its own login every time** inside the frame. Ham Lab could remember the + device's cookie per browser, or the hub could hold its credentials — a trade worth stating. +9. **Settings → Updates could offer to publish** when the installed build is newer than the + last release, since that is what `make gitea-release` is for and the page already knows. +10. **The compose editor is a plain textarea**: no line numbers, no YAML highlighting, and a + phone keyboard's autocorrect is off but its indentation is a fight. +11. **The hub's `/api/nodes//../me` answers a 307** from Go's path cleaning rather than a + 404 — harmless, but a stray. +12. **Guest logins can open Users and Settings** — by design ("a guest can do everything you + can"), but worth a second look now that Users includes removing accounts. diff --git a/web/src/Files.tsx b/web/src/Files.tsx index b7bacf4..197cc52 100644 --- a/web/src/Files.tsx +++ b/web/src/Files.tsx @@ -147,13 +147,13 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s }) // ---- actions ---- - const act = async (label: string, fn: () => Promise) => { - try { await fn(); toast(label); await load() } catch (e) { toast(`${label} — ${e instanceof Error ? e.message : 'failed'}`, 'err') } + const act = async (label: string, fn: () => Promise): Promise => { + try { await fn(); toast(label); await load(); return true } catch (e) { toast(`${label} — ${e instanceof Error ? e.message : 'failed'}`, 'err'); return false } } // A name is one path segment. ("../x" and "a/b" would quietly act somewhere else.) const okName = (n?: string | null): n is string => { const t = n?.trim(); if (!t) return false; if (t.includes('/') || t === '.' || t === '..') { toast('a name can\'t contain / or be . or .. — use Move… to put it elsewhere', 'err'); return false } return true } const mkdir = async () => { const n = await askText('New folder name'); if (okName(n)) act(`created ${n.trim()}`, () => api.fileMkdir(join(path, n.trim()), sudo)) } - const touch = async () => { const n = await askText('New file name'); if (okName(n)) act(`created ${n.trim()}`, () => api.fileTouch(join(path, n.trim()), sudo)).then(() => setOpen(join(path, n.trim()))) } + const touch = async () => { const n = await askText('New file name'); if (okName(n) && await act(`created ${n.trim()}`, () => api.fileTouch(join(path, n.trim()), sudo))) setOpen(join(path, n.trim())) } // opened only once it exists const rename = async (e: FileEntry) => { const n = await askText('Rename to', e.name); if (okName(n) && n.trim() !== e.name) act(`renamed to ${n.trim()}`, () => api.fileRename(join(path, e.name), join(path, n.trim()), sudo)) } const move = async (e: FileEntry) => { const n = await askText('Move to (absolute path)', join(path, e.name)); if (n?.trim() && n.trim() !== join(path, e.name)) act(`moved to ${n.trim()}`, () => api.fileRename(join(path, e.name), n.trim(), sudo)) } const chmod = async (e: FileEntry) => { const n = await askText(`Permissions for ${e.name} (octal)`, e.perm); if (n?.trim() && n.trim() !== e.perm) act(`chmod ${n.trim()} ${e.name}`, () => api.fileChmod(join(path, e.name), n.trim(), sudo)) } diff --git a/web/src/Hardware.tsx b/web/src/Hardware.tsx index 8ca9b76..beb003f 100644 --- a/web/src/Hardware.tsx +++ b/web/src/Hardware.tsx @@ -172,7 +172,7 @@ export default function Hardware() {
Processor
20 ? { fontSize: 16 } : undefined}>{cpu.model}
-
{cores ? `${cores} core${cores === 1 ? '' : 's'}` : ''}{threads && threads !== cores ? ` · ${threads} threads` : ''}{cpu.max_mhz ? ` · up to ${ghz(cpu.max_mhz)}` : ''}
+
{[cores ? `${cores} core${cores === 1 ? '' : 's'}` : '', threads && threads !== cores ? `${threads} threads` : '', cpu.max_mhz ? `up to ${ghz(cpu.max_mhz)}` : ''].filter(Boolean).join(' · ')}
Memory
diff --git a/web/src/Machines.tsx b/web/src/Machines.tsx index 7083c94..c11dee6 100644 --- a/web/src/Machines.tsx +++ b/web/src/Machines.tsx @@ -54,7 +54,10 @@ export default function Machines({ hub, onChanged, onOpen }: { hub: Me; onChange try { let res = await api.updateAllMachines() const busy = res.filter((r) => !r.ok && (r.error ?? '').startsWith('busy:')) - if (busy.length && await askYes(`${busy.map((r) => r.name).join(', ')}: ${busy[0].error?.slice(6)}\n\nForce those too?`)) res = res.map((r) => r).concat(await api.updateAllMachines(true)) + if (busy.length && await askYes(`${busy.map((r) => r.name).join(', ')}: ${busy[0].error?.slice(6)}\n\nForce those too?`)) { + const forced = await api.updateAllMachines(true) + res = res.filter((r) => !forced.some((f) => f.name === r.name)).concat(forced) // one line per machine, the forced answer winning + } for (const r of res) toast(r.ok ? `${r.name} → ${r.version}` : `${r.name}: ${r.error}`, r.ok ? 'ok' : 'err') setTimeout(load, 4000) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } diff --git a/web/src/Services.tsx b/web/src/Services.tsx index 640c01a..d0e097b 100644 --- a/web/src/Services.tsx +++ b/web/src/Services.tsx @@ -16,9 +16,16 @@ function fmtCPU(ns: number): string { const s = ns / 1e9 return s >= 3600 ? `${(s / 3600).toFixed(1)} h` : s >= 60 ? `${(s / 60).toFixed(1)} min` : `${s.toFixed(1)} s` } +// systemd writes "Fri 2026-10-02 23:48:25 PDT", which Chromium parses and Firefox does not; read +// the fields directly, in the browser's own zone (the machine's, on a LAN), and fall back to Date.parse. +function parseSystemdTime(ts: string): number { + const m = /(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})/.exec(ts) + if (m) return new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]).getTime() + return Date.parse(ts.replace(/^[A-Za-z]{3} /, '')) +} function since(ts?: string): string { if (!ts) return '' - const t = Date.parse(ts.replace(/^[A-Za-z]{3} /, '')) + const t = parseSystemdTime(ts) if (isNaN(t)) return ts const s = Math.max(0, (Date.now() - t) / 1000) const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60) @@ -217,7 +224,9 @@ export default function Services() { ))}
- {err &&
{err}
} + {err && units.length === 0 && loaded + ?
{err}
// a host with no systemd, or one that is unreachable: not a filter result + : err &&
{err}
}
{!loaded && } {shown.map((u) => ( @@ -230,7 +239,7 @@ export default function Services() { {u.failed ? 'failed' : u.sub}{u.enabled === 'enabled' ? '' : u.enabled ? ` · ${u.enabled}` : ''}
))} - {loaded && shown.length === 0 &&
Nothing matches.
} + {loaded && shown.length === 0 && !(err && units.length === 0) &&
Nothing matches.
} {sel ? setSel(null)} /> :
Select a service.
} diff --git a/web/src/Settings.tsx b/web/src/Settings.tsx index 7253442..688e9cb 100644 --- a/web/src/Settings.tsx +++ b/web/src/Settings.tsx @@ -198,7 +198,7 @@ function Updates({ me }: { me: Me }) { {st.notes &&
{st.notes.trim()}
} )} - {st && !st.behind && st.latest &&
This is the newest published build.
} + {st && !st.behind && st.latest &&
{st.current.replace(/^v/, '') === st.latest.replace(/^v/, '') ? 'This is the newest published build.' : `This build is newer than the last published release (${st.latest}).`}
} {(err || st?.error) &&
{err || st?.error}
}
Check for updates diff --git a/web/src/Shell.tsx b/web/src/Shell.tsx index 096aa16..82915d5 100644 --- a/web/src/Shell.tsx +++ b/web/src/Shell.tsx @@ -27,7 +27,7 @@ import { MachineStatus, setMachine } from './api' import { LIST_FLOOR, useRefreshMs } from './prefs' import Power from './Power' import { applyAccent } from './accents' -import { Btn, PageBoundary, Toasts, askText, askYes, hasUnsaved, okToLeave, toast } from './ui' +import { Btn, PageBoundary, Toasts, askText, askYes, hasUnsaved, okToLeave, toast, useEscape } from './ui' import { fmtLeft, useTimeLeft } from './guest' // Inline outline icon so it matches the monochrome glyphs in the rail (emoji would be full colour). @@ -135,6 +135,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut: const [active, setActive] = useState(null) const [view, setView] = useState(() => parseHash()?.view ?? 'procs') const [drawer, setDrawer] = useState(false) + useEscape(() => setDrawer(false), drawer) // the phone's drawer closes like every other overlay const [online, setOnline] = useState(true) const [creating, setCreating] = useState(false) const handles = useRef>({}) diff --git a/web/src/Updates.tsx b/web/src/Updates.tsx index 7de6778..9a5cf99 100644 --- a/web/src/Updates.tsx +++ b/web/src/Updates.tsx @@ -52,7 +52,11 @@ export default function Updates() { setOpen(p.name) if (!changelog[p.name]) { try { const r = await api.changelog(p.name); setChangelog((c) => ({ ...c, [p.name]: r.text || '(empty)' })) } - catch (e) { setChangelog((c) => ({ ...c, [p.name]: e instanceof Error ? e.message : 'unavailable' })) } + catch (e) { + const m = e instanceof Error ? e.message : 'unavailable' + // apt's own words for a third-party repository that publishes none are a wall of "E: Failed to fetch" + setChangelog((c) => ({ ...c, [p.name]: /changelog unavailable|failed to fetch/i.test(m) ? 'No changelog is published for this package — its repository does not provide one.' : m })) + } } } const toggle = (n: string) => setSel((s) => { const x = new Set(s); if (x.has(n)) x.delete(n); else x.add(n); return x }) @@ -99,7 +103,7 @@ export default function Updates() { {p.name}{p.security && security} {p.description} - {p.current} → {p.candidate} · {p.suite} + {p.current} → {p.candidate}{p.suite && · {p.suite}}
{open === p.name &&
{changelog[p.name] ??  fetching changelog…}
} diff --git a/web/src/ZFS.tsx b/web/src/ZFS.tsx index 9578e24..b1efe1b 100644 --- a/web/src/ZFS.tsx +++ b/web/src/ZFS.tsx @@ -97,7 +97,10 @@ function DatasetRow({ d, onChanged }: { d: ZFSInfo['datasets'][number]; onChange if (!open && !snaps) await reload() } async function snapshot() { - const def = new Date().toISOString().slice(0, 19).replace('T', '_').replace(/:/g, '.') + // zfSnap's name is the machine's local time, and snapMeta reads it back as local time — so the + // default is built from local fields, not toISOString(), which is UTC and read seven hours wrong. + const n = new Date(), two = (x: number) => String(x).padStart(2, '0') + const def = `${n.getFullYear()}-${two(n.getMonth() + 1)}-${two(n.getDate())}_${two(n.getHours())}.${two(n.getMinutes())}.${two(n.getSeconds())}` const name = await askText(`Snapshot name for ${d.name}@`, def) if (!name) return try { await api.zfsSnapshot(d.name, name.trim()); toast(`${d.name}@${name.trim()} created`); await reload(); onChanged() } @@ -299,7 +302,7 @@ function adopt(x: ExternalJob, fallbackDataset: string): Partial { const ds = /\b(tank[\w/-]*)\b/.exec(cmd)?.[1] ?? fallbackDataset return { ...BLANK, name: `${x.tool} (adopted)`, dataset: ds, - recursive: /\s-\w*r/.test(cmd) || true, + recursive: /\s-[a-zA-Z]*r\b/.test(cmd), // -r, or -r folded into another flag group every: simple ? 'daily' : 'custom', at: simple ? `${String(hour).padStart(2, '0')}:${String(min).padStart(2, '0')}` : '02:30', cron: simple ? '' : x.schedule.trim(), diff --git a/web/src/styles.css b/web/src/styles.css index ba92d5d..0d78333 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -27,6 +27,7 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi .login button.pweye:hover, .login button.pweye[aria-pressed="true"] { color: var(--fg); } .login button.pweye:disabled { opacity: .4; } .error { color: var(--bad); font-size: 13px; } +.svc-list > .error, .cron-list > .error { margin: 8px 12px; } /* layout */ .app { height: 100%; display: flex; flex-direction: column; padding-top: env(safe-area-inset-top); } @@ -204,6 +205,8 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi .log-lines { flex: 1; overflow: auto; padding: 6px 8px; font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; } .log { display: flex; gap: 8px; white-space: pre-wrap; word-break: break-word; } .log .lt { color: var(--muted); flex-shrink: 0; } .log .li { color: var(--accent2); flex-shrink: 0; } +/* a phone has no room for three columns: the stamp and the unit go on one line, the message underneath */ +@media (max-width: 560px) { .log { flex-wrap: wrap; gap: 0 8px; padding: 2px 0; border-bottom: 1px solid var(--border); } .log .lm { flex-basis: 100%; } .log .lt { font-size: 11px; } } .log.err .lm { color: var(--bad); } .log.warn .lm { color: var(--s2); } .log.debug .lm { color: var(--muted); } @media (max-width: 800px) { .svc-list { width: 100%; border-right: 0; } .has-sel .svc-list { display: none; } .svc-empty { display: none; } @@ -315,6 +318,7 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted); .ds-row { display: grid; grid-template-columns: minmax(160px, 1fr) 80px 90px 90px 90px minmax(120px, 1fr); gap: 8px; align-items: center; padding: 6px 12px; border-bottom: 1px solid var(--border); font-size: 13px; min-width: 700px; } .ds-row.head { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .5px; } .ds-row .num { text-align: right; } +@media (max-width: 800px) { .ds-row { grid-template-columns: minmax(110px, 1fr) 58px 80px; min-width: 0; gap: 6px; padding: 6px 8px; } .ds-row > :nth-child(3), .ds-row > :nth-child(5), .ds-row > :nth-child(6) { display: none; } .ds-row .ds-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } } .ds-name { cursor: pointer; display: flex; align-items: center; gap: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .ds-mount { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, monospace; font-size: 12px; } .ds-snaps { padding: 8px 12px 10px 40px; border-bottom: 1px solid var(--border); background: var(--bg); display: flex; flex-direction: column; gap: 6px; } @@ -769,6 +773,7 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%, @media (max-width: 640px) { .arc-sparks { grid-template-columns: 1fr; } } .arc-break { display: grid; grid-template-columns: minmax(140px, 1fr) 80px 110px 110px; gap: 2px 10px; font-size: 12px; margin-top: 10px; } .arc-break .head { color: var(--muted); } .arc-break .num { text-align: right; } +@media (max-width: 640px) { .arc-break { grid-template-columns: minmax(0, 1fr) auto auto auto; gap: 2px 8px; font-size: 11px; } .arc-break > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } } .arc-tiers { display: flex; flex-direction: column; gap: 5px; font-size: 13px; margin-top: 4px; } .arc-tier { display: flex; gap: 8px; min-width: 0; } .arc-tier .k { color: var(--muted); width: 80px; flex-shrink: 0; cursor: help; } .arc-tier-v { display: flex; flex-direction: column; gap: 1px; min-width: 0; } .arc-tier-v > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } @@ -795,6 +800,7 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%, .space .warn { color: var(--s2); } .space-tabs { padding: 8px 12px 0; } .space-split { flex: 1; display: flex; flex-direction: column; min-height: 0; padding: 8px 12px 12px; gap: 8px; } +@media (max-width: 800px) { .space-split { overflow-y: auto; padding: 8px; } .space-tree { flex: 0 0 auto; height: 40vh; } .space-map { flex: 0 0 auto; height: 60vh; } } .space-tree { flex: 1 1 55%; min-height: 120px; display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: 6px; background: var(--bg2); overflow: hidden; } .space-rows { flex: 1; overflow: auto; min-height: 0; } .space-row { display: grid; grid-template-columns: minmax(160px, 1fr) 84px 120px 70px 64px 88px; gap: 8px; align-items: center; padding: 3px 8px; font-size: 13px; border-bottom: 1px solid var(--border); cursor: pointer; white-space: nowrap; }