diff --git a/DESIGN.md b/DESIGN.md
index 29580b7d..6d570a34 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -102,6 +102,7 @@ measured elements or replace the application tokens with framework defaults.
| Control boundary | `--border-control` | Inputs and controls requiring stronger boundaries |
| Emphasized boundary | `--border-strong` | Rare boundaries requiring emphasis beyond focus or error styling |
| Neutral hover | `--hover` | Available controls under the pointer, compact count surfaces |
+| Shell hover | `--shell-hover` | Hover/expanded header controls on `--shell-canvas` |
| Accent | `--accent` | Selection, focus, navigation links, primary commit action |
| Accent interaction | `--accent-hover` | Hover/active on filled primary actions |
| Selected surface | `--accent-bg` | Selected file or pressed toggle |
@@ -120,7 +121,7 @@ measured elements or replace the application tokens with framework defaults.
- Status always includes text, a sign, an icon, or a shape. Preserve staging's
empty/half/full dot distinction; its half-fill is information, not decoration.
- CPU and RAM in the status bar describe the Diffx server process, not
- the browser or whole machine. Keep that scope explicit in the tooltip.
+ the browser or whole machine. Keep that scope explicit in the status popup.
- Pierre owns syntax colors, changed-line fills, word highlights, line selection,
and code-theme surfaces. App status tokens govern the surrounding UI only.
- Quiet frames use the light canvas/dot tokens or their dark equivalents from
@@ -204,7 +205,9 @@ such detail into a token. New layout spacing must use the scale.
Keep these frames outside Pierre's measured file geometry. At compact widths,
remove the empty navigation island and retain the inset review surface;
modal drawers use the same inset and corners without clipping focus or menus.
-- Header: `--topbar-height` (36 desktop, 44 narrow or coarse pointer). Keep
+- Header: `--topbar-height` (36 desktop, 44 narrow or coarse pointer). The
+ header replaces the shell's top gutter, sitting flush with the window edge
+ directly above the islands, so its controls center between both edges. Keep
desktop controls at 32px, header icons at 16px, the divider at 16px, and group
spacing at 6px so the header recedes into the editor chrome. Toolbar and sidebar
tabs share the compact 36px desktop rhythm (`--control-small` plus two
@@ -220,7 +223,8 @@ such detail into a token. New layout spacing must use the scale.
- Header uses `--shell-canvas`, a quiet brand, and the review identity as its primary
context. Keep the secondary scope heading and muted monospace path inline;
hide the path at 768–1011px and both below 768px. Group ghost Refresh and
- sync actions at the trailing edge; share `--hover` feedback with the trigger.
+ sync actions at the trailing edge; share `--shell-hover` feedback with the
+ trigger, since `--hover` is indistinguishable from the shell canvas.
Theme is the rightmost header action in every layout, with the same ghost
styling, 32px desktop hit area and 16px icon. Keep its outer edge flush with
the shell; never apply the diff scrollbar inset to header actions. Snapshot deletion is absent
@@ -272,7 +276,13 @@ such detail into a token. New layout spacing must use the scale.
Keep the same-stream notice available. Unchanged submissions must not create
unread prompts. Use existing ghost-button typography, icons and accessible
labels; preserve the header's review identity and compact-layout controls.
-- Status bar: `--statusbar-height` (28), with shortcuts shed before server metrics.
+- Status bar: `--statusbar-height` (28), with shortcuts shed before server status.
+ Server status is an icon-only compact ghost trigger at the trailing edge:
+ success check when ready, reduced-motion-safe spinner while connecting, and
+ error alert when metrics are unreachable; its accessible name carries the
+ state. Hover, focus plus Enter, or tap opens a `--surface-raised` popover
+ titled by the state, with a “Diffx server process” section listing CPU and RAM.
+ Add future system details as further sections in the same popover.
- The diff display toolbar uses `--panel`, matching the header and status bar.
- Desktop navigation panel: `--sidebar-width` defaults to 330px. `Navigation.tsx` owns
the shared frame and resize handle for Diff and Repos; `use-sidebar.ts` owns
diff --git a/apps/web/src/DiffWorkspace.tsx b/apps/web/src/DiffWorkspace.tsx
index fa9b7df8..247fcffe 100644
--- a/apps/web/src/DiffWorkspace.tsx
+++ b/apps/web/src/DiffWorkspace.tsx
@@ -39,7 +39,7 @@ import {
placementFor,
type CommentAnnotation,
} from "./review-model.ts";
-import { ServerMetrics } from "./ServerMetrics.tsx";
+import { ServerStatus } from "./ServerStatus.tsx";
import { WorkspaceFeedback } from "./WorkspaceFeedback.tsx";
import { capabilityEnabled, useSession } from "./session-context.tsx";
@@ -840,7 +840,7 @@ export function DiffWorkspace() {
>
)}
-
+
diff --git a/apps/web/src/ServerMetrics.tsx b/apps/web/src/ServerMetrics.tsx
deleted file mode 100644
index fafcb2a5..00000000
--- a/apps/web/src/ServerMetrics.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-import { api, type ApiServerMetrics } from "@diffx/api";
-import { useEffect, useState } from "react";
-import { formatBytes } from "./server-metrics.ts";
-
-const refreshMilliseconds = 2_500;
-
-export function ServerMetrics() {
- const [metrics, setMetrics] = useState(null);
- useEffect(() => {
- let disposed = false;
- let controller = new AbortController();
- async function refresh() {
- controller.abort();
- controller = new AbortController();
- try {
- const { data } = await api.GET("/api/v2/metrics", {
- signal: controller.signal,
- });
- if (!disposed && data) setMetrics(data);
- } catch {}
- }
- void refresh();
- const timer = window.setInterval(() => void refresh(), refreshMilliseconds);
- return () => {
- disposed = true;
- controller.abort();
- window.clearInterval(timer);
- };
- }, []);
-
- const cpu = metrics ? `${metrics.cpuUsage.toFixed(1)}%` : "--%";
- const ram = metrics ? formatBytes(metrics.rssBytes) : "-- MB";
- const title = metrics
- ? `Diffx server process\nResident RAM (RSS): ${ram}\nCPU usage: ${cpu}`
- : "Diffx server process metrics";
- return (
-
-
- CPU
- {cpu}
-
-
- ·
-
-
- RAM
- {ram}
-
-
- );
-}
diff --git a/apps/web/src/ServerStatus.tsx b/apps/web/src/ServerStatus.tsx
new file mode 100644
index 00000000..faad4bed
--- /dev/null
+++ b/apps/web/src/ServerStatus.tsx
@@ -0,0 +1,123 @@
+import { Popover } from "@base-ui/react/popover";
+import { api, type ApiServerMetrics } from "@diffx/api";
+import {
+ CircleAlertIcon,
+ CircleCheckIcon,
+ LoaderCircleIcon,
+} from "lucide-react";
+import { useEffect, useState } from "react";
+import { Button } from "@/components/ui/button";
+import { formatBytes } from "./server-metrics.ts";
+
+const refreshMilliseconds = 2_500;
+
+type ServerHealth = "checking" | "ready" | "unavailable";
+
+const statusLabels: Record = {
+ checking: "Connecting",
+ ready: "Ready",
+ unavailable: "Server unavailable",
+};
+
+export function ServerStatus() {
+ const [health, setHealth] = useState("checking");
+ const [metrics, setMetrics] = useState(null);
+ useEffect(() => {
+ let disposed = false;
+ let controller = new AbortController();
+ async function refresh() {
+ controller.abort();
+ controller = new AbortController();
+ const { signal } = controller;
+ try {
+ const { data } = await api.GET("/api/v2/metrics", { signal });
+ if (disposed) return;
+ if (data) {
+ setMetrics(data);
+ setHealth("ready");
+ } else setHealth("unavailable");
+ } catch {
+ if (!disposed && !signal.aborted) setHealth("unavailable");
+ }
+ }
+ void refresh();
+ const timer = window.setInterval(() => void refresh(), refreshMilliseconds);
+ return () => {
+ disposed = true;
+ controller.abort();
+ window.clearInterval(timer);
+ };
+ }, []);
+
+ const label = statusLabels[health];
+ const current = health === "ready" ? metrics : null;
+ const cpu = current ? `${current.cpuUsage.toFixed(1)}%` : "--";
+ const ram = current ? formatBytes(current.rssBytes) : "--";
+ return (
+
+
+ }
+ >
+
+
+
+
+
+
+
+ {label}
+
+ {health === "unavailable" && (
+
+ Can’t reach the diffx server. Retrying automatically.
+
+ )}
+
+ Diffx server process
+
+
+
- CPU
+ - {cpu}
+
+
+
RAM
+ {ram}
+
+
+
+
+
+
+
+ );
+}
+
+function StatusIcon({ health }: { health: ServerHealth }) {
+ const Icon =
+ health === "ready"
+ ? CircleCheckIcon
+ : health === "checking"
+ ? LoaderCircleIcon
+ : CircleAlertIcon;
+ return ;
+}
diff --git a/apps/web/src/style.css b/apps/web/src/style.css
index 6c7f08e6..4399c8e0 100644
--- a/apps/web/src/style.css
+++ b/apps/web/src/style.css
@@ -53,7 +53,8 @@ button[aria-busy="true"] {
}
@media (prefers-reduced-motion: no-preference) {
#refresh[aria-busy="true"] svg,
- #sync[aria-busy="true"] svg {
+ #sync[aria-busy="true"] svg,
+ :is(#server-status, .server-status-popup)[data-status="checking"] svg {
animation: refresh-spin 800ms linear infinite;
}
}
@@ -99,6 +100,8 @@ kbd {
}
.topbar {
height: var(--topbar-height);
+ /* Replace the shell's top gutter so controls center between window and islands. */
+ margin-top: calc(-1 * var(--shell-gutter));
display: flex;
gap: var(--space-1-5);
align-items: center;
@@ -197,7 +200,7 @@ kbd {
}
.topbar [data-variant="ghost"]:hover,
.topbar [data-variant="ghost"][aria-expanded="true"] {
- background: var(--hover);
+ background: var(--shell-hover);
}
.header-actions {
display: flex;
@@ -1041,29 +1044,68 @@ h2 {
.mobile-review-progress {
display: none;
}
-.footer-details,
-.server-metrics {
+.footer-details {
display: inline-flex;
align-items: center;
-}
-.footer-details {
gap: var(--space-3);
flex-shrink: 0;
}
-.server-metrics {
- gap: var(--space-1-5);
- padding: 1px var(--space-1-5);
+.server-status-popup {
+ width: min(16rem, calc(100vw - 2 * var(--space-4)));
+ padding: var(--space-3);
border: 1px solid var(--border);
- border-radius: var(--radius-sm);
+ border-radius: var(--radius-control);
background: var(--surface-raised);
color: var(--fg);
- font-variant-numeric: tabular-nums;
+ font-size: var(--text-sm);
+}
+.server-status-title {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ margin: 0;
+ font-size: var(--text-sm);
+ font-weight: var(--weight-semibold);
}
-.server-metrics:hover {
- border-color: var(--accent);
+.server-status-description {
+ margin: var(--space-1) 0 0;
+ color: var(--text-secondary);
+ line-height: var(--leading-copy);
+}
+:is(#server-status, .server-status-popup)[data-status="ready"] svg {
+ color: var(--success);
+}
+:is(#server-status, .server-status-popup)[data-status="unavailable"] svg {
+ color: var(--error);
}
-.server-metrics-divider {
+.server-status-section {
+ margin-top: var(--space-3);
+ padding-top: var(--space-3);
+ border-top: 1px solid var(--border-muted);
+}
+.server-status-section h2 {
+ margin: 0 0 var(--space-2);
color: var(--muted);
+ font-size: var(--text-xs);
+ font-weight: var(--weight-normal);
+}
+.server-status-section dl {
+ display: grid;
+ gap: var(--space-1);
+ margin: 0;
+}
+.server-status-section dl > div {
+ display: flex;
+ justify-content: space-between;
+ gap: var(--space-4);
+}
+.server-status-section dt {
+ color: var(--text-secondary);
+}
+.server-status-section dd {
+ margin: 0;
+ font-family: var(--font-mono);
+ font-variant-numeric: tabular-nums;
}
.mobile-only {
display: none;
@@ -1186,9 +1228,6 @@ h2 {
.diff-metadata {
gap: var(--space-1);
}
- .server-metrics-label {
- display: none;
- }
}
@theme inline {
diff --git a/apps/web/src/tokens.css b/apps/web/src/tokens.css
index 0e14043e..666d4acb 100644
--- a/apps/web/src/tokens.css
+++ b/apps/web/src/tokens.css
@@ -12,6 +12,7 @@
--border-control: #7f8491;
--border-strong: var(--border-control);
--hover: #eeeef4;
+ --shell-hover: #e1e2e8;
--diff-file-header: var(--panel);
--diff-canvas: var(--bg);
--diff-canvas-dot: var(--border);
@@ -95,6 +96,7 @@
--border-control: #747985;
--border-strong: var(--border-control);
--hover: #282930;
+ --shell-hover: #36373e;
--diff-canvas: #111216;
--diff-canvas-dot: #282a31;
--accent: #aba7ff;
diff --git a/apps/web/test/browser/app.spec.ts b/apps/web/test/browser/app.spec.ts
index 71445e3b..47a675e3 100644
--- a/apps/web/test/browser/app.spec.ts
+++ b/apps/web/test/browser/app.spec.ts
@@ -104,9 +104,7 @@ test("disabled comments issue no requests and expose no comment UI", async ({
expect(commentRequests).toBe(0);
});
-test("shows server process metrics in the compact status bar", async ({
- page,
-}) => {
+test("shows server status with process metrics on hover", async ({ page }) => {
await page.route("**/api/v2/metrics", (route) =>
route.fulfill({
contentType: "application/json",
@@ -116,12 +114,21 @@ test("shows server process metrics in the compact status bar", async ({
await page.goto("/");
await showDiff(page);
+ const status = page.getByRole("button", { name: "Server status: Ready" });
+ await expect(status).toBeVisible();
+ await expect(page.locator("#server-cpu")).toHaveCount(0);
+ await status.hover();
+ const popup = page.getByRole("dialog", { name: "Ready" });
+ await expect(popup).toContainText("Diffx server process");
await expect(page.locator("#server-cpu")).toHaveText("12.5%");
await expect(page.locator("#server-ram")).toHaveText("50.0 MB");
- await expect(page.locator("#server-metrics")).toHaveAttribute(
- "title",
- /Diffx server process/,
- );
+ await page.mouse.move(0, 0);
+ await expect(popup).toBeHidden();
+ await status.focus();
+ await page.keyboard.press("Enter");
+ await expect(popup).toBeVisible();
+ await page.keyboard.press("Escape");
+ await expect(popup).toBeHidden();
const sidebar = await page.locator(".sidebar-footer").boundingBox();
const main = await page.locator(".main-footer").boundingBox();
if (!sidebar || !main) throw new Error("Missing status bars");
@@ -129,6 +136,24 @@ test("shows server process metrics in the compact status bar", async ({
expect(main.y).toBeCloseTo(sidebar.y, 1);
});
+test("reports an unreachable server in the status indicator", async ({
+ page,
+}) => {
+ await page.route("**/api/v2/metrics", (route) =>
+ route.fulfill({ status: 503, body: "" }),
+ );
+ await page.goto("/");
+ await showDiff(page);
+
+ const status = page.getByRole("button", {
+ name: "Server status: Server unavailable",
+ });
+ await status.hover();
+ const popup = page.getByRole("dialog", { name: "Server unavailable" });
+ await expect(popup).toContainText("Retrying automatically");
+ await expect(page.locator("#server-cpu")).toHaveText("--");
+});
+
test("renders and filters a piped diff", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 4_000 });
await page.goto("/");
@@ -1547,7 +1572,11 @@ test("header text stays centered and Theme reaches the shell edge", async ({
bottomClearance: label.bottom - text.bottom,
};
});
+ const workbench = document.querySelector(".workbench");
+ if (!workbench) throw new Error("Missing workbench");
return {
+ top: box.top,
+ islandGap: workbench.getBoundingClientRect().top - box.bottom,
rightInset: box.right - themeBox.right,
iconOffset: Math.abs(
themeBox.y + themeBox.height / 2 - (box.y + box.height / 2),
@@ -1555,6 +1584,9 @@ test("header text stays centered and Theme reaches the shell edge", async ({
labels,
};
});
+ // Controls center between the window edge and the islands below.
+ expect(geometry.top).toBe(0);
+ expect(geometry.islandGap).toBe(0);
expect(geometry.rightInset).toBe(0);
expect(geometry.iconOffset).toBe(0);
expect(geometry.labels.length).toBeGreaterThan(0);
@@ -1567,6 +1599,30 @@ test("header text stays centered and Theme reaches the shell edge", async ({
}
});
+test("header actions show a visible hover surface in both themes", async ({
+ page,
+}) => {
+ await page.goto("/");
+ const theme = page.locator("#theme");
+ await expect(theme).toBeVisible();
+ for (const mode of ["light", "dark"]) {
+ await page
+ .locator("html")
+ .evaluate((element, value) => (element.dataset.theme = value), mode);
+ await theme.hover();
+ const surfaces = await theme.evaluate((element) => {
+ const header = element.closest(".topbar");
+ if (!header) throw new Error("Missing header");
+ return {
+ header: getComputedStyle(header).backgroundColor,
+ hover: getComputedStyle(element).backgroundColor,
+ };
+ });
+ expect(surfaces.hover).not.toBe(surfaces.header);
+ await page.mouse.move(0, 400);
+ }
+});
+
test("shared navigation resizer preserves width across Diff and Repos", async ({
page,
}) => {
@@ -1934,9 +1990,11 @@ test("drawer aligns its header and retains desktop navigation density", async ({
await expect.poll(density).toEqual(desktop);
const header = await page.locator(".topbar").boundingBox();
const tabs = await page.locator(".sidebar-tabs").boundingBox();
- if (!header || !tabs) throw new Error("Missing drawer header");
+ const drawer = await page.locator("#sidebar").boundingBox();
+ if (!header || !tabs || !drawer) throw new Error("Missing drawer header");
expect(tabs.height).toBe(header.height);
- expect(tabs.y).toBe(header.y);
+ // The header fills the shell's top gutter; the drawer keeps its uniform inset.
+ expect(tabs.y).toBe(drawer.x);
if (width === 390) {
const toolbar = await page.locator(".toolbar").boundingBox();
const theme = await page.locator("#theme").boundingBox();