From 3fcdddefda6ffdd153d86b39b6cfff22857fd877 Mon Sep 17 00:00:00 2001 From: jules Date: Tue, 14 Jul 2026 14:40:58 +1000 Subject: [PATCH] =?UTF-8?q?Phase=207:=20polish=20=E2=80=94=20overview=20he?= =?UTF-8?q?alth,=20reduced-motion,=20mobile,=20a11y,=20copy-ids?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Overview health hero derives from subsystem probes when the backend gives no overall status, so it never says "Unknown" next to an all-Healthy Subsystems card (the audit's most jarring contradiction). Home error copy now uses the Phase-2 describeError instead of raw backend strings. - Global prefers-reduced-motion guard (transitions, loops, aurora field) + motion-reduce on the sidebar width transition. - Mobile: content reserves a top row (pt-16 md:p-6) so the floating hamburger never overlaps the page H1. - A11y: collapsed rail items get an explicit aria-label. - Monitoring: disk "busiest mount" tile shows the % as the value and the long mount path as a truncated caption, so it no longer clips mid-word. - New CopyId component (click-to-copy UUIDs), wired into Profile. Typecheck 36→36, 24-route sweep clean. Remaining items (lib a11y labels, table aria-sort, dev-only monitoring data) noted in the spec. Co-Authored-By: Claude Fable 5 --- app/app.css | 18 ++++++++++ app/components/copy-id.tsx | 51 +++++++++++++++++++++++++++++ app/components/layout/app-shell.tsx | 10 ++++-- app/routes/home.tsx | 30 ++++++++++++++--- app/routes/monitoring.tsx | 26 ++++++++++++--- app/routes/profile.tsx | 9 +++-- 6 files changed, 129 insertions(+), 15 deletions(-) create mode 100644 app/components/copy-id.tsx diff --git a/app/app.css b/app/app.css index b264497..91732a1 100644 --- a/app/app.css +++ b/app/app.css @@ -156,3 +156,21 @@ } } + +/* Accessibility: honour a reduced-motion request. Skyrise leans on ambient + * drift (the aurora field), spring transitions, and looping keyframes; for + * anyone who asks for less motion, near-instant everything and freeze the + * decorative loops. This is the global guard the app previously lacked. */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } + [data-slot="aurora-field"] { + animation: none !important; + } +} diff --git a/app/components/copy-id.tsx b/app/components/copy-id.tsx new file mode 100644 index 0000000..fde3c7d --- /dev/null +++ b/app/components/copy-id.tsx @@ -0,0 +1,51 @@ +import { useState } from "react" +import { Check, Copy } from "lucide-react" + +/** + * A monospace id (UUID, slug, token name) that copies to the clipboard on + * click, with a brief checkmark. Raw ids are common in an admin console and + * useless if you can't get them into a support ticket or a CLI. + */ +export function CopyId({ + value, + label, + className = "", + dataAction, +}: { + value: string + /** What's being copied, for the aria-label. Defaults to "id". */ + label?: string + className?: string + dataAction?: string +}) { + const [copied, setCopied] = useState(false) + + const copy = async () => { + try { + await navigator.clipboard.writeText(value) + setCopied(true) + setTimeout(() => setCopied(false), 1200) + } catch { + // Clipboard blocked (insecure context / permissions) — no-op; the value + // is still selectable by hand. + } + } + + return ( + + ) +} diff --git a/app/components/layout/app-shell.tsx b/app/components/layout/app-shell.tsx index 228e0eb..09129cd 100644 --- a/app/components/layout/app-shell.tsx +++ b/app/components/layout/app-shell.tsx @@ -360,7 +360,7 @@ export function AppShell({ data-slot="sidebar" data-expanded={expanded ? "true" : "false"} className={[ - "sticky top-0 z-30 hidden h-svh shrink-0 flex-col border-r bg-sidebar transition-[width] duration-base ease-standard md:flex", + "sticky top-0 z-30 hidden h-svh shrink-0 flex-col border-r bg-sidebar transition-[width] duration-base ease-standard motion-reduce:transition-none md:flex", expanded ? "w-60" : "w-16", ].join(" ")} > @@ -656,7 +656,10 @@ export function AppShell({ one floating card in a sea of black. The floating actions pill is fixed to the viewport edge and lives outside this column, so it stays clear regardless of cap width. */} -
+ {/* pt-16 on mobile reserves a row for the floating hamburger (left) + and actions pill (right) so they never overlap the page H1; the + desktop rail/pill sit outside this column, so pt drops to p-6. */} +
{children}
@@ -693,6 +696,9 @@ function NavRow({ to={item.to} end={item.end} title={expanded ? undefined : item.label} + // When collapsed there's no visible label text, only an icon — give + // screen readers the name explicitly (title alone isn't reliably read). + aria-label={expanded ? undefined : item.label} onClick={onNavigate} data-action={`${prefix}${item.label.toLowerCase()}`} className={({ isActive }) => diff --git a/app/routes/home.tsx b/app/routes/home.tsx index dce8b46..cde2153 100644 --- a/app/routes/home.tsx +++ b/app/routes/home.tsx @@ -11,7 +11,7 @@ import { Users as UsersIcon, } from "lucide-react" -import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-core-client" +import { useArcadiaClient } from "@crema/arcadia-core-client" import { AlertBanner } from "@crema/feedback-ui" import { AppShell } from "~/components/layout/app-shell" @@ -37,6 +37,7 @@ import { listTenants, type Tenant } from "~/lib/arcadia/tenants" import { listUsers, type User } from "~/lib/arcadia/users" import { useRegisterContext } from "@crema/aifirst-ui/context" import { pageTitle } from "~/lib/page-meta" +import { errorMessage } from "~/lib/errors" import { useSession } from "~/lib/session" export const meta = () => pageTitle("Overview") @@ -56,7 +57,7 @@ export default function HomeRoute() { const [data, setData] = useState(EMPTY) const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) + const [error, setError] = useState(null) const [refreshedAt, setRefreshedAt] = useState(null) const refresh = useCallback(async () => { @@ -70,7 +71,7 @@ export default function HomeRoute() { listAuditLogs(arcadia, { limit: 10 }), getHealth(arcadia).catch(() => null), ]).catch((err) => { - setError(err instanceof ArcadiaError ? err.message : "Failed to load overview.") + setError(err) return [[], [], [], null] as [Tenant[], User[], AuditLog[], OverallHealth | null] }) setData({ tenants, users, audit, health }) @@ -92,7 +93,7 @@ export default function HomeRoute() { tenants: { total: data.tenants.length, active: activeTenants }, users: { total: data.users.length, active: activeUsers }, audit: { recent: data.audit.length, errors: errorEvents }, - health: data.health?.status ?? "unconfigured", + health: deriveOverallStatus(data.health), } }, [data]) @@ -129,7 +130,7 @@ export default function HomeRoute() { {error ? ( setError(null)}> - {error} + {errorMessage(error, "load the overview")} ) : null} @@ -408,6 +409,25 @@ function statusLabel(status: HealthStatus | string): string { return "Unknown" } +/** + * The overall health hero must never say "Unknown" while its own Subsystems + * card reports everything Healthy. When the backend gives no meaningful overall + * status (unknown/unconfigured/missing), derive it from the subsystem probes + * the same card shows: any error → Down, any degraded → Degraded, otherwise + * Healthy if at least one subsystem reported ok. + */ +function deriveOverallStatus(health: OverallHealth | null): HealthStatus | string { + if (!health) return "unconfigured" + if (health.status === "ok" || health.status === "degraded" || health.status === "error") { + return health.status + } + const statuses = Object.values(health.subsystems ?? {}).map((s) => s?.status) + if (statuses.some((s) => s === "error")) return "error" + if (statuses.some((s) => s === "degraded")) return "degraded" + if (statuses.some((s) => s === "ok")) return "ok" + return health.status ?? "unconfigured" +} + function statusTone(status: HealthStatus | string): "default" | "ok" | "warning" | "error" { if (status === "ok") return "ok" if (status === "degraded") return "warning" diff --git a/app/routes/monitoring.tsx b/app/routes/monitoring.tsx index 6cf0557..1c70c25 100644 --- a/app/routes/monitoring.tsx +++ b/app/routes/monitoring.tsx @@ -334,7 +334,15 @@ export default function MonitoringRoute() { /> + {busiestDiskMount(data.host.disks)} + + } icon={} tone={ busiestDiskPct(data.host.disks) > 90 @@ -691,10 +699,18 @@ function busiestDiskPct(disks: HostStats["disks"]): number { return disks.reduce((m, d) => Math.max(m, d.used_pct), 0) } -function busiestDiskLabel(disks: HostStats["disks"]): string { - if (disks.length === 0) return "—" - const busiest = disks.reduce((a, b) => (b.used_pct > a.used_pct ? b : a)) - return `${busiest.used_pct}% (${busiest.mount})` +function busiestDisk(disks: HostStats["disks"]) { + if (disks.length === 0) return null + return disks.reduce((a, b) => (b.used_pct > a.used_pct ? b : a)) +} + +function busiestDiskValue(disks: HostStats["disks"]): string { + const d = busiestDisk(disks) + return d ? `${d.used_pct}%` : "—" +} + +function busiestDiskMount(disks: HostStats["disks"]): string { + return busiestDisk(disks)?.mount ?? "no mounts" } function barColor(pct: number): string { diff --git a/app/routes/profile.tsx b/app/routes/profile.tsx index 0beca0d..82908ed 100644 --- a/app/routes/profile.tsx +++ b/app/routes/profile.tsx @@ -5,6 +5,7 @@ import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-core-client" import { AlertBanner } from "@crema/feedback-ui" import { AppShell } from "~/components/layout/app-shell" +import { CopyId } from "~/components/copy-id" import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar" import { Badge } from "~/components/ui/badge" import { Button } from "~/components/ui/button" @@ -357,9 +358,11 @@ export default function ProfileRoute() { {account ? ( <> - - Tenant {account.tenant_id} · - ID {account.id} + + Tenant + + · ID + Last sign-in{" "}