Phase 7: polish — overview health, reduced-motion, mobile, a11y, copy-ids

- 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 <noreply@anthropic.com>
This commit is contained in:
jules
2026-07-14 14:40:58 +10:00
parent a03fbd9e9b
commit 3fcdddefda
6 changed files with 129 additions and 15 deletions

View File

@@ -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;
}
}

View File

@@ -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 (
<button
type="button"
onClick={copy}
data-action={dataAction}
aria-label={`Copy ${label ?? "id"}`}
title="Copy"
className={`group inline-flex max-w-full items-center gap-1 rounded bg-muted px-1.5 py-0.5 font-mono text-xs transition-colors hover:bg-accent ${className}`}
>
<span className="truncate">{value}</span>
{copied ? (
<Check className="size-3 shrink-0 text-emerald-500" />
) : (
<Copy className="size-3 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
)}
</button>
)
}

View File

@@ -360,7 +360,7 @@ export function AppShell({
data-slot="sidebar" data-slot="sidebar"
data-expanded={expanded ? "true" : "false"} data-expanded={expanded ? "true" : "false"}
className={[ 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", expanded ? "w-60" : "w-16",
].join(" ")} ].join(" ")}
> >
@@ -656,7 +656,10 @@ export function AppShell({
one floating card in a sea of black. The floating actions one floating card in a sea of black. The floating actions
pill is fixed to the viewport edge and lives outside this pill is fixed to the viewport edge and lives outside this
column, so it stays clear regardless of cap width. */} column, so it stays clear regardless of cap width. */}
<div className="mx-auto flex w-full max-w-[1180px] flex-1 flex-col gap-6 p-6 [&>*:first-child]:lg:pr-72"> {/* 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. */}
<div className="mx-auto flex w-full max-w-[1180px] flex-1 flex-col gap-6 px-4 pb-6 pt-16 md:p-6 [&>*:first-child]:lg:pr-72">
<RouteGuard>{children}</RouteGuard> <RouteGuard>{children}</RouteGuard>
</div> </div>
</div> </div>
@@ -693,6 +696,9 @@ function NavRow({
to={item.to} to={item.to}
end={item.end} end={item.end}
title={expanded ? undefined : item.label} 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} onClick={onNavigate}
data-action={`${prefix}${item.label.toLowerCase()}`} data-action={`${prefix}${item.label.toLowerCase()}`}
className={({ isActive }) => className={({ isActive }) =>

View File

@@ -11,7 +11,7 @@ import {
Users as UsersIcon, Users as UsersIcon,
} from "lucide-react" } 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 { AlertBanner } from "@crema/feedback-ui"
import { AppShell } from "~/components/layout/app-shell" 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 { listUsers, type User } from "~/lib/arcadia/users"
import { useRegisterContext } from "@crema/aifirst-ui/context" import { useRegisterContext } from "@crema/aifirst-ui/context"
import { pageTitle } from "~/lib/page-meta" import { pageTitle } from "~/lib/page-meta"
import { errorMessage } from "~/lib/errors"
import { useSession } from "~/lib/session" import { useSession } from "~/lib/session"
export const meta = () => pageTitle("Overview") export const meta = () => pageTitle("Overview")
@@ -56,7 +57,7 @@ export default function HomeRoute() {
const [data, setData] = useState<DashboardData>(EMPTY) const [data, setData] = useState<DashboardData>(EMPTY)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<unknown>(null)
const [refreshedAt, setRefreshedAt] = useState<Date | null>(null) const [refreshedAt, setRefreshedAt] = useState<Date | null>(null)
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
@@ -70,7 +71,7 @@ export default function HomeRoute() {
listAuditLogs(arcadia, { limit: 10 }), listAuditLogs(arcadia, { limit: 10 }),
getHealth(arcadia).catch(() => null), getHealth(arcadia).catch(() => null),
]).catch((err) => { ]).catch((err) => {
setError(err instanceof ArcadiaError ? err.message : "Failed to load overview.") setError(err)
return [[], [], [], null] as [Tenant[], User[], AuditLog[], OverallHealth | null] return [[], [], [], null] as [Tenant[], User[], AuditLog[], OverallHealth | null]
}) })
setData({ tenants, users, audit, health }) setData({ tenants, users, audit, health })
@@ -92,7 +93,7 @@ export default function HomeRoute() {
tenants: { total: data.tenants.length, active: activeTenants }, tenants: { total: data.tenants.length, active: activeTenants },
users: { total: data.users.length, active: activeUsers }, users: { total: data.users.length, active: activeUsers },
audit: { recent: data.audit.length, errors: errorEvents }, audit: { recent: data.audit.length, errors: errorEvents },
health: data.health?.status ?? "unconfigured", health: deriveOverallStatus(data.health),
} }
}, [data]) }, [data])
@@ -129,7 +130,7 @@ export default function HomeRoute() {
{error ? ( {error ? (
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}> <AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
{error} {errorMessage(error, "load the overview")}
</AlertBanner> </AlertBanner>
) : null} ) : null}
@@ -408,6 +409,25 @@ function statusLabel(status: HealthStatus | string): string {
return "Unknown" 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" { function statusTone(status: HealthStatus | string): "default" | "ok" | "warning" | "error" {
if (status === "ok") return "ok" if (status === "ok") return "ok"
if (status === "degraded") return "warning" if (status === "degraded") return "warning"

View File

@@ -334,7 +334,15 @@ export default function MonitoringRoute() {
/> />
<KpiTile <KpiTile
label="Disk (busiest mount)" label="Disk (busiest mount)"
value={busiestDiskLabel(data.host.disks)} // The percentage alone is the metric — keep it in `value` so it
// never clips. The mount path (which can be long) goes in the
// caption, truncated with the full path on hover.
value={busiestDiskValue(data.host.disks)}
caption={
<span className="block truncate" title={busiestDiskMount(data.host.disks)}>
{busiestDiskMount(data.host.disks)}
</span>
}
icon={<Database className="size-4" />} icon={<Database className="size-4" />}
tone={ tone={
busiestDiskPct(data.host.disks) > 90 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) return disks.reduce((m, d) => Math.max(m, d.used_pct), 0)
} }
function busiestDiskLabel(disks: HostStats["disks"]): string { function busiestDisk(disks: HostStats["disks"]) {
if (disks.length === 0) return "—" if (disks.length === 0) return null
const busiest = disks.reduce((a, b) => (b.used_pct > a.used_pct ? b : a)) return disks.reduce((a, b) => (b.used_pct > a.used_pct ? b : a))
return `${busiest.used_pct}% (${busiest.mount})` }
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 { function barColor(pct: number): string {

View File

@@ -5,6 +5,7 @@ import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-core-client"
import { AlertBanner } from "@crema/feedback-ui" import { AlertBanner } from "@crema/feedback-ui"
import { AppShell } from "~/components/layout/app-shell" import { AppShell } from "~/components/layout/app-shell"
import { CopyId } from "~/components/copy-id"
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar" import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"
import { Badge } from "~/components/ui/badge" import { Badge } from "~/components/ui/badge"
import { Button } from "~/components/ui/button" import { Button } from "~/components/ui/button"
@@ -357,9 +358,11 @@ export default function ProfileRoute() {
</span> </span>
{account ? ( {account ? (
<> <>
<span className="text-xs text-muted-foreground"> <span className="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
Tenant <code className="font-mono">{account.tenant_id}</code> · Tenant
ID <code className="font-mono">{account.id}</code> <CopyId value={account.tenant_id} label="tenant id" dataAction="profile-copy-tenant-id" />
· ID
<CopyId value={account.id} label="account id" dataAction="profile-copy-account-id" />
</span> </span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
Last sign-in{" "} Last sign-in{" "}