Skip to content

Commit 176e27c

Browse files
devantlerclaude
andauthored
feat(ui): polish a11y, branding, and UX; fix log-follow and stale-state bugs (#5222)
Web UI polish + bug-fix pass over the SPA served by the operator, `ksail ui`, and the desktop app: Bugs / unintended behavior - LogViewer no longer yanks the view to the bottom while reading scrollback: auto-follow pauses on scroll-up and a Follow button resumes it (also fixes invalid <div> inside <pre>). - ResourcesView closes detail/log/exec/confirm state when the active cluster switches, so panels never operate on the previous cluster. - ApplyManifestsDialog only spins the clicked action (Validate vs Apply) instead of both, and disables the file picker while busy. - OverviewView no longer shows a false "No pods." empty state and a blank Workloads card while live health is still loading. - Create-cluster name is now validated inline (required + RFC 1123 pattern) instead of silently no-op'ing the submit. - Local/desktop surface preselects Docker as create-form provider (the operator keeps the in-cluster Kubernetes default). - Theme follows live OS appearance changes until explicitly toggled; endpoint copy failures surface a toast. Accessibility & guideline compliance - Real labels for the replicas input, secrets/apply textareas, and the command-palette input; aria-pressed on segmented toggles. - Visible focus states for sort headers, row actions, format toggles, the login link, and secondary/ghost buttons. - prefers-reduced-motion honored globally; overscroll containment in modals, drawers, and menus; skip-to-content link; error banners and error toasts announced via role=alert; aria-busy on buttons. - Platform-aware shortcut hint (⌘K vs Ctrl K), theme-color metas, touch-action: manipulation on interactive elements. Branding & polish - The sidebar and login screen now carry the real KSail twin-sail sloop mark (shared inline Logo component) instead of a generic boxes icon; subtle brand glow on the login screen. - Toast slide-in, cluster-switcher menu transition, animated health bars, contextual empty-state icons, search-type filter input. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent bd81974 commit 176e27c

21 files changed

Lines changed: 343 additions & 119 deletions

web/ui/index.html

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
<head>
44
<meta charset="UTF-8" />
55
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<meta name="description" content="KSail — manage local Kubernetes clusters and GitOps workloads." />
7+
<!-- Match the page background (slate-50 / slate-950) so browser chrome blends with the theme. -->
8+
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f8fafc" />
9+
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#020617" />
610
<title>KSail</title>
711
<!-- Render-blocking, same-origin theme init (no inline script, so it passes the operator CSP). -->
812
<script src="/theme-init.js"></script>

web/ui/src/components/AppShell.tsx

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { Dialog, DialogPanel, Transition, TransitionChild } from "@headlessui/react";
22
import {
33
Activity,
4-
Boxes,
54
KeyRound,
65
Layers,
76
LayoutDashboard,
@@ -18,8 +17,13 @@ import { Fragment, useState, type ReactNode } from "react";
1817
import type { Cluster, User } from "../api.ts";
1918
import type { Theme } from "../hooks/useTheme.ts";
2019
import { ClusterSwitcher } from "./ClusterSwitcher.tsx";
20+
import { KSailMark } from "./Logo.tsx";
2121
import { IconButton } from "./ui.tsx";
2222

23+
// isMacLike picks the platform-appropriate shortcut hint for the search button (the handler accepts
24+
// both ⌘K and Ctrl+K regardless; this only affects the displayed kbd).
25+
const isMacLike = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform);
26+
2327
// View is the top-level SPA section. Cluster-scoped views (overview/resources/events) operate on the
2428
// active cluster; the rest are global. Routing is view-state (no router dependency).
2529
export type View = "clusters" | "overview" | "resources" | "events" | "secrets" | "settings";
@@ -77,7 +81,7 @@ function SectionLabel({ children }: { children: ReactNode }) {
7781
function Brand() {
7882
return (
7983
<div className="flex h-14 items-center gap-2 border-b border-slate-200 px-5 dark:border-slate-800">
80-
<Boxes className="size-6 text-blue-600 dark:text-blue-500" aria-hidden />
84+
<KSailMark className="size-6" />
8185
<span className="text-lg font-semibold tracking-tight text-slate-900 dark:text-white">KSail</span>
8286
</div>
8387
);
@@ -153,7 +157,7 @@ export function AppShell({
153157
// navContent renders the two zones — the cluster workspace (switcher + scoped nav, only when a
154158
// cluster is active) above the always-present global zone. onPick lets the drawer close on navigate.
155159
const navContent = (onPick: (next: View) => void) => (
156-
<nav className="flex flex-1 flex-col gap-1 overflow-y-auto p-3">
160+
<nav className="flex flex-1 flex-col gap-1 overflow-y-auto overscroll-contain p-3">
157161
{activeClusterKey ? (
158162
<>
159163
<div className="pb-1">
@@ -174,6 +178,13 @@ export function AppShell({
174178

175179
return (
176180
<div className="flex h-full">
181+
{/* Keyboard users can jump straight past the chrome to the active view's content. */}
182+
<a
183+
href="#main-content"
184+
className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-[80] focus:rounded-md focus:bg-blue-600 focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:text-white"
185+
>
186+
Skip to content
187+
</a>
177188
{/* Persistent sidebar at md+; replaced by the drawer below md. */}
178189
<aside className="hidden w-64 shrink-0 flex-col border-r border-slate-200 bg-white md:flex dark:border-slate-800 dark:bg-slate-900">
179190
<Brand />
@@ -245,7 +256,7 @@ export function AppShell({
245256
<Search className="size-4" aria-hidden />
246257
<span className="hidden lg:inline">Search</span>
247258
<kbd className="hidden rounded border border-slate-300 px-1 font-sans text-[10px] text-slate-400 sm:inline dark:border-slate-600">
248-
⌘K
259+
{isMacLike ? "⌘K" : "Ctrl K"}
249260
</kbd>
250261
</button>
251262
) : null}
@@ -269,7 +280,7 @@ export function AppShell({
269280
</div>
270281
</header>
271282

272-
<main className="flex-1 overflow-y-auto p-4 md:p-6">{children}</main>
283+
<main id="main-content" className="flex-1 overflow-y-auto p-4 md:p-6">{children}</main>
273284
</div>
274285
</div>
275286
);

web/ui/src/components/ApplyManifestsDialog.tsx

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ export function ApplyManifestsDialog({
2828
const [manifests, setManifests] = useState("");
2929
const [results, setResults] = useState<ApplyResult[] | null>(null);
3030
const [lastDryRun, setLastDryRun] = useState(false);
31-
const [busy, setBusy] = useState(false);
31+
// busy tracks WHICH action is running (null = idle) so only the clicked button shows its spinner
32+
// while both stay disabled.
33+
const [busy, setBusy] = useState<"validate" | "apply" | null>(null);
3234
// A hidden file input drives the "Load from file…" button. In the desktop webview (WKWebView /
3335
// WebView2 / WebKitGTK) this opens the OS-native file picker; in a browser it opens the browser's —
3436
// so manifests can be loaded from disk on every surface with no Wails-specific binding.
@@ -70,7 +72,7 @@ export function ApplyManifestsDialog({
7072
return;
7173
}
7274

73-
setBusy(true);
75+
setBusy(dryRun ? "validate" : "apply");
7476
setResults(null);
7577
applyManifests(clusterNamespace, clusterName, manifests, dryRun)
7678
.then((response) => {
@@ -92,7 +94,7 @@ export function ApplyManifestsDialog({
9294
}
9395
})
9496
.catch((err: unknown) => toast.error(errorMessage(err)))
95-
.finally(() => setBusy(false));
97+
.finally(() => setBusy(null));
9698
}
9799

98100
return (
@@ -108,6 +110,7 @@ export function ApplyManifestsDialog({
108110
onChange={(event) => setManifests(event.target.value)}
109111
placeholder={PLACEHOLDER}
110112
spellCheck={false}
113+
aria-label="Kubernetes manifests (multi-document YAML)"
111114
rows={16}
112115
className="w-full rounded-lg border border-slate-300 bg-white p-3 font-mono text-xs text-slate-800 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
113116
/>
@@ -127,13 +130,18 @@ export function ApplyManifestsDialog({
127130
}}
128131
/>
129132
<div className="flex items-center gap-2">
130-
<Button variant="secondary" onClick={() => fileInputRef.current?.click()}>
133+
<Button variant="secondary" onClick={() => fileInputRef.current?.click()} disabled={busy !== null}>
131134
Load from file…
132135
</Button>
133-
<Button variant="secondary" onClick={() => run(true)} loading={busy}>
136+
<Button
137+
variant="secondary"
138+
onClick={() => run(true)}
139+
loading={busy === "validate"}
140+
disabled={busy !== null}
141+
>
134142
Validate (dry run)
135143
</Button>
136-
<Button onClick={() => run(false)} loading={busy}>
144+
<Button onClick={() => run(false)} loading={busy === "apply"} disabled={busy !== null}>
137145
Apply
138146
</Button>
139147
</div>

web/ui/src/components/ClusterFormDialog.tsx

Lines changed: 39 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ function createDefaults(
138138
name: "",
139139
namespace: "default",
140140
distribution,
141-
provider: preferredProvider(availableProviders(meta.providers[distribution] ?? [], providerStatus)),
141+
provider: preferredProvider(availableProviders(meta.providers[distribution] ?? [], providerStatus), providerStatus),
142142
controlPlanes: "1",
143143
workers: "0",
144144
cni: defaults.cni ?? "",
@@ -244,7 +244,7 @@ export function ClusterFormDialog({
244244
setValues((current) => ({
245245
...current,
246246
distribution: value,
247-
provider: preferredProvider(availableProviders(meta.providers[value] ?? [], providerStatus)),
247+
provider: preferredProvider(availableProviders(meta.providers[value] ?? [], providerStatus), providerStatus),
248248
}));
249249
}
250250

@@ -255,7 +255,10 @@ export function ClusterFormDialog({
255255
setValues({
256256
...base,
257257
distribution: template.distribution,
258-
provider: preferredProvider(availableProviders(meta.providers[template.distribution] ?? [], providerStatus)),
258+
provider: preferredProvider(
259+
availableProviders(meta.providers[template.distribution] ?? [], providerStatus),
260+
providerStatus,
261+
),
259262
...template.overrides,
260263
});
261264
}
@@ -352,38 +355,26 @@ export function ClusterFormDialog({
352355
{yamlEnabled ? (
353356
<div className="flex justify-end">
354357
<div className="inline-flex overflow-hidden rounded-md ring-1 ring-inset ring-slate-300 dark:ring-slate-700">
355-
<button
356-
type="button"
357-
onClick={() => {
358-
if (view !== "form") {
359-
showForm();
360-
}
361-
}}
362-
className={cx(
363-
"px-3 py-1 text-xs font-medium",
364-
view === "form"
365-
? "bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900"
366-
: "bg-white text-slate-600 dark:bg-slate-800 dark:text-slate-300",
367-
)}
368-
>
369-
Form
370-
</button>
371-
<button
372-
type="button"
373-
onClick={() => {
374-
if (view !== "yaml") {
375-
showYaml();
376-
}
377-
}}
378-
className={cx(
379-
"px-3 py-1 text-xs font-medium",
380-
view === "yaml"
381-
? "bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900"
382-
: "bg-white text-slate-600 dark:bg-slate-800 dark:text-slate-300",
383-
)}
384-
>
385-
YAML
386-
</button>
358+
{(["form", "yaml"] as const).map((target) => (
359+
<button
360+
key={target}
361+
type="button"
362+
aria-pressed={view === target}
363+
onClick={() => {
364+
if (view !== target) {
365+
(target === "form" ? showForm : showYaml)();
366+
}
367+
}}
368+
className={cx(
369+
"px-3 py-1 text-xs font-medium transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-blue-600",
370+
view === target
371+
? "bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900"
372+
: "bg-white text-slate-600 hover:bg-slate-50 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700",
373+
)}
374+
>
375+
{target === "form" ? "Form" : "YAML"}
376+
</button>
377+
))}
387378
</div>
388379
</div>
389380
) : null}
@@ -427,17 +418,28 @@ export function ClusterFormDialog({
427418
) : null}
428419
<TextField
429420
label="Name"
421+
name="cluster-name"
430422
value={values.name}
431423
autoFocus={!isEdit}
432424
disabled={isEdit}
425+
required={!isEdit}
426+
// RFC 1123 DNS label, like the API enforces — surfaces as inline native validation
427+
// instead of a silent no-op submit.
428+
pattern="[a-z0-9]([-a-z0-9]*[a-z0-9])?"
429+
title="Lowercase letters, digits, and hyphens; must start and end with a letter or digit"
430+
autoComplete="off"
431+
spellCheck={false}
433432
placeholder="my-cluster"
434433
onChange={(event) => setField("name", event.target.value)}
435434
/>
436435
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
437436
<TextField
438437
label="Namespace"
438+
name="cluster-namespace"
439439
value={values.namespace}
440440
disabled={isEdit}
441+
autoComplete="off"
442+
spellCheck={false}
441443
onChange={(event) => setField("namespace", event.target.value)}
442444
/>
443445
<SelectField
@@ -493,13 +495,15 @@ export function ClusterFormDialog({
493495
label="Control planes"
494496
type="number"
495497
min={0}
498+
inputMode="numeric"
496499
value={values.controlPlanes}
497500
onChange={(event) => setField("controlPlanes", event.target.value)}
498501
/>
499502
<TextField
500503
label="Workers"
501504
type="number"
502505
min={0}
506+
inputMode="numeric"
503507
value={values.workers}
504508
onChange={(event) => setField("workers", event.target.value)}
505509
/>

web/ui/src/components/ClusterSwitcher.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,11 @@ export function ClusterSwitcher({
4242
</span>
4343
<ChevronsUpDown className="size-4 shrink-0 text-slate-400" aria-hidden />
4444
</MenuButton>
45-
<MenuItems className="absolute z-30 mt-1 w-full origin-top overflow-hidden rounded-lg border border-slate-200 bg-white shadow-lg focus:outline-none dark:border-slate-700 dark:bg-slate-800">
46-
<div className="max-h-72 overflow-y-auto p-1">
45+
<MenuItems
46+
transition
47+
className="absolute z-30 mt-1 w-full origin-top overflow-hidden rounded-lg border border-slate-200 bg-white shadow-lg transition duration-100 ease-out focus:outline-none data-[closed]:scale-95 data-[closed]:opacity-0 dark:border-slate-700 dark:bg-slate-800"
48+
>
49+
<div className="max-h-72 overflow-y-auto overscroll-contain p-1">
4750
{clusters.map((cluster) => {
4851
const key = clusterKey(cluster);
4952

web/ui/src/components/ClustersTable.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ export function ClustersTable({
154154
event.stopPropagation();
155155
onEdit(cluster);
156156
}}
157-
className="rounded-md p-1.5 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700 dark:hover:bg-slate-700 dark:hover:text-slate-200"
157+
className="rounded-md p-1.5 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-blue-600 dark:hover:bg-slate-700 dark:hover:text-slate-200"
158158
>
159159
<Pencil className="size-4" />
160160
</button>
@@ -167,7 +167,7 @@ export function ClustersTable({
167167
event.stopPropagation();
168168
onDelete(cluster);
169169
}}
170-
className="rounded-md p-1.5 text-slate-400 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-500/10 dark:hover:text-red-400"
170+
className="rounded-md p-1.5 text-slate-400 transition-colors hover:bg-red-50 hover:text-red-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-red-600 dark:hover:bg-red-500/10 dark:hover:text-red-400"
171171
>
172172
<Trash2 className="size-4" />
173173
</button>

web/ui/src/components/CommandPalette.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,13 +86,16 @@ export function CommandPalette({
8686
<Search className="size-4 shrink-0 text-slate-400" aria-hidden />
8787
<ComboboxInput
8888
autoFocus
89+
aria-label="Search commands and clusters"
90+
autoComplete="off"
91+
spellCheck={false}
8992
className="w-full bg-transparent py-3 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none dark:text-white"
9093
placeholder="Search commands and clusters…"
9194
displayValue={() => ""}
9295
onChange={(event) => setQuery(event.target.value)}
9396
/>
9497
</div>
95-
<ComboboxOptions static className="max-h-80 overflow-y-auto p-2">
98+
<ComboboxOptions static className="max-h-80 overflow-y-auto overscroll-contain p-2">
9699
{filtered.length === 0 ? (
97100
<div className="px-3 py-6 text-center text-sm text-slate-500 dark:text-slate-400">No matches</div>
98101
) : (

web/ui/src/components/EventsView.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { RotateCw } from "lucide-react";
1+
import { Activity, RotateCw } from "lucide-react";
22
import { useMemo, useState } from "react";
33
import type { Cluster } from "../api.ts";
44
import { useResourceList } from "../hooks/useResourceList.ts";
@@ -56,7 +56,8 @@ export function EventsView({ cluster }: { cluster: Cluster | null }) {
5656
</SelectField>
5757
<TextField
5858
label="Search"
59-
placeholder="reason, object, message"
59+
type="search"
60+
placeholder="reason, object, message…"
6061
value={search}
6162
onChange={(event) => setSearch(event.target.value)}
6263
className="min-w-52"
@@ -73,6 +74,7 @@ export function EventsView({ cluster }: { cluster: Cluster | null }) {
7374
empty={rows.length === 0}
7475
emptyTitle="No events"
7576
emptyDescription="Nothing to show for this selection."
77+
emptyIcon={<Activity className="size-6" aria-hidden />}
7678
onRetry={refresh}
7779
>
7880
<TableCard>

0 commit comments

Comments
 (0)