From 32e304ad3535437802cb3d7196bbbaa701555709 Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Sat, 17 Oct 2026 00:54:55 -0700
Subject: [PATCH 1/6] fix: unify dashboard facts and conditional form behavior
---
.changeset/tidy-dashboard-forms.md | 6 +
.../__tests__/add-node-dialog.test.tsx | 8 +-
.../__tests__/backup-settings.test.tsx | 4 +-
.../__tests__/network-plugin-card.test.tsx | 22 +-
.../__tests__/restart-dialog.test.tsx | 2 +-
.../__tests__/sharing-dialog.test.tsx | 40 +-
.../src/components/auth/provider-dialog.tsx | 28 +-
.../src/components/auth/providers-table.tsx | 19 +-
.../components/backups/backup-workflow.tsx | 40 +-
.../components/backups/create-backup-card.tsx | 10 +-
.../backups/restore-backup-card.tsx | 449 ++++++++++--------
.../src/components/clone-subshell-dialog.tsx | 19 +-
.../connect/__tests__/ssh-wizard.test.tsx | 10 +-
.../components/connect/ssh-forward-fields.tsx | 167 ++++---
.../web/src/components/connect/ssh-wizard.tsx | 185 ++++----
.../verify-browser-key-destination.tsx | 32 +-
.../components/connect/verify-destination.tsx | 21 +-
.../web/src/components/device-name-card.tsx | 13 +-
apps/server/web/src/components/fact-list.tsx | 19 +
.../web/src/components/instance-name-card.tsx | 12 +-
.../components/networking/addresses-card.tsx | 5 +-
.../networking/network-addresses.tsx | 24 +-
.../networking/network-settings-form.tsx | 12 +-
.../src/components/nodes/node-key-setup.tsx | 31 +-
.../__tests__/ssh-preset-editor.test.tsx | 2 +-
.../presets/command-paste-field.tsx | 20 +-
.../web/src/components/profile-card.tsx | 22 +-
.../__tests__/stack-form-dialog.test.tsx | 6 +-
.../components/prompts/prompt-form-dialog.tsx | 14 +-
.../components/prompts/stack-form-dialog.tsx | 14 +-
.../src/components/service/restart-dialog.tsx | 14 +-
.../components/service/supervision-dialog.tsx | 38 +-
.../src/components/sharing-dialog-core.tsx | 47 +-
.../components/system-key-create-dialog.tsx | 2 +-
.../web/src/components/ui/choice-fields.tsx | 13 +
apps/server/web/src/components/ui/notice.tsx | 21 +
.../src/components/updates/update-dialog.tsx | 25 +-
apps/server/web/src/routes/nodes_.$id.tsx | 65 +--
docs/README.md | 1 +
docs/design-system.md | 33 +-
docs/ux-review-2026-10-17.md | 65 +++
.../node-admin/src/components/fact-list.tsx | 9 +-
.../src/components/node-runtime-card.tsx | 2 +-
.../src/components/ssh-trust-card.tsx | 30 +-
44 files changed, 952 insertions(+), 669 deletions(-)
create mode 100644 .changeset/tidy-dashboard-forms.md
create mode 100644 apps/server/web/src/components/fact-list.tsx
create mode 100644 apps/server/web/src/components/ui/choice-fields.tsx
create mode 100644 apps/server/web/src/components/ui/notice.tsx
create mode 100644 docs/ux-review-2026-10-17.md
diff --git a/.changeset/tidy-dashboard-forms.md b/.changeset/tidy-dashboard-forms.md
new file mode 100644
index 000000000..2dd74d378
--- /dev/null
+++ b/.changeset/tidy-dashboard-forms.md
@@ -0,0 +1,6 @@
+---
+"@internal/server": patch
+"@internal/node": patch
+---
+
+Make dashboard information easier to scan with consistent labels, readable values, and wrapping for long paths and addresses. Keep conditional fields beneath their selected radio option, disable Save for unchanged drafts, and clarify warnings and validation. Apply the shared fact styling to the node dashboard as well.
diff --git a/apps/server/web/src/components/__tests__/add-node-dialog.test.tsx b/apps/server/web/src/components/__tests__/add-node-dialog.test.tsx
index 6012f0564..113628f26 100644
--- a/apps/server/web/src/components/__tests__/add-node-dialog.test.tsx
+++ b/apps/server/web/src/components/__tests__/add-node-dialog.test.tsx
@@ -656,9 +656,9 @@ describe("AddNodeDialog", () => {
// dropdown's items are in the DOM too — Base UI force-mounts them once the
// trigger has focus, which is where the dialog's initial focus lands (the
// old count of 2 rode the two-step never focusing the trigger — timing, not
- // truth). The paste target is the CODE row, and
+ // truth). The paste target is the fact value, and
// there is exactly one. The key shows ONCE.
- expect(screen.getAllByText("https://plane.example").filter((el) => el.tagName === "CODE")).toHaveLength(1);
+ expect(screen.getAllByText("https://plane.example").filter((el) => el.closest("dd") !== null)).toHaveLength(1);
expect(screen.getAllByText(/nsk_secret/)).toHaveLength(1);
expect(screen.getByRole("button", { name: "Copy setup key" })).toBeDefined();
expect(screen.getByRole("button", { name: "Copy server address" })).toBeDefined();
@@ -716,11 +716,11 @@ describe("AddNodeDialog", () => {
expect(label.compareDocumentPosition(trigger) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(trigger.compareDocumentPosition(switchButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
- // The pick is the same value the app path's paste row carries — one CODE row
+ // The pick is the same value the app path's paste row carries — one fact value
// (the closed dropdown holds further copies in the DOM; see the count comment
// in the test above).
fireEvent.click(screen.getByRole("button", { name: "Desktop App" }));
- expect(screen.getAllByText("https://plane.example").filter((el) => el.tagName === "CODE")).toHaveLength(1);
+ expect(screen.getAllByText("https://plane.example").filter((el) => el.closest("dd") !== null)).toHaveLength(1);
} finally {
restore();
}
diff --git a/apps/server/web/src/components/__tests__/backup-settings.test.tsx b/apps/server/web/src/components/__tests__/backup-settings.test.tsx
index 0d953b88a..3ed506c61 100644
--- a/apps/server/web/src/components/__tests__/backup-settings.test.tsx
+++ b/apps/server/web/src/components/__tests__/backup-settings.test.tsx
@@ -159,7 +159,7 @@ describe("Settings backup workflows", () => {
expect(activity.at(-1)).toBe(false);
});
- it("gates encryption on shared validation and keeps gold errors beside blurred fields", async () => {
+ it("gates encryption and distinguishes invalid values from missing input", async () => {
const requests = mockApi();
mount( );
fireEvent.click(screen.getByRole("switch", { name: "Encrypt the archive with a password" }));
@@ -170,7 +170,7 @@ describe("Settings backup workflows", () => {
for (const input of [password, confirm]) fireEvent.change(input, { target: { value: "short" } });
fireEvent.blur(password);
expect(screen.getByRole("alert").textContent).toContain("at least 8");
- expect(screen.getByRole("alert").className).toContain("text-warning");
+ expect(screen.getByRole("alert").className).toContain("text-destructive");
fireEvent.focus(password);
fireEvent.change(password, { target: { value: "unique archive password" } });
fireEvent.change(confirm, { target: { value: "different" } });
diff --git a/apps/server/web/src/components/__tests__/network-plugin-card.test.tsx b/apps/server/web/src/components/__tests__/network-plugin-card.test.tsx
index 92844f32a..3b3c8257e 100644
--- a/apps/server/web/src/components/__tests__/network-plugin-card.test.tsx
+++ b/apps/server/web/src/components/__tests__/network-plugin-card.test.tsx
@@ -1163,29 +1163,20 @@ describe("NetworkPluginCard: the state matrix", () => {
});
it("labels each address above its value, in the QUIET data-label grammar", async () => {
- // Two defects, in order, both from the same operator's live card. First the
- // tag-after-URL row (`http://… MagicDNS name`) read as two disjoint
- // things, because a small muted word after a big bold URL names nothing
- // until you scan back — so the label moved ABOVE the value. Then the
- // grammar it moved into was the wrong one: the form label's `font-strong
- // text-label` put a bold "MagicDNS name" over its URL while the `Fact`
- // rows above it set a quiet "Client version" over theirs, and a card
- // holding both grammars reads as two systems. These are read-only facts, so
- // the label carries `Fact`'s `dt` classes — quiet colour, no weight token,
- // and the body size from the LIST, exactly as the `` supplies it.
+ // Facts use smaller muted labels and full-size values across cards and dialogs.
await renderCard(row({ state: "joined", status: { state: "joined", addresses: ADDRESSES, hints: [] } }));
- const items = screen.getAllByRole("listitem");
+ const items = ADDRESSES.map((address) => screen.getByText(address.label).closest("div") as HTMLElement);
const first = items[0] as HTMLElement;
expect(first.children[0]?.textContent).toBe("MagicDNS name");
expect(first.children[0]?.className).toContain("text-muted-foreground");
expect(first.children[0]?.className).not.toContain("font-strong");
expect(first.children[0]?.className).not.toContain("text-label");
- // The size comes from the list so it cannot drift from the `` beside it.
- expect(first.parentElement?.className).toContain("text-sm");
+ expect(first.children[0]?.className).toContain("text-detail");
+ expect(first.children[1]?.className).toContain("text-body");
expect(first.children[1]?.textContent).toContain("https://box.tail1234.ts.net");
// The secure-context sentence keeps its own line below the value, and it
// is still the address's own — one per address, comparative by repetition.
- expect(first.children[2]?.textContent).toBe("Passkeys and secure cookies work at this address.");
+ expect(first.children[1]?.textContent).toContain("Passkeys and secure cookies work at this address.");
const second = items[1] as HTMLElement;
expect(second.children[0]?.textContent).toBe("Tailscale IP");
});
@@ -1196,7 +1187,8 @@ describe("NetworkPluginCard: the state matrix", () => {
// here by scheme, by secure context or by label would make the page
// contradict its own data for a reason no reader can see.
await renderCard(row({ state: "joined", status: { state: "joined", addresses: ADDRESSES, hints: [] } }));
- const shown = screen.getAllByRole("listitem").map((item) => item.textContent ?? "");
+ const list = screen.getByText(ADDRESSES[0]!.label).closest("dl")!;
+ const shown = Array.from(list.children, (item) => item.textContent ?? "");
expect(shown[0]).toContain("https://box.tail1234.ts.net");
expect(shown[1]).toContain("http://100.64.0.1:3080");
});
diff --git a/apps/server/web/src/components/__tests__/restart-dialog.test.tsx b/apps/server/web/src/components/__tests__/restart-dialog.test.tsx
index ef688e9fd..7971a85f4 100644
--- a/apps/server/web/src/components/__tests__/restart-dialog.test.tsx
+++ b/apps/server/web/src/components/__tests__/restart-dialog.test.tsx
@@ -46,6 +46,6 @@ describe("RestartDialog pane safety", () => {
// Absence of evidence is not evidence of safety: a definition that could
// not be read gets the same refusal as one known to be lethal.
open({ installed: true, paneSafety: "unknown" });
- expect(screen.getByText(/close every running subshell/)).toBeTruthy();
+ expect(screen.getByText(/Restarting may close them/)).toBeTruthy();
});
});
diff --git a/apps/server/web/src/components/__tests__/sharing-dialog.test.tsx b/apps/server/web/src/components/__tests__/sharing-dialog.test.tsx
index 9977bf7cf..52a6514f8 100644
--- a/apps/server/web/src/components/__tests__/sharing-dialog.test.tsx
+++ b/apps/server/web/src/components/__tests__/sharing-dialog.test.tsx
@@ -59,7 +59,40 @@ describe("SharingDialog", () => {
// The grant row renders (Everyone, one Remove control); the add-row
// picker omits Everyone since it's already shared.
expect(await screen.findByText("Everyone")).toBeDefined();
- expect(screen.getByRole("button", { name: "Remove" })).toBeDefined();
+ expect(screen.getByRole("button", { name: /^Remove access for/ })).toBeDefined();
+ } finally {
+ restore();
+ }
+ });
+
+ it("disables Save until access changes and disables it again when changes are undone", async () => {
+ const { calls, restore } = mockFetch([{ granteeUserId: null, permission: "view" }]);
+ try {
+ renderDialog();
+ await screen.findByText("Everyone");
+ const save = () => screen.getByRole("button", { name: "Save" });
+ const disabled = () => save().matches(":disabled, [aria-disabled=true]");
+ await waitFor(() => expect(disabled()).toBe(true));
+ fireEvent.click(save());
+ expect(calls.some((call) => call.method === "PUT")).toBe(false);
+ fireEvent.click(screen.getByRole("combobox", { name: "Access for Everyone" }));
+ const editOption = await screen.findByRole("option", { name: "View + edit" });
+ fireEvent.pointerDown(editOption);
+ fireEvent.pointerUp(editOption);
+ fireEvent.click(editOption);
+ await waitFor(() => expect(disabled()).toBe(false));
+ fireEvent.click(screen.getByRole("combobox", { name: "Access for Everyone" }));
+ const viewOption = await screen.findByRole("option", { name: "View" });
+ fireEvent.pointerDown(viewOption);
+ fireEvent.pointerUp(viewOption);
+ fireEvent.click(viewOption);
+ await waitFor(() => expect(disabled()).toBe(true));
+ fireEvent.click(screen.getByRole("button", { name: "Remove access for Everyone" }));
+ await waitFor(() => expect(disabled()).toBe(false));
+ // Re-adding the original grant also restores the saved state.
+ fireEvent.click(screen.getByRole("button", { name: "Add" }));
+ await waitFor(() => expect(disabled()).toBe(true));
+ expect(calls.some((call) => call.method === "PUT")).toBe(false);
} finally {
restore();
}
@@ -100,7 +133,10 @@ describe("SharingDialog", () => {
try {
renderDialog();
await screen.findByText("Everyone");
- fireEvent.click(screen.getByRole("button", { name: "Remove" }));
+ fireEvent.click(screen.getByRole("button", { name: /^Remove access for/ }));
+ expect(screen.queryByText("You have unsaved sharing changes.")).toBeNull();
+ expect(screen.getByText("No one will have shared access after you save.")).toBeTruthy();
+ expect(calls.some((call) => call.method === "PUT")).toBe(false);
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
const put = calls.find((c) => c.method === "PUT" && c.url.endsWith("/shares"));
diff --git a/apps/server/web/src/components/auth/provider-dialog.tsx b/apps/server/web/src/components/auth/provider-dialog.tsx
index 964ab758a..4a14c5839 100644
--- a/apps/server/web/src/components/auth/provider-dialog.tsx
+++ b/apps/server/web/src/components/auth/provider-dialog.tsx
@@ -106,9 +106,22 @@ function ProviderForm({ provider, onDone }: { provider: ProviderAdminView | null
// sends this exact slug as `id`, so the preview and the truth are one
// string (§5a's "live while filling").
const displayId = editing ? provider.id : slug;
+ const unchanged =
+ provider !== null &&
+ name.trim() === provider.name &&
+ issuer.trim() === (provider.issuer ?? "") &&
+ clientId.trim() === (provider.clientId ?? "") &&
+ secret === "" &&
+ overrideOrigin === (provider.callbackBaseUrl ?? null) &&
+ domains
+ .split(",")
+ .map((value) => value.trim())
+ .filter(Boolean)
+ .sort()
+ .join(",") === [...(provider.allowedDomains ?? [])].sort().join(",");
async function save(): Promise {
setSaveError(null);
- if (invalidOverride) return;
+ if (invalidOverride || unchanged || busy) return;
const shared = {
name: name.trim(),
issuer: issuer.trim(),
@@ -161,6 +174,7 @@ function ProviderForm({ provider, onDone }: { provider: ProviderAdminView | null
: null;
const saveDisabled =
+ unchanged ||
busy ||
name.trim() === "" ||
issuer.trim() === "" ||
@@ -408,11 +422,13 @@ function ProviderForm({ provider, onDone }: { provider: ProviderAdminView | null
disabledReason={
busy
? "Wait for the sign-in provider to save."
- : invalidOverride
- ? "Fix the provider endpoint addresses before saving."
- : entries.length === 0
- ? "Add an entry point first."
- : "Complete the required provider fields first."
+ : unchanged
+ ? "No provider changes to save."
+ : invalidOverride
+ ? "Fix the provider endpoint addresses before saving."
+ : entries.length === 0
+ ? "Add an entry point first."
+ : "Complete the required provider fields first."
}
>
{busy ? "Saving…" : editing ? "Save" : "Add provider"}
diff --git a/apps/server/web/src/components/auth/providers-table.tsx b/apps/server/web/src/components/auth/providers-table.tsx
index bd5ebb8ba..7c204ad49 100644
--- a/apps/server/web/src/components/auth/providers-table.tsx
+++ b/apps/server/web/src/components/auth/providers-table.tsx
@@ -3,6 +3,7 @@ import { Pencil, Settings2, Trash2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { type ActionItem, ActionsMenu } from "@/components/actions-menu";
import { ProviderSettingsDialog } from "@/components/auth/provider-settings-dialog";
+import { FactList } from "@/components/fact-list";
import { useDeleteAuthProvider, usePatchAuthProvider } from "@/hooks/use-auth-providers";
import {
EMAIL_PROVIDER_ID,
@@ -127,16 +128,14 @@ function ProviderRow({
can repeat, so the two strings that actually identify the row —
the slug id and the issuer — get label-over-value rows, the
values in the mono foreground voice they wear everywhere else. */}
-
-
-
Slug id
- {provider.id}
-
-
-
Issuer
- {provider.issuer ?? ""}
-
-
+
+
+
>
),
confirmLabel: "Remove",
diff --git a/apps/server/web/src/components/backups/backup-workflow.tsx b/apps/server/web/src/components/backups/backup-workflow.tsx
index 4cabf64d8..840978279 100644
--- a/apps/server/web/src/components/backups/backup-workflow.tsx
+++ b/apps/server/web/src/components/backups/backup-workflow.tsx
@@ -3,13 +3,13 @@ import {
CardFooter,
CardHeader,
CardTitle,
- cn,
Card as WorkflowCard,
CardContent as WorkflowContent,
} from "@internal/node-admin";
import { CheckCircle2, LoaderCircle } from "lucide-react";
-import { Fragment, type ReactNode } from "react";
-import { CopyButton } from "@/components/copy-button";
+import type { ReactNode } from "react";
+import { FactList } from "@/components/fact-list";
+import { ErrorNotice } from "@/components/ui/notice";
import { Card, CardContent } from "./backup-card";
export function BackupWorkflow({
@@ -69,34 +69,16 @@ export function BackupProgress({
export function BackupFacts({ rows }: { rows: { label: string; value: string; copy?: boolean }[] }) {
return (
-
- {rows.map(({ label, value, copy }) => (
-
- {label}
-
-
-
- {value}
-
- {copy && }
-
-
-
- ))}
-
+ ({
+ ...row,
+ mono: row.copy && row.label !== "Control plane URL",
+ copyText: row.copy ? row.value : undefined,
+ }))}
+ />
);
}
export function BackupError({ message, className }: { message: string; className?: string }) {
- return (
-
- {message}
-
- );
+ return {message} ;
}
diff --git a/apps/server/web/src/components/backups/create-backup-card.tsx b/apps/server/web/src/components/backups/create-backup-card.tsx
index 07dda50b5..0d25b11ff 100644
--- a/apps/server/web/src/components/backups/create-backup-card.tsx
+++ b/apps/server/web/src/components/backups/create-backup-card.tsx
@@ -237,7 +237,10 @@ export function CreateBackupCard({ onBusy }: { onBusy?: (busy: boolean) => void
disabled={busy}
/>
{problem && (
-
+
{problem}
)}
@@ -273,7 +276,10 @@ export function CreateBackupCard({ onBusy }: { onBusy?: (busy: boolean) => void
disabled={busy}
/>
{problem && (
-
+
{problem}
)}
diff --git a/apps/server/web/src/components/backups/restore-backup-card.tsx b/apps/server/web/src/components/backups/restore-backup-card.tsx
index 1c2d347fb..a2d574fa1 100644
--- a/apps/server/web/src/components/backups/restore-backup-card.tsx
+++ b/apps/server/web/src/components/backups/restore-backup-card.tsx
@@ -1,9 +1,10 @@
-import { Button, Input, Switch } from "@internal/node-admin";
+import { Button, cn, Input, Switch } from "@internal/node-admin";
import { BACKUP_RESTORE_DEFAULTS, BACKUP_RESTORE_MODES, type RestoreMode } from "@internal/subshell-protocol";
import { useStore } from "@tanstack/react-form";
import { LoaderCircle } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Checkbox } from "@/components/ui/checkbox";
+import { ChoiceFields } from "@/components/ui/choice-fields";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@@ -421,7 +422,11 @@ export function RestoreBackupCard({ onBusy }: { onBusy?: (busy: boolean) => void
onChange={(event) => field.handleChange(event.target.value)}
/>
{problem && (
-
+
{problem}
)}
@@ -690,7 +695,6 @@ export function RestoreBackupCard({ onBusy }: { onBusy?: (busy: boolean) => void
@@ -698,17 +702,41 @@ export function RestoreBackupCard({ onBusy }: { onBusy?: (busy: boolean) => void
}
>
{BACKUP_RESTORE_MODES.map((item) => (
-
-
-
- {item.label}
-
+
+
+
+
+ {item.label}
+
+ {item.description}
+
+
+ {!inspection?.legacyDatabaseOnly && draft.mode === item.value && (
+
+ {textField("databasePath", "Database path (optional)")}
+ {textField("dataDir", "Data directory (optional)")}
+ {textField("configDir", "Configuration directory (optional)")}
+ {textField("baseUrl", "Control plane URL (optional)")}
+ {textField("host", "Bind address (optional)")}
+ {textField("port", "Port (optional)")}
+ {textField("trustedOrigins", "Trusted origins (optional)")}
+
+ )}
+
))}
-
- {BACKUP_RESTORE_MODES.find((mode) => mode.value === draft.mode)?.description}
-
{inspection?.legacyDatabaseOnly && (
This backup contains only the database. It restores into this server using its current
@@ -716,17 +744,6 @@ export function RestoreBackupCard({ onBusy }: { onBusy?: (busy: boolean) => void
)}
- {!inspection?.legacyDatabaseOnly && (
- <>
- {textField("databasePath", "Database path (optional)")}
- {textField("dataDir", "Data directory (optional)")}
- {textField("configDir", "Configuration directory (optional)")}
- {textField("baseUrl", "Control plane URL (optional)")}
- {textField("host", "Bind address (optional)")}
- {textField("port", "Port (optional)")}
- {textField("trustedOrigins", "Trusted origins (optional)")}
- >
- )}
void
>
}
>
-
- Restore from
- {
- setSource(value === "saved" ? "saved" : "file");
- void resetSelection().catch((error) => setSelectionProblem(String(error)));
- }}
- >
-
-
-
- Open a backup file
-
-
-
-
-
- Use a saved backup
-
-
-
-
);
diff --git a/apps/server/web/src/components/clone-subshell-dialog.tsx b/apps/server/web/src/components/clone-subshell-dialog.tsx
index 93df2ec68..31e4f4300 100644
--- a/apps/server/web/src/components/clone-subshell-dialog.tsx
+++ b/apps/server/web/src/components/clone-subshell-dialog.tsx
@@ -1,6 +1,7 @@
import { Button, Input } from "@internal/node-admin";
import { useNavigate } from "@tanstack/react-router";
import { type JSX, useState } from "react";
+import { FactList } from "@/components/fact-list";
import {
Dialog,
DialogContent,
@@ -115,16 +116,14 @@ export function CloneSubshellDialog({
the date and time.
-
- Agent
- {agentName}
- Preset
- {presetName}
- Node
- {nodeLabel}
- Working directory
- {source.workingDir}
-
+
Name (optional)
diff --git a/apps/server/web/src/components/connect/__tests__/ssh-wizard.test.tsx b/apps/server/web/src/components/connect/__tests__/ssh-wizard.test.tsx
index ba8c9ead1..46e6e5a8d 100644
--- a/apps/server/web/src/components/connect/__tests__/ssh-wizard.test.tsx
+++ b/apps/server/web/src/components/connect/__tests__/ssh-wizard.test.tsx
@@ -480,14 +480,18 @@ for (const ownerLabel of ["Alex Smith", "alex@example.com"]) {
});
}
-it("shows authentication details only for the selected option and gates browser keys on trust", async () => {
+it("keeps descriptions visible, groups fields with their selected choice, and gates browser keys on trust", async () => {
const { calls } = await setup({ initial: { node: "desk", destination: "alice@host" } });
expect(screen.queryByText(/Compromised page code could steal/)).toBeNull();
- expect(screen.queryByText(/Enter your password in the terminal/)).toBeNull();
+ expect(screen.getByText(/Enter your password in the terminal/)).toBeTruthy();
expect(screen.queryByRole("button", { name: "Choose key…" })).toBeNull();
fireEvent.click(screen.getByRole("radio", { name: "Use a key from my machine" }));
expect(screen.getByText(/Compromised page code could steal/)).toBeTruthy();
- expect(document.getElementById("ssh-keys-node-name")).toBeNull();
+ expect(document.getElementById("ssh-keys-node-name")).toBeTruthy();
+ const keyFields = screen.getByRole("region", { name: "Use a key from my machine" });
+ expect(keyFields.contains(screen.getByRole("button", { name: "Choose key…" }))).toBe(true);
+ expect(keyFields.closest('[role="radiogroup"]')).toBeTruthy();
+ expect(keyFields.closest("label")).toBeNull();
expect(screen.getByRole("button", { name: "Choose key…" }).getAttribute("aria-disabled")).toBe("true");
await waitFor(() => expect(disabled()).toBe(true));
await click("Start SSH subshell");
diff --git a/apps/server/web/src/components/connect/ssh-forward-fields.tsx b/apps/server/web/src/components/connect/ssh-forward-fields.tsx
index e20189aee..55c32bb26 100644
--- a/apps/server/web/src/components/connect/ssh-forward-fields.tsx
+++ b/apps/server/web/src/components/connect/ssh-forward-fields.tsx
@@ -2,6 +2,7 @@ import { Button, cn, Input, Label } from "@internal/node-admin";
import { SSH_MAX_FORWARD_ROWS } from "@internal/subshell-protocol";
import { Plus } from "lucide-react";
import { useState } from "react";
+import { ChoiceFields } from "@/components/ui/choice-fields";
import { Field, FieldLabel } from "@/components/ui/field";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { RequiredMark } from "@/components/ui/required-mark";
@@ -102,7 +103,6 @@ export function SshForwardFields({
onChange([...rows, { ...editor }]);
foldEditor();
};
- const helpId = "ssh-fwd-kind-help";
const example = FORWARD_EXAMPLE[editor.kind];
return (
@@ -137,91 +137,90 @@ export function SshForwardFields({
value={editor.kind}
onValueChange={(value) => patchEditor({ kind: (value ?? "local") as SshForwardDraftRow["kind"] })}
>
- {/* The Authentication field's shape: the chosen option carries its
- caption, route diagram and fields in the column beside the radio,
- and the other options stay one line each. */}
- {KINDS.map((option) => {
- const selected = editor.kind === option.value;
- return (
-
-
-
-
- {option.label}
-
- {selected && (
- <>
-
- {KIND_HELP[option.value]}
-
-
- {sshForwardRowDiagram(editor)}
-
-
+
+ )}
+
+
+ ))}
{/* Sentences show once a field is touched, not while it sits pristine. */}
{touched && problem && (
@@ -268,7 +267,7 @@ export function SshForwardFields({
key={`${row.kind}|${row.bindHost.trim()}|${row.listenPort.trim()}`}
className="flex items-center justify-between gap-3"
>
-
+
{KINDS.find((option) => option.value === row.kind)?.short ?? row.kind}
diff --git a/apps/server/web/src/components/connect/ssh-wizard.tsx b/apps/server/web/src/components/connect/ssh-wizard.tsx
index 0049b0ee4..4a7a431a9 100644
--- a/apps/server/web/src/components/connect/ssh-wizard.tsx
+++ b/apps/server/web/src/components/connect/ssh-wizard.tsx
@@ -14,6 +14,7 @@ import {
sshKeySourceProblem,
} from "@/components/connect/ssh-session-draft";
import { SshQueryStatus } from "@/components/ssh/query-status";
+import { ChoiceFields } from "@/components/ui/choice-fields";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { useSshNodeRoster } from "@/hooks/use-ssh";
@@ -185,15 +186,11 @@ export function SshWizard({
id="ssh-keys-connecting"
value="connecting"
aria-labelledby="ssh-keys-connecting-label"
- aria-describedby={
- (!draft.authentication || draft.authentication === "connecting") && !remote
- ? "ssh-keys-node-name"
- : undefined
- }
+ aria-describedby="ssh-keys-node-name"
/>
Use the connecting node’s keys
- {(!draft.authentication || draft.authentication === "connecting") && !remote && (
+ {
{machine?.node.name ?? "Choose a node to connect from first"}
- )}
+ }
@@ -212,73 +209,77 @@ export function SshWizard({
id="ssh-keys-remote"
value="remote"
aria-labelledby="ssh-keys-remote-label"
- aria-describedby={remote && noOtherKeyNodes ? "ssh-keys-empty" : undefined}
+ aria-describedby="ssh-keys-remote-description"
/>
-
+
Use another node’s keys
-
- {remote && noOtherKeyNodes && (
-
-
- No other nodes are available to supply keys. Use the connecting node’s keys or{" "}
-
-
- add another node
-
- .
+
+ Choose a different node to supply the keys for this connection.
- )}
- {remote && (!noOtherKeyNodes || keyProblem || keyMachine) && (
-
- {!noOtherKeyNodes && (
- {
- patch({ keyHome, authentication: "remote" });
- }}
- exclude={draft.nodeId}
- id="ssh-keys-machine"
- label="Use keys from"
- hideLabel
- notice={
- enableKeysOnStart
- ? "Starting this subshell will enable SSH on the node supplying keys. You’ll then choose which keys to use before connecting."
- : undefined
- }
- >
- {keyMachine && !keyProblem && !keyMachine.canConnect && !enableKeysOnStart && (
-
+ {remote && (
+
+ {noOtherKeyNodes && (
+
+ No other nodes are available to supply keys. Use the connecting node’s keys or{" "}
+
+ add another node
+
+ .
+
+ )}
+ {(!noOtherKeyNodes || keyProblem || keyMachine) && (
+
+ {!noOtherKeyNodes && (
+ {
+ patch({ keyHome, authentication: "remote" });
+ }}
+ exclude={draft.nodeId}
+ id="ssh-keys-machine"
+ label="Use keys from"
+ notice={
+ enableKeysOnStart
+ ? "Starting this subshell will enable SSH on the node supplying keys. You’ll then choose which keys to use before connecting."
+ : undefined
+ }
+ >
+ {keyMachine && !keyProblem && !keyMachine.canConnect && !enableKeysOnStart && (
+
+ )}
+
+ )}
+ {keyProblem && (
+
+ {keyProblem}
+
+ )}
+ {keyMachine?.canConnect && !keyProblem && (
+
+ patch({ selections: { ...draft.selections, [draft.keyHome]: selection } })
+ }
/>
)}
-
- )}
- {keyProblem && (
-
- {keyProblem}
-
- )}
- {keyMachine?.canConnect && !keyProblem && (
-
- patch({ selections: { ...draft.selections, [draft.keyHome]: selection } })
- }
- />
+
)}
-
+
)}
@@ -287,32 +288,32 @@ export function SshWizard({
id="ssh-auth-local-key"
value="local-key"
aria-labelledby="ssh-auth-local-key-label"
- aria-describedby={
- draft.authentication === "local-key"
- ? isDesktop() || presetEditor
- ? "ssh-auth-local-key-description"
- : "ssh-browser-key-risk"
- : undefined
- }
+ aria-describedby="ssh-auth-local-key-summary"
/>
-
+
Use a key from my machine
+
+ Choose a private key on the device you’re using now.
+
- {draft.authentication === "local-key" &&
- (presetEditor ? (
-
- Choose a key from your machine when creating a new SSH subshell. Your key isn’t saved in the
- preset.
-
- ) : isDesktop() ? (
-
- Choose a key when you start. It stays on your machine and is released after one authentication
- attempt or 90 seconds.
-
- ) : (
-
- ))}
+ {draft.authentication === "local-key" && (
+
+ {presetEditor ? (
+
+ Choose a key from your machine when creating a new SSH subshell. Your key isn’t saved in the
+ preset.
+
+ ) : isDesktop() ? (
+
+ Choose a key when you start. It stays on your machine and is released after one authentication
+ attempt or 90 seconds.
+
+ ) : (
+
+ )}
+
+ )}
@@ -320,17 +321,17 @@ export function SshWizard({
id="ssh-auth-password"
value="password"
aria-labelledby="ssh-auth-password-label"
- aria-describedby={draft.authentication === "password" ? "ssh-auth-password-description" : undefined}
+ aria-describedby="ssh-auth-password-description"
/>
Enter a password when connecting
- {draft.authentication === "password" && (
+ {
{presetEditor
? "When creating a new SSH subshell with this preset, enter your password in the terminal when SSH asks for it. The preset won’t save your password."
: "Enter your password in the terminal when SSH asks for it. Subshell won’t save it."}
- )}
+ }
diff --git a/apps/server/web/src/components/connect/verify-browser-key-destination.tsx b/apps/server/web/src/components/connect/verify-browser-key-destination.tsx
index f6086a906..db2183e26 100644
--- a/apps/server/web/src/components/connect/verify-browser-key-destination.tsx
+++ b/apps/server/web/src/components/connect/verify-browser-key-destination.tsx
@@ -1,4 +1,6 @@
import { confirmAction } from "@internal/node-admin";
+import { FactList } from "@/components/fact-list";
+import { WarningNotice } from "@/components/ui/notice";
import { browserHostFingerprint } from "@/lib/ssh-browser-signer-core";
import type { LocalKeyPreparation } from "@/lib/ssh-local-key";
@@ -21,28 +23,18 @@ export async function verifyBrowserKeyDestination(
title: "Use this key to connect?",
description: (
-
-
-
Connecting node
- {selection.nodeName}
-
-
-
Key file
- {selection.keyFileName}
-
-
-
SSH command
- {command}
-
-
-
Host fingerprint
- {fingerprint}
-
-
-
+
+
The node “{selection.nodeName}” supplied this fingerprint. Verify it through a trusted channel before
continuing.
-
+
),
confirmLabel: "Use key and connect",
diff --git a/apps/server/web/src/components/connect/verify-destination.tsx b/apps/server/web/src/components/connect/verify-destination.tsx
index 2c95610b2..c3cfdb41b 100644
--- a/apps/server/web/src/components/connect/verify-destination.tsx
+++ b/apps/server/web/src/components/connect/verify-destination.tsx
@@ -1,4 +1,6 @@
import { ApiError, apiPost, confirmAction } from "@internal/node-admin";
+import { FactList } from "@/components/fact-list";
+import { WarningNotice } from "@/components/ui/notice";
import type { SshLaunchRequest, SshLaunchResponse } from "@/lib/ssh";
/** A first connection can ask for trust; a changed identity must never take this path. */
@@ -24,20 +26,21 @@ export async function launchWithDestinationVerification(
title: "Connect to this destination?",
description: (
-
{check.destination}
+
Subshell hasn’t verified this destination before. Continuing remembers its identity and blocks future
connections if it changes.
If you aren’t sure this is the right machine, cancel and check with its administrator.
-
- Verification details
-
- This identity came from the destination and hasn’t been independently verified. Its administrator can
- confirm this fingerprint:
-
- {check.fingerprint}
-
+
+ This identity came from the destination and hasn’t been independently verified. Its administrator can confirm
+ this fingerprint.
+
),
confirmLabel: "Trust and connect",
diff --git a/apps/server/web/src/components/device-name-card.tsx b/apps/server/web/src/components/device-name-card.tsx
index 628ab9837..4f12c2d9d 100644
--- a/apps/server/web/src/components/device-name-card.tsx
+++ b/apps/server/web/src/components/device-name-card.tsx
@@ -18,15 +18,20 @@ export function DeviceNameCard(): JSX.Element {
// Lazy read: the choice is browser-local, so there is no in-flight state to
// wait for (unlike the server-backed switches).
const [value, setValue] = useState(() => deviceName());
+ const [savedName, setSavedName] = useState(() => deviceName());
const [saved, setSaved] = useState(false);
const derived = deviceNameFromUserAgent(navigator.userAgent ?? "");
const cleaned = normalizeDeviceLabel(value);
// The blank case is a real choice (restore the default), not an error.
const willBe = cleaned || derived;
+ const unchanged = willBe === savedName;
+
function save(): void {
+ if (unchanged) return;
setDeviceName(value);
setValue(deviceName());
+ setSavedName(deviceName());
setSaved(true);
}
@@ -56,7 +61,13 @@ export function DeviceNameCard(): JSX.Element {
}}
className="w-64"
/>
-
+
Save
{saved && Saved. New attachments use it }
diff --git a/apps/server/web/src/components/fact-list.tsx b/apps/server/web/src/components/fact-list.tsx
new file mode 100644
index 000000000..842c1640c
--- /dev/null
+++ b/apps/server/web/src/components/fact-list.tsx
@@ -0,0 +1,19 @@
+import { Fact } from "@internal/node-admin";
+import type { ReactNode } from "react";
+import { CopyButton } from "@/components/copy-button";
+
+/** Facts keep their values readable, including long paths and fingerprints. */
+export function FactList({ rows }: { rows: { label: string; value: ReactNode; mono?: boolean; copyText?: string }[] }) {
+ return (
+
+ {rows.map(({ label, value, mono, copyText }) => (
+
+
+ {value}
+ {copyText !== undefined && }
+
+
+ ))}
+
+ );
+}
diff --git a/apps/server/web/src/components/instance-name-card.tsx b/apps/server/web/src/components/instance-name-card.tsx
index 92bb6fedf..18f83bbc4 100644
--- a/apps/server/web/src/components/instance-name-card.tsx
+++ b/apps/server/web/src/components/instance-name-card.tsx
@@ -43,7 +43,9 @@ export function InstanceNameCard(): JSX.Element {
const current = value ?? "";
const cleaned = normalizeLabel(current, INSTANCE_NAME_MAX);
+ const unchanged = cleaned === settings?.instanceName;
async function save(): Promise {
+ if (busy || value === null || unchanged) return;
setBusy(true);
setError(null);
try {
@@ -96,8 +98,14 @@ export function InstanceNameCard(): JSX.Element {
variant="outline"
size="sm"
onClick={() => void save()}
- disabled={busy || value === null}
- disabledReason={busy ? "Wait for the name to save." : "Wait for the current name to load."}
+ disabled={busy || value === null || unchanged}
+ disabledReason={
+ busy
+ ? "Wait for the name to save."
+ : unchanged
+ ? "No instance name changes to save."
+ : "Wait for the current name to load."
+ }
>
Save
diff --git a/apps/server/web/src/components/networking/addresses-card.tsx b/apps/server/web/src/components/networking/addresses-card.tsx
index cd75d983e..838ad5c77 100644
--- a/apps/server/web/src/components/networking/addresses-card.tsx
+++ b/apps/server/web/src/components/networking/addresses-card.tsx
@@ -211,7 +211,9 @@ export function AddressesCard({ view, restart }: { view: ServerDeployment; resta
const [drafts, setDrafts] = useState>>({});
const [confirming, setConfirming] = useState(false);
const update = useUpdateServerConfig();
- const touched = Object.keys(drafts) as EditableKey[];
+ const touched = (Object.keys(drafts) as EditableKey[]).filter(
+ (key) => wireValue(key, drafts[key] ?? "") !== wireValue(key, view.settings[key].saved),
+ );
const needsRestart = touched.some((key) => !LIVE_KEYS.has(key));
const fieldFailure = invalidField(update.error);
@@ -252,6 +254,7 @@ export function AddressesCard({ view, restart }: { view: ServerDeployment; resta
const [focusedKey, setFocusedKey] = useState(null);
function save(): void {
+ if (touched.length === 0 || update.isPending) return;
// The gate already refuses an invalid touched value; this guard is the
// guarantee behind the explanation, kept because a disabled button is
// not a guarantee of this function's preconditions.
diff --git a/apps/server/web/src/components/networking/network-addresses.tsx b/apps/server/web/src/components/networking/network-addresses.tsx
index 6f1f231a1..e584211db 100644
--- a/apps/server/web/src/components/networking/network-addresses.tsx
+++ b/apps/server/web/src/components/networking/network-addresses.tsx
@@ -1,4 +1,4 @@
-import { CopyableValue } from "@internal/node-admin";
+import { CopyableValue, Fact } from "@internal/node-admin";
import type { NetworkAddress } from "@/types/network";
/**
@@ -36,17 +36,8 @@ function secureContextLine(address: NetworkAddress): string {
* trailing it — and an operator read the two as disjoint things, because a
* tag that follows a bold value names nothing until you scan back from it.
*
- * **The label is a DATA label, not a control label** (amended 2026-09-16, same
- * operator's second read). The first pass gave it the form-label grammar
- * (`font-strong text-label`), and the card then held both grammars six pixels
- * apart: bold "NetBird FQDN" over its URL, quiet "Client version" over its —
- * one read as a heading and one as data, so the card looked like two systems
- * rendering two kinds of thing. These rows are read-only facts, so they take
- * the `Fact`/`dt` grammar verbatim: `text-muted-foreground`, no weight token.
- * The `text-sm` sits on the list, exactly as it sits on the `` beside it,
- * rather than on the label — that is what keeps the two from drifting if
- * {@link Fact} ever changes. `font-strong` belongs to a control's label and to
- * a section heading, and the "Addresses" heading above keeps it.
+ * Read-only addresses use the shared Fact component so labels and values
+ * stay consistent with status cards and dialogs.
*
* The secure-context sentence keeps its line below the value — see
* {@link secureContextLine} for why it appears on every address, the good one
@@ -55,16 +46,15 @@ function secureContextLine(address: NetworkAddress): string {
export function NetworkAddresses({ addresses }: { addresses: NetworkAddress[] }) {
if (addresses.length === 0) return null;
return (
-
+
{addresses.map((address) => (
-
- {address.label}
+
{secureContextLine(address)}
-
+
))}
-
+
);
}
diff --git a/apps/server/web/src/components/networking/network-settings-form.tsx b/apps/server/web/src/components/networking/network-settings-form.tsx
index 4b406f221..75d6c6846 100644
--- a/apps/server/web/src/components/networking/network-settings-form.tsx
+++ b/apps/server/web/src/components/networking/network-settings-form.tsx
@@ -116,7 +116,13 @@ export function NetworkSettingsForm({
const describedBy = reason ? { "aria-describedby": reasonId } : {};
const [drafts, setDrafts] = useState>({});
const update = useUpdateNetworkSettings();
- const touched = Object.keys(drafts);
+ const touched = Object.keys(drafts).filter((key) => {
+ const field = fields.find((candidate) => candidate.key === key);
+ if (!field) return false;
+ return field.type === "secret"
+ ? drafts[key] !== ""
+ : drafts[key] !== (field.type === "boolean" ? String(savedBoolean(row, field)) : savedValue(row, field));
+ });
// The box the caret is in right now; the required sentence waits for it to
// leave, exactly as in `AddressesCard` (no complaint mid-thought).
const [focusedKey, setFocusedKey] = useState(null);
@@ -155,9 +161,9 @@ export function NetworkSettingsForm({
// this is the guarantee behind the explanation (a disabled button is not
// a precondition guard). A required-and-empty save would only earn the
// route's 400, so it never fires.
- if (missingRequired) return;
+ if (missingRequired || touched.length === 0 || disabled || update.isPending) return;
update.mutate(
- { id: row.id, settings: drafts },
+ { id: row.id, settings: Object.fromEntries(touched.map((key) => [key, drafts[key]])) },
// The answer is the list refetch, which re-seeds every field from what
// the server stored — including anything it canonicalized on the way in.
{ onSuccess: () => setDrafts({}) },
diff --git a/apps/server/web/src/components/nodes/node-key-setup.tsx b/apps/server/web/src/components/nodes/node-key-setup.tsx
index 131f1fd5f..88300070d 100644
--- a/apps/server/web/src/components/nodes/node-key-setup.tsx
+++ b/apps/server/web/src/components/nodes/node-key-setup.tsx
@@ -2,6 +2,7 @@ import { Label } from "@internal/node-admin";
import { NODE_TARGETS, SUBSHELL_REPO_SLUG } from "@internal/subshell-protocol";
import { type ReactNode, useId, useState } from "react";
import { CopyCommandRow } from "@/components/copy-command-row";
+import { FactList } from "@/components/fact-list";
import { Segmented } from "@/components/ui/segmented";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { usePublicSettings } from "@/hooks/use-public-settings";
@@ -335,25 +336,17 @@ export function NodeKeySetup({
.
- {/* Then the two values, and nothing else. The app cannot be handed a
- command — its Enroll step takes these two — so the rows ARE the rest
- of the instruction.
- Label over value, the line-item shape: two rows, and the button in each
- says out loud which of the two it copies. */}
-
-
-
Setup key
- {/* The placeholder names the one press that fills this row — and the row
- above it was copyable all along, because it needs no key. */}
- {keyText === null ? (
-
Generate setup key first
- ) : (
-
- )}
-
+
)}
>
diff --git a/apps/server/web/src/components/presets/__tests__/ssh-preset-editor.test.tsx b/apps/server/web/src/components/presets/__tests__/ssh-preset-editor.test.tsx
index c35b51307..ecb63be29 100644
--- a/apps/server/web/src/components/presets/__tests__/ssh-preset-editor.test.tsx
+++ b/apps/server/web/src/components/presets/__tests__/ssh-preset-editor.test.tsx
@@ -92,7 +92,7 @@ it("saves the local-key radio choice without requiring a file or approval", asyn
expect(screen.getByRole("radio", { name: "Use a key from my machine" }).getAttribute("aria-checked")).toBe("true");
const localKeyChoice = screen.getByRole("radio", { name: "Use a key from my machine" });
expect(document.getElementById(localKeyChoice.getAttribute("aria-describedby") ?? "")?.textContent).toContain(
- "Choose a key from your machine when creating a new SSH subshell",
+ "Choose a private key on the device you’re using now.",
);
expect(screen.queryByRole("button", { name: "Choose key…" })).toBeNull();
expect(screen.queryByRole("checkbox", { name: "I trust this website and its operator" })).toBeNull();
diff --git a/apps/server/web/src/components/presets/command-paste-field.tsx b/apps/server/web/src/components/presets/command-paste-field.tsx
index 619aa3e0f..5aa34bbd5 100644
--- a/apps/server/web/src/components/presets/command-paste-field.tsx
+++ b/apps/server/web/src/components/presets/command-paste-field.tsx
@@ -1,4 +1,4 @@
-import { errMessage } from "@internal/node-admin";
+import { errMessage, Fact } from "@internal/node-admin";
import { useMemo, useState } from "react";
import { Textarea } from "@/components/ui/textarea";
import { parseCommandPaste, presetFormToCommand } from "@/lib/preset-command";
@@ -116,25 +116,23 @@ export function CommandPasteField({
: `${envCount} env var${envCount === 1 ? "" : "s"} · ${flagCount} flag${flagCount === 1 ? "" : "s"}`}
{command.env.length > 0 && (
-
+
{command.env.map((row) => (
-
-
{row.key}
- {row.value || "(empty)"}
-
+
+ {row.value || "(empty)"}
+
))}
)}
{command.flags.length > 0 && (
-
+
{command.flags.map((row, i) => (
// Flags are positional and may legitimately repeat
// (`--allow a --allow b`), so the index is the only key.
// biome-ignore lint/suspicious/noArrayIndexKey: see above
-
-
{row.flag}
- {row.value}
-
+
+ {row.value || "No value"}
+
))}
)}
diff --git a/apps/server/web/src/components/profile-card.tsx b/apps/server/web/src/components/profile-card.tsx
index 00df53583..99700acb0 100644
--- a/apps/server/web/src/components/profile-card.tsx
+++ b/apps/server/web/src/components/profile-card.tsx
@@ -1,6 +1,7 @@
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Input, Label } from "@internal/node-admin";
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
+import { FactList } from "@/components/fact-list";
import { useCurrentUser } from "@/lib/auth";
import { authClient } from "@/lib/auth-client";
import { CURRENT_USER_QUERY_KEY } from "@/lib/query-keys";
@@ -55,9 +56,11 @@ export function ProfileCard({ updateUser = (input) => authClient.updateUser(inpu
const shown = nameDraft ?? user?.name ?? "";
+ const unchanged = nameIsUsable(shown) === user?.name;
+
async function save() {
const name = nameIsUsable(shown);
- if (name === null) return;
+ if (name === null || unchanged || busy) return;
setBusy(true);
setError(null);
setSaved(false);
@@ -111,19 +114,20 @@ export function ProfileCard({ updateUser = (input) => authClient.updateUser(inpu
void save()}
- disabled={busy || nameIsUsable(shown) === null}
- disabledReason={busy ? "Wait for your profile to save." : "Enter a display name first."}
+ disabled={busy || nameIsUsable(shown) === null || unchanged}
+ disabledReason={
+ busy
+ ? "Wait for your profile to save."
+ : unchanged
+ ? "No profile changes to save."
+ : "Enter a display name first."
+ }
>
{busy ? "Saving…" : "Save"}
{saved && saved }
-
- E-mail
- {/* Read-only by design: the email IS the credential; changing it
- is not offered here (muted to say so at a glance). */}
-
-
+
{error && {error}
}
>
)}
diff --git a/apps/server/web/src/components/prompts/__tests__/stack-form-dialog.test.tsx b/apps/server/web/src/components/prompts/__tests__/stack-form-dialog.test.tsx
index 5e63d3ef1..5f6dfcf02 100644
--- a/apps/server/web/src/components/prompts/__tests__/stack-form-dialog.test.tsx
+++ b/apps/server/web/src/components/prompts/__tests__/stack-form-dialog.test.tsx
@@ -132,11 +132,13 @@ describe("StackFormDialog", () => {
shared: false,
},
});
+ expect(saveButton(true).matches(":disabled, [aria-disabled=true]")).toBe(true);
+ fireEvent.change(screen.getByLabelText(/Label/), { target: { value: "Changed label" } });
fireEvent.click(saveButton(true));
await waitFor(() => expect(m.calls.length).toBe(1));
// The unlabeled inline row rides UNLABELED (the placeholder is display).
expect(m.calls[0]?.body).toEqual({
- label: "Mixed",
+ label: "Changed label",
items: [{ promptId: "p1" }, { body: "mine" }],
shared: false,
});
@@ -149,6 +151,8 @@ describe("StackFormDialog", () => {
const m = mockFetch({ failWith: "The stack's text joined is 30000 characters" });
try {
renderDialog({ editingId: "st1", initial: { label: "Big", blocks: [block({ body: "x" })], shared: false } });
+ expect(saveButton(true).matches(":disabled, [aria-disabled=true]")).toBe(true);
+ fireEvent.change(screen.getByLabelText(/Label/), { target: { value: "Changed label" } });
fireEvent.click(saveButton(true));
const alert = await screen.findByRole("alert");
expect(alert.textContent).toContain("30000 characters");
diff --git a/apps/server/web/src/components/prompts/prompt-form-dialog.tsx b/apps/server/web/src/components/prompts/prompt-form-dialog.tsx
index 1ce6a083e..8d6223a23 100644
--- a/apps/server/web/src/components/prompts/prompt-form-dialog.tsx
+++ b/apps/server/web/src/components/prompts/prompt-form-dialog.tsx
@@ -1,4 +1,5 @@
import { Button, errMessage, Input, Label, Switch } from "@internal/node-admin";
+import { useStore } from "@tanstack/react-form";
import { useState } from "react";
import {
Dialog,
@@ -45,10 +46,17 @@ export function PromptFormDialog({
const update = useUpdatePrompt();
const busy = create.isPending || update.isPending;
+ const unchangedDraft = (values: PromptDraft) =>
+ !!editingId &&
+ !!initial &&
+ values.description.trim() === initial.description.trim() &&
+ values.body === initial.body &&
+ values.shared === initial.shared;
const form = makeForm({
defaultValues: initial ?? { description: "", body: "", shared: false },
validator: promptDraftSchema,
onSubmit: async (draft) => {
+ if (unchangedDraft(draft)) return;
// The guard behind the gate (Enter-key paths, races).
if (draft.description.trim() === "" || draft.body.trim() === "") return;
setServerError(null);
@@ -65,8 +73,10 @@ export function PromptFormDialog({
}
},
});
- const disabled = useSubmitDisabled(form, busy);
- const disabledReason = useSubmitDisabledReason(form, busy);
+ const unchanged = useStore(form.store, (state) => unchangedDraft(state.values));
+ const disabled = useSubmitDisabled(form, busy) || unchanged;
+ const validationReason = useSubmitDisabledReason(form, busy);
+ const disabledReason = validationReason ?? (unchanged ? "No changes to save." : undefined);
return (
diff --git a/apps/server/web/src/components/prompts/stack-form-dialog.tsx b/apps/server/web/src/components/prompts/stack-form-dialog.tsx
index 1c78b94b3..b4942e2f2 100644
--- a/apps/server/web/src/components/prompts/stack-form-dialog.tsx
+++ b/apps/server/web/src/components/prompts/stack-form-dialog.tsx
@@ -1,4 +1,5 @@
import { Button, errMessage, Input, Label, Switch } from "@internal/node-admin";
+import { useStore } from "@tanstack/react-form";
import { Plus } from "lucide-react";
import { useId, useState } from "react";
import { PromptPickerBody } from "@/components/prompts/prompt-picker-body";
@@ -55,6 +56,12 @@ export function StackFormDialog({
const update = useUpdatePromptStack();
const busy = create.isPending || update.isPending;
+ const unchangedDraft = (values: { label: string; blocks: PromptBlock[]; shared: boolean }) =>
+ !!editingId &&
+ !!initial &&
+ values.label.trim() === initial.label.trim() &&
+ values.shared === initial.shared &&
+ JSON.stringify(stackMembersFromBlocks(values.blocks)) === JSON.stringify(stackMembersFromBlocks(initial.blocks));
const form = makeForm({
defaultValues: {
label: initial?.label ?? "",
@@ -63,6 +70,7 @@ export function StackFormDialog({
},
validator: makePromptStackSchema(editingId ? 0 : 1),
onSubmit: async (values) => {
+ if (unchangedDraft(values)) return;
// The guard behind the gate (Enter-key paths, races).
if (values.label.trim() === "" || (!editingId && values.blocks.length === 0)) return;
setServerError(null);
@@ -82,8 +90,10 @@ export function StackFormDialog({
}
},
});
- const disabled = useSubmitDisabled(form, busy);
- const disabledReason = useSubmitDisabledReason(form, busy);
+ const unchanged = useStore(form.store, (state) => unchangedDraft(state.values));
+ const disabled = useSubmitDisabled(form, busy) || unchanged;
+ const validationReason = useSubmitDisabledReason(form, busy);
+ const disabledReason = validationReason ?? (unchanged ? "No changes to save." : undefined);
return (
diff --git a/apps/server/web/src/components/service/restart-dialog.tsx b/apps/server/web/src/components/service/restart-dialog.tsx
index 56c5297ea..f1f1fa42d 100644
--- a/apps/server/web/src/components/service/restart-dialog.tsx
+++ b/apps/server/web/src/components/service/restart-dialog.tsx
@@ -9,6 +9,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
+import { WarningNotice } from "@/components/ui/notice";
import type { ServerDeployment } from "@/types/server-deployment";
/**
@@ -96,12 +97,19 @@ export function RestartDialog({
Restart the server?
{kills
- ? view.service.manager === "docker"
- ? "Restarting the Docker container closes every running subshell. Open terminals reconnect after the server returns."
- : `This server's service definition will close every running subshell. Rewrite it by running ${REINSTALL_COMMAND} on this machine, or restart anyway.`
+ ? "Open terminals reconnect after the server returns."
: "Running subshells keep running; open terminals reconnect in a few seconds."}
+ {kills && (
+
+ {view.service.manager === "docker"
+ ? "Restarting the Docker container closes every running subshell."
+ : view.service.paneSafety === "kills"
+ ? `This server's service definition will close every running subshell. Rewrite it by running ${REINSTALL_COMMAND} on this machine, or restart anyway.`
+ : `We couldn't determine whether running subshells will survive. Restarting may close them. Rewrite the service definition by running ${REINSTALL_COMMAND} on this machine, or restart anyway.`}
+
+ )}
{enables ? (
{enables}
) : (
diff --git a/apps/server/web/src/components/service/supervision-dialog.tsx b/apps/server/web/src/components/service/supervision-dialog.tsx
index ad4207d4b..97b1f8c17 100644
--- a/apps/server/web/src/components/service/supervision-dialog.tsx
+++ b/apps/server/web/src/components/service/supervision-dialog.tsx
@@ -10,6 +10,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
+import { WarningNotice } from "@/components/ui/notice";
import type { PaneSafety, ServerDeployment } from "@/types/server-deployment";
/**
@@ -120,14 +121,18 @@ export function SupervisionDialog({
{target &&
- consequences(target, view.platform, autostart, view.service.paneSafety).map((line) => (
-
-
- •
-
- {line}
-
- ))}
+ consequences(target, view.platform, autostart, view.service.paneSafety)
+ .filter((_, index) => !lethal || index !== 3)
+ .map((line) => (
+
+
+ •
+
+
+ {line}
+
+
+ ))}
{!toApp && (
@@ -136,11 +141,18 @@ export function SupervisionDialog({
)}
{lethal && (
-
- {view.service.paneSafety === "kills"
- ? "This machine's service definition predates the setting that spares live panes. Running `subshell-server service install` on that machine rewrites it."
- : "This machine's service definition could not be read, so this is the safe assumption."}
-
+
+
+ {view.service.paneSafety === "kills"
+ ? "Every running subshell will be closed"
+ : "Whether running subshells survive could not be determined"}
+
+
+ {view.service.paneSafety === "kills"
+ ? "This machine's service definition predates the setting that spares live panes. Running `subshell-server service install` on that machine rewrites it."
+ : "This machine's service definition could not be read. Switching may close running subshells."}
+
+
)}
This page will lose its connection for a few seconds while the server comes back.
diff --git a/apps/server/web/src/components/sharing-dialog-core.tsx b/apps/server/web/src/components/sharing-dialog-core.tsx
index 32c5f7196..21462ab59 100644
--- a/apps/server/web/src/components/sharing-dialog-core.tsx
+++ b/apps/server/web/src/components/sharing-dialog-core.tsx
@@ -9,6 +9,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
+import { ErrorNotice, WarningNotice } from "@/components/ui/notice";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import type { RosterUser, ShareDraft } from "@/hooks/use-subshell-shares";
import type { SubshellAccess } from "@/types/subshell";
@@ -116,6 +117,12 @@ export function SharingDialogCore({
const pickerId = useId();
const grants = draft ?? serverGrants;
+ // Grant order isn't meaningful: removing and re-adding the same access is unchanged.
+ const savedPermissions = new Map(serverGrants.map((grant) => [grant.granteeUserId, grant.permission]));
+ const hasChanges =
+ grants.length !== serverGrants.length ||
+ grants.some((grant) => savedPermissions.get(grant.granteeUserId) !== grant.permission);
+ const saveDisabled = saving || isLoading || !hasChanges;
const alreadyShared = new Set(grants.map((g) => g.granteeUserId ?? EVERYONE));
const recipientOptions = [
@@ -149,6 +156,7 @@ export function SharingDialogCore({
}
async function save() {
+ if (!canManage || saveDisabled) return;
setError(null);
try {
await onSave(grants);
@@ -168,23 +176,26 @@ export function SharingDialogCore({
{/* Before the grant rows, not after: the disclosure has to be read
while deciding, not discovered under the Save button. */}
- {warning != null && (
-
- {warning}
-
- )}
+ {warning != null && {warning} }
+ {canManage && (
+
+ Save applies all additions, removals, and access changes. Cancel discards them.
+
+ )}
{isLoading &&
Loading…
}
{!isLoading && grants.length === 0 && (
-
Not shared with anyone yet.
+
+ {hasChanges ? "No one will have shared access after you save." : "Not shared with anyone yet."}
+
)}
{grants.map((g, i) => (
-
+
{g.granteeUserId === null
? "Everyone"
: (roster.find((u) => u.id === g.granteeUserId)?.email ?? g.granteeUserId)}
@@ -196,7 +207,13 @@ export function SharingDialogCore({
value={g.permission}
onChange={(level) => changeLevel(i, level)}
/>
- removeAt(i)} aria-label="Remove">
+ removeAt(i)}
+ aria-label={`Remove access for ${g.granteeUserId === null ? "Everyone" : (roster.find((u) => u.id === g.granteeUserId)?.email ?? "this user")}`}
+ >
Remove
>
@@ -237,11 +254,7 @@ export function SharingDialogCore({
)}
- {error && (
-
- {error}
-
- )}
+ {error &&
{error} }
@@ -251,9 +264,13 @@ export function SharingDialogCore({
{canManage && (
void save()}
- disabled={saving || (isLoading && draft === null)}
+ disabled={saveDisabled}
disabledReason={
- saving ? "Wait for sharing changes to save." : "Wait for the current sharing settings to load."
+ saving
+ ? "Wait for sharing changes to save."
+ : isLoading
+ ? "Wait for the current sharing settings to load."
+ : "No sharing changes to save."
}
>
{saving ? "Saving…" : "Save"}
diff --git a/apps/server/web/src/components/system-key-create-dialog.tsx b/apps/server/web/src/components/system-key-create-dialog.tsx
index 6bdb936ae..85929370d 100644
--- a/apps/server/web/src/components/system-key-create-dialog.tsx
+++ b/apps/server/web/src/components/system-key-create-dialog.tsx
@@ -95,7 +95,7 @@ export function SystemKeyCreateDialog({
Use it as the request's Authorization header:{" "}
Authorization: Bearer {created.key}
-
+
Store it somewhere safe. If you lose it, you'll need to create a new key.
diff --git a/apps/server/web/src/components/ui/choice-fields.tsx b/apps/server/web/src/components/ui/choice-fields.tsx
new file mode 100644
index 000000000..c24c03516
--- /dev/null
+++ b/apps/server/web/src/components/ui/choice-fields.tsx
@@ -0,0 +1,13 @@
+import type { ReactNode } from "react";
+
+/** Keep a choice's controls beside it, with a boundary distinct from its label. */
+export function ChoiceFields({ labelledBy, children }: { labelledBy: string; children: ReactNode }) {
+ return (
+
+ );
+}
diff --git a/apps/server/web/src/components/ui/notice.tsx b/apps/server/web/src/components/ui/notice.tsx
new file mode 100644
index 000000000..3f8bcf2ce
--- /dev/null
+++ b/apps/server/web/src/components/ui/notice.tsx
@@ -0,0 +1,21 @@
+import { cn } from "@internal/node-admin";
+import { TriangleAlert } from "lucide-react";
+import type { ReactNode } from "react";
+
+/** Persistent consequences stay visible without announcing an error on dialog open. */
+export function WarningNotice({ children }: { children: ReactNode }) {
+ return (
+
+ );
+}
+
+export function ErrorNotice({ children, className }: { children: ReactNode; className?: string }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/server/web/src/components/updates/update-dialog.tsx b/apps/server/web/src/components/updates/update-dialog.tsx
index 36d9c7983..26a2bf1f3 100644
--- a/apps/server/web/src/components/updates/update-dialog.tsx
+++ b/apps/server/web/src/components/updates/update-dialog.tsx
@@ -1,4 +1,5 @@
import { Button } from "@internal/node-admin";
+import { FactList } from "@/components/fact-list";
import { REINSTALL_COMMAND } from "@/components/service/service-card";
import {
Dialog,
@@ -8,6 +9,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
+import { WarningNotice } from "@/components/ui/notice";
import type { ServerUpdateView } from "@/types/updates";
/**
@@ -52,13 +54,24 @@ export function UpdateDialog({
Update this server?
-
- Update to {to}.{" "}
- {kills
- ? `This server's service definition will close every running subshell when it restarts. Rewrite it by running ${REINSTALL_COMMAND} on this machine, or update anyway.`
- : "The server restarts when the new binary is in place; open subshells keep running."}
-
+ The server restarts when the new binary is in place.
+
+ {kills ? (
+
+ {view.paneSafety === "kills"
+ ? "This server’s service definition will close every running subshell when it restarts."
+ : "We couldn’t determine whether running subshells will survive the restart. Updating may close them."}{" "}
+ Rewrite the service definition by running {REINSTALL_COMMAND} on this machine, or update anyway.
+
+ ) : (
+ Open subshells keep running.
+ )}
The database is backed up to {view.backups.dir} first
{view.backups.keep === 0 ? " (all kept)" : ` (${view.backups.keep} kept)`}. If the new version can't start,
diff --git a/apps/server/web/src/routes/nodes_.$id.tsx b/apps/server/web/src/routes/nodes_.$id.tsx
index 0d5e82d04..f7b9b8c15 100644
--- a/apps/server/web/src/routes/nodes_.$id.tsx
+++ b/apps/server/web/src/routes/nodes_.$id.tsx
@@ -3,6 +3,7 @@ import {
Button,
confirmAction,
errMessage,
+ Fact,
NodeAllowedDirs,
NodeMaintenanceCard,
relativeElapsed,
@@ -146,10 +147,9 @@ function NodeDetailPage() {
)}
-
-
-
Status
-
+
+
+
{n.status}
{/* Any mismatch, either direction: the protocol is matched
EXACTLY, so a node ahead of the server is refused just as
@@ -167,8 +167,8 @@ function NodeDetailPage() {
{n.protocolVersion < NODE_PROTOCOL_VERSION ? "node too old" : "node too new"}
)}
-
-
+
+
{/* Node facts, and `local` runs no node daemon — it is a launch
target the server drives in-process through `LocalLauncher`,
with no daemon, no socket and no enrollment. So `lastSeenAt`
@@ -178,26 +178,18 @@ function NodeDetailPage() {
node rather than as questions this row cannot be asked. Same
reason `local` is excluded from the version-floor list. */}
{n.kind !== "local" && (
-
-
Last seen
- {n.lastSeenAt ? relativeElapsed(n.lastSeenAt) : "never"}
-
+ {n.lastSeenAt ? relativeElapsed(n.lastSeenAt) : "never"}
)}
-
-
OS / arch
-
- {osLabel(n.os)}
- {n.arch ? ` · ${n.arch}` : ""}
-
-
-
-
Hostname
- {n.hostname ?? "—"}
-
+
+ {osLabel(n.os)}
+ {n.arch ? ` · ${n.arch}` : ""}
+
+
+ {n.hostname ?? "—"}
+
{n.kind !== "local" && (
-
-
Node version
-
+
+
{n.agentVersion ?? "—"}
{/* The OTHER refusal gate, and an independent one: the floor
is raised whenever the server needs newer node behaviour,
@@ -212,34 +204,29 @@ function NodeDetailPage() {
below minimum ({MIN_NODE_VERSION})
)}
-
-
+
+
)}
-
-
Your access
- {n.access}
-
+ {n.access}
{/* The server sends `ownerLabel` to ADMINS only (node-view
schema): the operator whose list shows every machine learns
whose machine this is. Every other viewer's payload has no
field, so nothing renders for them. */}
{n.ownerLabel !== undefined && (
-
-
Owner
- {n.ownerLabel}
-
+
+ {n.ownerLabel}
+
)}
{n.capabilities.length > 0 && (
-
-
Capabilities
-
+
+
{n.capabilities.map((c) => (
{c}
))}
-
-
+
+
)}
diff --git a/docs/README.md b/docs/README.md
index 77cf9a5a7..f88970d05 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -12,6 +12,7 @@ notes should link to them and explain only the additional engineering details.
- [Security follow-ups](security-actionable.md): unresolved work, decision triggers, and regression history.
- [Release and CI](release-and-ci.md): build, signing, publishing, updates, and deployment mechanics.
- [Design system](design-system.md): product tokens, dashboard layouts, responsive tables, interaction patterns, and implementation constraints.
+- [Dashboard UX review](ux-review-2026-10-17.md): October 2026 findings, corrections, verification scope, and remaining coverage limits.
- [Documentation authoring](../apps/docs/AGENTS.md) and [style guide](../apps/docs/STYLE.md): public site structure, language, visuals, and verification.
- [Proxmox submission research](proxmox-community-submission.md): external acceptance criteria and future integration work.
diff --git a/docs/design-system.md b/docs/design-system.md
index 5af48e115..3d8450d9e 100644
--- a/docs/design-system.md
+++ b/docs/design-system.md
@@ -252,9 +252,9 @@ admitted here.
- **Line item**: `label` over `detail`, differing by weight AND colour, never
by tone alone. *(15px regular over 13px muted read as one paragraph.)*
- **Quiet label vs control label**: a read-only data row labels over its value
- with the label in `muted-foreground` at `body` size, the `Fact`/`dt` grammar,
- and it takes that size from the list or `` around it rather than from the
- label itself. `font-strong` label weight belongs to a control's label and to a
+ with the label in `muted-foreground` at `detail` size and the value at `body`
+ size in `foreground`, using the shared `Fact` component. Monospace values keep
+ the same body size. Size and colour together distinguish labels from values. `font-strong` label weight belongs to a control's label and to a
section heading, ONLY. *(A networking card set bold "NetBird FQDN" over its URL
six pixels above a quiet "Client version" over its: two label grammars in one
card, so one row read as a heading and the other as data, and neither was the
@@ -364,10 +364,31 @@ admitted here.
label. Use `aria-labelledby` for the title and `aria-describedby` for the
description so assistive technology announces their separate roles. This applies
to native radios and shared radio components across web and desktop surfaces.
+- **Dependent forms**: keep a selected radio option's fields directly beneath
+ that option, inside a bordered, inset panel (`ChoiceFields` in the SPA).
+ Keep the panel outside the clickable label, associate it with the option's
+ title, and keep field labels visible. The boundary distinguishes the subform
+ from option descriptions without moving it away from its choice. Every option
+ keeps its short clickable description. Settings shared by the whole group can
+ use a named section below it. Equivalent branches use the same
+ container treatment.
+- **Dialog facts**: use the SPA's `FactList` for review and approval details:
+ muted detail-size labels over foreground body-size values, using the shared
+ `Fact` component also used by status and node cards. Technical values may
+ use monospace without becoming smaller. Wrap decision-critical values rather
+ than truncating paths, destinations, or fingerprints; offer copying when useful.
+- **Pending list edits**: when Add or Remove changes a draft, explain that Save
+ applies the changes. Disable Save until the draft differs from saved data,
+ including after edits are undone; don't add an unsaved-changes message.
+ Compare list contents rather than their order. Empty-state wording must describe
+ the pending result rather than claiming the saved state has changed.
+- **Confirmation warnings**: present disruptive consequences in `WarningNotice`,
+ separate from introductory text and target facts. Failed operations use
+ `ErrorNotice`; prerequisites and restrictions remain gold.
- **Choice group**: radios inside `role="radiogroup"` with an `aria-label`; a
- dependent setting sits BELOW the group after a rule, never indented under one
- option; its dependency is a disabled control that says why and names what
- would answer. *("needs the box above" named a widget.)*
+ setting specific to one option uses its inset `ChoiceFields` panel. Settings
+ shared by the group sit below it after a rule; unavailable settings explain
+ their dependency and what would make them available. *("needs the box above" named a widget.)*
- **Copy affordance**: the copy ICON, at `icon-sm`, never the word "Copy" as a button
label, and never a caption of its own: what is copied is named by `aria-label`
(`Copy server address`), and the icon is a `Check` in `text-success` for the 1.5 s the
diff --git a/docs/ux-review-2026-10-17.md b/docs/ux-review-2026-10-17.md
new file mode 100644
index 000000000..e1992384b
--- /dev/null
+++ b/docs/ux-review-2026-10-17.md
@@ -0,0 +1,65 @@
+# Dashboard UX review, 2026-10-17
+
+This review covers the server dashboard and the shared node-admin components
+it renders. The first pass missed shared runtime facts and some conditional
+forms. This follow-up reviews the recurring patterns across routes and their
+components; it is not a claim that every possible product state was exercised.
+
+## Findings addressed
+
+| Pattern | Finding and correction | Surfaces |
+| --- | --- | --- |
+| Read-only facts | Labels and values had similar size and color, and several screens implemented their own pairs. Shared `Fact` now uses smaller muted labels and body-sized foreground values, including monospace values. | Runtime, Status, node details, SSH trust, Networking, clone, enrollment, backup summaries, destination verification, provider removal, command previews |
+| Long values | Some review values were truncated or could force a wide layout. Facts wrap complete values; machine facts collapse to one column on narrow screens. | Addresses, paths, fingerprints, commands, SSH summaries, node metadata |
+| Radio-dependent fields | Fields below the whole group made their association unclear. The selected option now contains an inset, bordered field panel immediately below its description, outside its clickable label. | SSH authentication, SSH forwarding, Restore source and mode |
+| Save availability | Touched fields and unchanged drafts could enable Save. Save now compares the effective draft with saved data, including reverting edits. Sharing no longer displays a separate unsaved-changes sentence. | Sharing, profile, device name, instance name, providers, prompts, prompt stacks, network plugin settings, server addresses |
+| Notices and validation | Consequence notices and error styling varied. Shared notices distinguish warnings from failures; backup validation distinguishes missing input from an invalid supplied value. | Sharing, restart, update, supervision, destination verification, backup creation and restore, generated API keys |
+| Review information | Destination identity details were hidden behind a disclosure. Destination and copyable fingerprint are now visible at the trust decision. Profile email is presented as a fact instead of an editable-looking field. | SSH verification, account profile |
+
+The [design system](design-system.md) records the shared rules. Creation,
+immediate-save switches, and actions that also apply an existing configuration
+retain their respective behavior; they are not ordinary draft-save actions.
+
+## Coverage and evidence
+
+Source review covered dashboard routes, their dialogs and editors, and the
+shared node-admin fact components. The main review criteria were information
+hierarchy, complete values, conditional-field association, validation tone,
+Save availability, and narrow-screen layout.
+
+Browser checks used the isolated review backend and Vite frontend, with a
+seeded terminal containing a long working-directory path. At both 320px and
+1600px, the following route entries had no document-level horizontal overflow
+with the available data:
+
+- Subshells, Nodes, the server node detail, Presets, Prompts, Workspaces.
+- Account and Preferences.
+- Server Settings: General, Users, Auth, API keys, Plugins, Service,
+ Networking, Updates, Backups, Status, and Logs.
+- The legacy SSH settings entry redirected to Presets as expected.
+
+That is 20 route entries and 19 distinct rendered surfaces. Many lists were
+empty in the sandbox; a successful page-width check does not prove every
+populated row or dialog fits. Additional browser checks opened the SSH
+authentication field panel at both widths and the saved-backup Restore panel.
+The SSH dialog had no horizontal overflow, and Restore exposed its associated
+region. Runtime and Restore screenshots were visually inspected.
+
+Component tests cover the changed radio association, sharing change/revert
+behavior, backup validation, stack editing, address presentation, enrollment
+facts, and restart wording. Workspace build, type checks, lint, and tests are
+the automated verification for the changes; these are distinct from browser
+workflow coverage.
+
+## Limits
+
+This was not a full accessibility certification or an end-to-end exercise of
+every role, failure, and integration state. Remote node/key availability,
+external identity providers, native key approval, actual server replacement,
+live network changes, and populated saved-backup recovery were not exercised
+through the review browser. Existing component tests cover some of those
+states, but they do not substitute for integration testing.
+
+The native desktop assistant and mobile app were outside this dashboard
+review. Shared node-admin changes reach their consumers, but those native
+surfaces were not visually checked in this pass.
diff --git a/packages/node-admin/src/components/fact-list.tsx b/packages/node-admin/src/components/fact-list.tsx
index d46be1739..54b07c666 100644
--- a/packages/node-admin/src/components/fact-list.tsx
+++ b/packages/node-admin/src/components/fact-list.tsx
@@ -1,4 +1,5 @@
import type { ReactNode } from "react";
+import { cn } from "../lib/utils";
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
/**
@@ -17,9 +18,9 @@ export function Fact({
wide?: boolean;
}) {
return (
-
-
{label}
-
{children}
+
+
{label}
+ {children}
);
}
@@ -36,7 +37,7 @@ export function FactCard({ title, children }: { title: string; children: ReactNo
{title}
- {children}
+ {children}
);
diff --git a/packages/node-admin/src/components/node-runtime-card.tsx b/packages/node-admin/src/components/node-runtime-card.tsx
index e2a22815d..eef1966f2 100644
--- a/packages/node-admin/src/components/node-runtime-card.tsx
+++ b/packages/node-admin/src/components/node-runtime-card.tsx
@@ -117,7 +117,7 @@ export function NodeRuntimeCard({ node, local = false }: { node: NodeDetail; loc
{runtime.tmuxPath ? (
- {runtime.tmuxPath}
+ {runtime.tmuxPath}
) : (
// Said here rather than discovered at launch time: without tmux the
// node accepts nothing, and nothing else on this page would say so.
diff --git a/packages/node-admin/src/components/ssh-trust-card.tsx b/packages/node-admin/src/components/ssh-trust-card.tsx
index f3c2a2311..86a9e7b6d 100644
--- a/packages/node-admin/src/components/ssh-trust-card.tsx
+++ b/packages/node-admin/src/components/ssh-trust-card.tsx
@@ -3,6 +3,7 @@ import type { NodeDetail, NodeSshTrustPeer } from "../types/node";
import { Badge } from "../ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
import { CopyableValue } from "../ui/copyable-value";
+import { Fact } from "./fact-list";
/**
* The machine trust card (spec 2026-10-08 §4.6): this machine's own SSH relay
@@ -77,20 +78,18 @@ export function NodeSshTrustCard({
copyLabel="This machine's encryption fingerprint"
/>
{trust.peers.length === 0 ? (
-
-
Saved machine identities
- No saved machine identities yet.
-
+
+ No saved machine identities yet.
+
) : (
trust.peers.map((peer) => (
-
-
Peer {peer.nodeId}
-
+
+
-
+
{renderPeerAction && {renderPeerAction(peer)}
}
-
+
))
)}
@@ -100,22 +99,19 @@ export function NodeSshTrustCard({
return embedded ?
:
{content} ;
}
-/** One labelled fingerprint: `label` over a mono `detail`, the line-item shape. */
+/** Fingerprints use the same label/value hierarchy as other machine facts. */
function FingerprintFact({ label, value, copyLabel }: { label: string; value: string; copyLabel: string }) {
return (
-
-
{label}
-
-
-
-
+
+
+
);
}
/** The value plus its copy affordance, the thing a person takes to the other machine. */
function FingerprintValue({ value, copyLabel }: { value: string; copyLabel: string }) {
return (
-
+
);
From d7a4feb9fe389a9b73cbd5a98e6f536ea17db55b Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Sat, 17 Oct 2026 01:47:11 -0700
Subject: [PATCH 2/6] fix: address review findings on dashboard UX consistency
Fact copying, validation tone, and the Save-unchanged comparison now go
through shared logic instead of per-site guesses, the restore overrides
move to a named section below their mode group (they apply to both),
and the supervision dialog drops the panes line by its text rather than
its index. The destination-confirmation e2e spec and docs follow the
fingerprint out of the disclosure it shipped behind; a whitespace-only
field reads the same tone on both backup cards; the ChoiceFields-in-group
decision is recorded where the a11y rules live.
Co-Authored-By: Claude Fable 5
---
apps/docs/content/docs/guides/ssh.mdx | 6 +-
.../ssh-destination-confirmation.png | Bin 84532 -> 131587 bytes
.../__tests__/backup-settings.test.tsx | 19 ++++++
.../__tests__/network-plugin-card.test.tsx | 14 +++--
.../__tests__/sharing-dialog.test.tsx | 4 +-
.../__tests__/supervision-dialog.test.tsx | 58 ++++++++++++++++++
.../components/backups/create-backup-card.tsx | 11 +---
.../components/backups/field-problem-tone.ts | 16 +++++
.../backups/restore-backup-card.tsx | 49 ++++++++-------
apps/server/web/src/components/fact-list.tsx | 36 ++++++++---
.../__tests__/addresses-card.test.ts | 25 +++++++-
.../components/networking/addresses-card.tsx | 22 ++++++-
.../networking/network-plugin-card.tsx | 2 +-
.../src/components/nodes/node-key-setup.tsx | 4 +-
.../presets/command-paste-field.tsx | 2 +-
.../src/components/service/restart-dialog.tsx | 2 +-
.../components/service/supervision-dialog.tsx | 56 ++++++++++-------
.../web/src/components/ui/choice-fields.tsx | 12 +++-
docs/design-system.md | 12 +++-
e2e/tests/24-ssh-ux.spec.ts | 4 +-
20 files changed, 273 insertions(+), 81 deletions(-)
create mode 100644 apps/server/web/src/components/backups/field-problem-tone.ts
diff --git a/apps/docs/content/docs/guides/ssh.mdx b/apps/docs/content/docs/guides/ssh.mdx
index 69654fe2b..2a58c6e53 100644
--- a/apps/docs/content/docs/guides/ssh.mdx
+++ b/apps/docs/content/docs/guides/ssh.mdx
@@ -80,11 +80,11 @@ Select **Start SSH subshell**. If you're using another node's keys and neither t
A host key is the destination machine's public SSH key. It identifies that machine; your login key or password identifies you. Remembering the host key lets SSH detect a different machine answering at the same address later. You don't need to generate, paste, or manage host keys in the dashboard.
-
- A first connection using another node's keys asks before remembering an unfamiliar destination. This screenshot uses a disposable local SSH destination. Select the image to enlarge it.
+
+ A first connection using another node's keys asks before remembering an unfamiliar destination. This screenshot was captured from a sandboxed review instance with test data. Select the image to enlarge it.
-Check the address. If you need to verify the machine, expand **Verification details** and compare the fingerprint with one obtained from its administrator through a trusted channel. Choose **Trust and connect** to remember that identity and retry the connection, or **Cancel** to leave it untrusted.
+Check the address. The dialog shows the destination's host fingerprint and warns that the fingerprint came from the destination itself, not an independent check. If you need to verify the machine, compare the fingerprint with one obtained from its administrator through a trusted channel. Choose **Trust and connect** to remember that identity and retry the connection, or **Cancel** to leave it untrusted.
> [!warning] First-connection trust
> A key retrieved from the destination isn't independent proof that you've reached the right machine. Accepting it without checking the fingerprint trusts the first response. Subshell blocks later connections if the identity changes; it doesn't silently replace the saved identity. If that happens, stop and verify the change with the destination's administrator.
diff --git a/apps/docs/public/screenshots/ssh-destination-confirmation.png b/apps/docs/public/screenshots/ssh-destination-confirmation.png
index 84980f9ace681710adb6875b0a75620f42639e6f..3a251f02d442457d9d6c4328c3a63ac729fc96bd 100644
GIT binary patch
literal 131587
zcmdSBRa6~O(Pj#rGyc7x|9wH1342tw;ab*~o*Pmfv-n@JF3Mgsxw6TMM
zc@HBkE~4t5ew6Xn8&d+mkGk!4)k{=VmgH*HODZ+QpM(xOjOLxXu(*Wy8}V3$>Kvf_
zlrkVL2Kck>b;?Q5uRpl5jUKmk4?S^P7y&IUWMug4_?WLwaIiFd1)70@$==T${de{`(i+s{q2kH{XN)pMRvejEI?oWoB+_YGR_itn5=I4HM0$
zvWf~LE75QkvDf6UU%wU*XuG(m@z3cB3qyqeJM6P%74oAphH?VJbc>DE?nh;b^H|YGNuVE+wN^c6ws=
z-1(N7nHe+x&w`n$d0{~zE61;|@;r5Qb+5gj>Rg;H?v<``Cu@}Nm+*x{*lwXYdmJKrf7T$U3y6
zS{yF|+1dGd6bah9xk}Zwz89vHx3_^Bxu`n^;jgQJi
z{&z0__p9$DaoiQ<%u+c2S!f#zqM@ONi-#Ad9k=|6mV<{ufTM#X3oMh@T|^Lq9(XHE
za*KuOI;%c=g+WQ#+e=Bgxvw^(b}o&OM>R}8lz&o`TW)J&B1gyh|szFApX$MZAM(N>h8>pH7D{mk-L-`?3aGclEk
zQlg9;4~ROOtYK9mGp{g4MB>BP5GndRb5v0-X7oizHe*8@bENoP1%_~Y)jPA@9p;U1
zU$r9PeFS`X4V#g9n*4WvII#nr`j*dW;Ew7{m57CP(ei=j|KFegS!qa}G~~)d4AziZ
zwX-R@zmAS9zn7HI*4nl^AW7f5m0_+kibSY6t+q4^Y@cnwMyy~DNCXBs#^BbsSwT8V
z$soy0J0e(L*vu3C(Wh6f0HM8$rlzL(`IL@$C|$~>YO286
zVi$AAO(xFxPt4A8;$ZJBFQ>*2Vl0Z7azrNx;X*KZB;+~h3WqY09mCQEsd;=*C#-NL
zrJwxt8!;w(+gID`-s56vsWVoAEm_K8g#2u6Gwyoar7KG*P8d%L3JOdNBEurlPran_
zdsb%juIv+;H#6EZs~Z{wSGG&6ex#}6GdlfMZS1TA$XZIYAu~GK+6hTni5fB8cqsL6
zXm*ebA-{K236(}mq{m}NXmyzLhsCt
zV8c8#laX4}n>-XGh<^D=&pZ57q$|za{lQ;IRB_^;?4;)3iIVR3T>KNVyN&3#*f-g^
z(3x~u2TDu=A(56EnyO(<|2vxS@MlCH6F?wNkD@`av_S)>eBRk4D|(ING^eq@)j;ih
zELc@T<2^yt(?WU|MzB+bY$S@U#n-))1Ib5q)r5X(3d|&IS%LN_P*hrJR&l6=nv08r
zw1biZDMq-~E49A|<9Epr&~JPl$|EUtC1qtf#PRuo`QN{PFHB60OtX@*T;QgjKoIy!
z`=7B w|
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