feat(ui): improve visual polish, responsiveness, and accessibility (#58)
* feat(ui): improve visual polish, responsiveness, and UX consistency
- GatewayConnectScreen: replace hardcoded text-white* with semantic
foreground/muted-foreground tokens so the connect form is readable
in both light and dark modes
- HeaderBar: show gateway status chip for "connected" state in addition
to "connecting", giving users clear visual feedback once connected
- FleetSidebar: add aria-label and aria-pressed to agent row buttons
for screen-reader accessibility
- HQSidebar: add role=tablist/tab/tabpanel, aria-selected, aria-controls,
and aria-labelledby to the headquarters panel tabs
- OfficePage: replace Suspense fallback={null} with a themed spinner
so users see feedback instead of a blank screen during initial load
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(office): widen agent nameplate to prevent name truncation
- Expand background plane from 0.68 to 1.1 width
- Increase maxWidth from 0.56 to 1.0
- Slightly reduce fontSize 0.1 to 0.09 for better fit
- Add whiteSpace nowrap to prevent wrapping
- Truncate names >14 chars with ellipsis for very long agent names
---------
Co-authored-by: Claw3D UI Bot <ui-improvements@claw3d.local>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+18
-1
@@ -10,12 +10,29 @@ const readDebugFlag = (value: string | undefined): boolean => {
|
||||
return ENABLED_RE.test(normalized);
|
||||
};
|
||||
|
||||
function OfficeLoadingFallback() {
|
||||
return (
|
||||
<div
|
||||
className="flex h-full w-full items-center justify-center bg-background"
|
||||
aria-label="Loading office"
|
||||
role="status"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-primary" />
|
||||
<p className="font-mono text-[11px] tracking-[0.08em] text-muted-foreground">
|
||||
Loading…
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OfficePage() {
|
||||
const showOpenClawConsole = readDebugFlag(process.env.DEBUG);
|
||||
|
||||
return (
|
||||
<AgentStoreProvider>
|
||||
<Suspense fallback={null}>
|
||||
<Suspense fallback={<OfficeLoadingFallback />}>
|
||||
<OfficeScreen showOpenClawConsole={showOpenClawConsole} />
|
||||
</Suspense>
|
||||
</AgentStoreProvider>
|
||||
|
||||
@@ -126,6 +126,8 @@ export const FleetSidebar = ({
|
||||
}}
|
||||
type="button"
|
||||
data-testid={`fleet-agent-row-${agent.agentId}`}
|
||||
aria-label={`Select agent: ${agent.name}`}
|
||||
aria-pressed={selected}
|
||||
className={`group relative ui-card flex w-full items-center gap-3 overflow-hidden border px-3 py-3 text-left transition-colors ${
|
||||
selected
|
||||
? "ui-card-selected"
|
||||
|
||||
@@ -85,7 +85,7 @@ export const GatewayConnectScreen = ({
|
||||
const commandField = (
|
||||
<div className="space-y-1.5">
|
||||
<div className="ui-command-surface flex items-center gap-2 rounded-md px-3 py-2">
|
||||
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap font-mono text-[12px] text-white">
|
||||
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap font-mono text-[12px] text-[var(--command-fg)]">
|
||||
{localGatewayCommand}
|
||||
</code>
|
||||
<button
|
||||
@@ -99,12 +99,12 @@ export const GatewayConnectScreen = ({
|
||||
</button>
|
||||
</div>
|
||||
{copyStatus === "copied" ? (
|
||||
<p className="text-xs text-white/80">Copied</p>
|
||||
<p className="text-xs text-muted-foreground">Copied</p>
|
||||
) : copyStatus === "failed" ? (
|
||||
<p className="ui-text-danger text-xs">Could not copy command.</p>
|
||||
) : (
|
||||
<p className="text-xs leading-snug text-white/80">
|
||||
In a source checkout, use <span className="font-mono text-white">{localGatewayCommandPnpm}</span>.
|
||||
<p className="text-xs leading-snug text-muted-foreground">
|
||||
In a source checkout, use <span className="font-mono text-foreground">{localGatewayCommandPnpm}</span>.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -112,7 +112,7 @@ export const GatewayConnectScreen = ({
|
||||
|
||||
const remoteForm = (
|
||||
<div className="mt-2.5 flex flex-col gap-3">
|
||||
<label className="flex flex-col gap-1 text-[11px] font-medium text-white/90">
|
||||
<label className="flex flex-col gap-1 text-[11px] font-medium text-foreground/90">
|
||||
Upstream URL
|
||||
<input
|
||||
className="ui-input h-10 rounded-md px-4 font-sans text-sm text-foreground outline-none"
|
||||
@@ -124,14 +124,14 @@ export const GatewayConnectScreen = ({
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="space-y-0.5 text-xs text-white/80">
|
||||
<p className="font-medium text-white">Using Tailscale?</p>
|
||||
<div className="space-y-0.5 text-xs text-muted-foreground">
|
||||
<p className="font-medium text-foreground">Using Tailscale?</p>
|
||||
<p>
|
||||
URL: <span className="font-mono">wss://<your-tailnet-host></span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="flex flex-col gap-1 text-[11px] font-medium text-white/90">
|
||||
<label className="flex flex-col gap-1 text-[11px] font-medium text-foreground/90">
|
||||
Upstream token
|
||||
<div className="relative">
|
||||
<input
|
||||
@@ -144,7 +144,7 @@ export const GatewayConnectScreen = ({
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="ui-btn-icon absolute inset-y-0 right-1 my-auto h-8 w-8 border-transparent bg-transparent text-white/70 hover:bg-transparent hover:text-white"
|
||||
className="ui-btn-icon absolute inset-y-0 right-1 my-auto h-8 w-8 border-transparent bg-transparent text-muted-foreground hover:bg-transparent hover:text-foreground"
|
||||
aria-label={showToken ? "Hide token" : "Show token"}
|
||||
onClick={() => setShowToken((prev) => !prev)}
|
||||
>
|
||||
@@ -167,19 +167,19 @@ export const GatewayConnectScreen = ({
|
||||
</button>
|
||||
|
||||
{status === "connecting" ? (
|
||||
<p className="inline-flex items-center gap-1.5 text-xs text-white/80">
|
||||
<p className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Connecting…
|
||||
</p>
|
||||
) : null}
|
||||
{error ? <p className="ui-text-danger text-xs leading-snug">{error}</p> : null}
|
||||
{showApprovalHint ? (
|
||||
<div className="rounded-md border border-white/10 bg-white/5 px-3 py-3 text-xs text-white/85">
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-3 text-xs text-muted-foreground">
|
||||
<p className="leading-snug">
|
||||
If the first connection attempt did not work, go to your OpenClaw computer and approve this
|
||||
device:
|
||||
</p>
|
||||
<code className="mt-2 block overflow-x-auto whitespace-nowrap rounded-md bg-black/30 px-2.5 py-2 font-mono text-[11px] text-white">
|
||||
<code className="mt-2 block overflow-x-auto whitespace-nowrap rounded-md bg-[var(--command-bg)] px-2.5 py-2 font-mono text-[11px] text-[var(--command-fg)]">
|
||||
openclaw devices approve --latest
|
||||
</code>
|
||||
</div>
|
||||
@@ -198,26 +198,26 @@ export const GatewayConnectScreen = ({
|
||||
className={`h-2.5 w-2.5 ${statusDotClass}`}
|
||||
/>
|
||||
)}
|
||||
<p className="text-sm font-semibold text-white">{statusCopy}</p>
|
||||
<p className="text-sm font-semibold text-foreground">{statusCopy}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ui-card px-4 py-5 sm:px-6">
|
||||
<div>
|
||||
<p className="font-mono text-[10px] font-medium tracking-[0.06em] text-white/80">
|
||||
<p className="font-mono text-[10px] font-medium tracking-[0.06em] text-muted-foreground">
|
||||
Remote gateway (recommended)
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-white/90">Default: enter your URL and token to connect.</p>
|
||||
<p className="mt-2 text-sm text-foreground/90">Default: enter your URL and token to connect.</p>
|
||||
</div>
|
||||
{remoteForm}
|
||||
</div>
|
||||
|
||||
<div className="ui-card px-4 py-4 sm:px-6 sm:py-5">
|
||||
<div className="space-y-1.5">
|
||||
<p className="font-mono text-[10px] font-semibold tracking-[0.06em] text-white/80">
|
||||
<p className="font-mono text-[10px] font-semibold tracking-[0.06em] text-muted-foreground">
|
||||
Run locally (optional)
|
||||
</p>
|
||||
<p className="text-sm text-white/90">
|
||||
<p className="text-sm text-foreground/90">
|
||||
Start a local gateway process on this machine, then connect.
|
||||
</p>
|
||||
</div>
|
||||
@@ -226,15 +226,15 @@ export const GatewayConnectScreen = ({
|
||||
{localGatewayDefaults ? (
|
||||
<div className="ui-input rounded-md px-3 py-3">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-white/80">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Use token from <span className="font-mono">~/.openclaw/openclaw.json</span>.
|
||||
</p>
|
||||
<p className="font-mono text-[11px] text-white">
|
||||
<p className="font-mono text-[11px] text-foreground">
|
||||
{localGatewayDefaults.url}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="ui-btn-secondary h-9 w-full px-3 text-xs font-semibold tracking-[0.05em] text-white"
|
||||
className="ui-btn-secondary h-9 w-full px-3 text-xs font-semibold tracking-[0.05em]"
|
||||
onClick={onUseLocalDefaults}
|
||||
>
|
||||
Use local defaults
|
||||
|
||||
@@ -42,13 +42,14 @@ export const HeaderBar = ({
|
||||
<div aria-hidden="true" />
|
||||
<p className="truncate text-sm font-semibold tracking-[0.01em] text-foreground">Claw3D</p>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{status === "connecting" ? (
|
||||
{status !== "disconnected" ? (
|
||||
<span
|
||||
className={`ui-chip px-2 py-0.5 font-mono text-[9px] font-semibold tracking-[0.08em] ${resolveGatewayStatusBadgeClass("connecting")}`}
|
||||
data-testid="gateway-connecting-indicator"
|
||||
data-status="connecting"
|
||||
className={`ui-chip px-2 py-0.5 font-mono text-[9px] font-semibold tracking-[0.08em] ${resolveGatewayStatusBadgeClass(status)}`}
|
||||
data-testid="gateway-status-indicator"
|
||||
data-status={status}
|
||||
aria-label={`Gateway ${status}`}
|
||||
>
|
||||
Connecting
|
||||
{status === "connecting" ? "Connecting" : "Connected"}
|
||||
</span>
|
||||
) : null}
|
||||
<ThemeToggle />
|
||||
|
||||
@@ -137,7 +137,11 @@ export function HQSidebar({
|
||||
</div>
|
||||
|
||||
{!railOnly ? (
|
||||
<div className="grid grid-cols-3 border-b border-cyan-500/15">
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Headquarters panels"
|
||||
className="grid grid-cols-3 border-b border-cyan-500/15"
|
||||
>
|
||||
{PRIMARY_TABS.map((tab) => {
|
||||
const isActive = tab === activeTab;
|
||||
const showBadge = tab === "inbox" && inboxCount > 0;
|
||||
@@ -145,6 +149,10 @@ export function HQSidebar({
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
aria-controls={`hq-panel-${tab}`}
|
||||
id={`hq-tab-${tab}`}
|
||||
onClick={() => onTabChange(tab)}
|
||||
className={`flex items-center justify-center gap-1 border-r border-cyan-500/10 px-2 py-2.5 font-mono text-[11px] uppercase tracking-[0.18em] transition-colors last:border-r-0 ${
|
||||
isActive
|
||||
@@ -154,7 +162,7 @@ export function HQSidebar({
|
||||
>
|
||||
<span>{TAB_LABELS[tab]}</span>
|
||||
{showBadge ? (
|
||||
<span className="rounded bg-cyan-500/15 px-1.5 py-0.5 text-[10px] text-cyan-300">
|
||||
<span className="rounded bg-cyan-500/15 px-1.5 py-0.5 text-[10px] text-cyan-300" aria-label={`${inboxCount} unread`}>
|
||||
{inboxCount}
|
||||
</span>
|
||||
) : null}
|
||||
@@ -164,7 +172,14 @@ export function HQSidebar({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden">{activePanel}</div>
|
||||
<div
|
||||
role="tabpanel"
|
||||
id={`hq-panel-${activeTab}`}
|
||||
aria-labelledby={`hq-tab-${activeTab}`}
|
||||
className="min-h-0 flex-1 overflow-hidden"
|
||||
>
|
||||
{activePanel}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</aside>
|
||||
|
||||
@@ -349,23 +349,25 @@ export function DeskNameplates({
|
||||
return (
|
||||
<Billboard key={`nameplate-${index}`} position={[wx, 0.55, wz]}>
|
||||
<mesh position={[0, 0, -0.001]}>
|
||||
<planeGeometry args={[0.68, 0.18]} />
|
||||
<planeGeometry args={[1.1, 0.18]} />
|
||||
<meshBasicMaterial color="#0a0804" transparent opacity={0.75} />
|
||||
</mesh>
|
||||
<mesh position={[-0.32, 0, 0]}>
|
||||
<mesh position={[-0.52, 0, 0]}>
|
||||
<planeGeometry args={[0.04, 0.18]} />
|
||||
<meshBasicMaterial color={agent.color} />
|
||||
</mesh>
|
||||
<Text
|
||||
position={[0.02, 0, 0.001]}
|
||||
fontSize={0.1}
|
||||
fontSize={0.09}
|
||||
color="#c8a860"
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
maxWidth={0.56}
|
||||
maxWidth={1.0}
|
||||
font={undefined}
|
||||
overflowWrap="break-word"
|
||||
whiteSpace="nowrap"
|
||||
>
|
||||
{agent.name}
|
||||
{agent.name.length > 14 ? agent.name.slice(0, 13) + "…" : agent.name}
|
||||
</Text>
|
||||
</Billboard>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user