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:
Yusup Supriyadi
2026-03-24 22:55:19 +07:00
committed by GitHub
parent c9789c2148
commit 994c8b06b5
6 changed files with 71 additions and 34 deletions
+18 -1
View File
@@ -10,12 +10,29 @@ const readDebugFlag = (value: string | undefined): boolean => {
return ENABLED_RE.test(normalized); 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() { export default function OfficePage() {
const showOpenClawConsole = readDebugFlag(process.env.DEBUG); const showOpenClawConsole = readDebugFlag(process.env.DEBUG);
return ( return (
<AgentStoreProvider> <AgentStoreProvider>
<Suspense fallback={null}> <Suspense fallback={<OfficeLoadingFallback />}>
<OfficeScreen showOpenClawConsole={showOpenClawConsole} /> <OfficeScreen showOpenClawConsole={showOpenClawConsole} />
</Suspense> </Suspense>
</AgentStoreProvider> </AgentStoreProvider>
@@ -126,6 +126,8 @@ export const FleetSidebar = ({
}} }}
type="button" type="button"
data-testid={`fleet-agent-row-${agent.agentId}`} 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 ${ className={`group relative ui-card flex w-full items-center gap-3 overflow-hidden border px-3 py-3 text-left transition-colors ${
selected selected
? "ui-card-selected" ? "ui-card-selected"
@@ -85,7 +85,7 @@ export const GatewayConnectScreen = ({
const commandField = ( const commandField = (
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="ui-command-surface flex items-center gap-2 rounded-md px-3 py-2"> <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} {localGatewayCommand}
</code> </code>
<button <button
@@ -99,12 +99,12 @@ export const GatewayConnectScreen = ({
</button> </button>
</div> </div>
{copyStatus === "copied" ? ( {copyStatus === "copied" ? (
<p className="text-xs text-white/80">Copied</p> <p className="text-xs text-muted-foreground">Copied</p>
) : copyStatus === "failed" ? ( ) : copyStatus === "failed" ? (
<p className="ui-text-danger text-xs">Could not copy command.</p> <p className="ui-text-danger text-xs">Could not copy command.</p>
) : ( ) : (
<p className="text-xs leading-snug text-white/80"> <p className="text-xs leading-snug text-muted-foreground">
In a source checkout, use <span className="font-mono text-white">{localGatewayCommandPnpm}</span>. In a source checkout, use <span className="font-mono text-foreground">{localGatewayCommandPnpm}</span>.
</p> </p>
)} )}
</div> </div>
@@ -112,7 +112,7 @@ export const GatewayConnectScreen = ({
const remoteForm = ( const remoteForm = (
<div className="mt-2.5 flex flex-col gap-3"> <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 Upstream URL
<input <input
className="ui-input h-10 rounded-md px-4 font-sans text-sm text-foreground outline-none" 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> </label>
<div className="space-y-0.5 text-xs text-white/80"> <div className="space-y-0.5 text-xs text-muted-foreground">
<p className="font-medium text-white">Using Tailscale?</p> <p className="font-medium text-foreground">Using Tailscale?</p>
<p> <p>
URL: <span className="font-mono">wss://&lt;your-tailnet-host&gt;</span> URL: <span className="font-mono">wss://&lt;your-tailnet-host&gt;</span>
</p> </p>
</div> </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 Upstream token
<div className="relative"> <div className="relative">
<input <input
@@ -144,7 +144,7 @@ export const GatewayConnectScreen = ({
/> />
<button <button
type="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"} aria-label={showToken ? "Hide token" : "Show token"}
onClick={() => setShowToken((prev) => !prev)} onClick={() => setShowToken((prev) => !prev)}
> >
@@ -167,19 +167,19 @@ export const GatewayConnectScreen = ({
</button> </button>
{status === "connecting" ? ( {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" /> <Loader2 className="h-3.5 w-3.5 animate-spin" />
Connecting Connecting
</p> </p>
) : null} ) : null}
{error ? <p className="ui-text-danger text-xs leading-snug">{error}</p> : null} {error ? <p className="ui-text-danger text-xs leading-snug">{error}</p> : null}
{showApprovalHint ? ( {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"> <p className="leading-snug">
If the first connection attempt did not work, go to your OpenClaw computer and approve this If the first connection attempt did not work, go to your OpenClaw computer and approve this
device: device:
</p> </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 openclaw devices approve --latest
</code> </code>
</div> </div>
@@ -198,26 +198,26 @@ export const GatewayConnectScreen = ({
className={`h-2.5 w-2.5 ${statusDotClass}`} 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> </div>
<div className="ui-card px-4 py-5 sm:px-6"> <div className="ui-card px-4 py-5 sm:px-6">
<div> <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) Remote gateway (recommended)
</p> </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> </div>
{remoteForm} {remoteForm}
</div> </div>
<div className="ui-card px-4 py-4 sm:px-6 sm:py-5"> <div className="ui-card px-4 py-4 sm:px-6 sm:py-5">
<div className="space-y-1.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) Run locally (optional)
</p> </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. Start a local gateway process on this machine, then connect.
</p> </p>
</div> </div>
@@ -226,15 +226,15 @@ export const GatewayConnectScreen = ({
{localGatewayDefaults ? ( {localGatewayDefaults ? (
<div className="ui-input rounded-md px-3 py-3"> <div className="ui-input rounded-md px-3 py-3">
<div className="space-y-2"> <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>. Use token from <span className="font-mono">~/.openclaw/openclaw.json</span>.
</p> </p>
<p className="font-mono text-[11px] text-white"> <p className="font-mono text-[11px] text-foreground">
{localGatewayDefaults.url} {localGatewayDefaults.url}
</p> </p>
<button <button
type="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} onClick={onUseLocalDefaults}
> >
Use local defaults Use local defaults
+6 -5
View File
@@ -42,13 +42,14 @@ export const HeaderBar = ({
<div aria-hidden="true" /> <div aria-hidden="true" />
<p className="truncate text-sm font-semibold tracking-[0.01em] text-foreground">Claw3D</p> <p className="truncate text-sm font-semibold tracking-[0.01em] text-foreground">Claw3D</p>
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
{status === "connecting" ? ( {status !== "disconnected" ? (
<span <span
className={`ui-chip px-2 py-0.5 font-mono text-[9px] font-semibold tracking-[0.08em] ${resolveGatewayStatusBadgeClass("connecting")}`} className={`ui-chip px-2 py-0.5 font-mono text-[9px] font-semibold tracking-[0.08em] ${resolveGatewayStatusBadgeClass(status)}`}
data-testid="gateway-connecting-indicator" data-testid="gateway-status-indicator"
data-status="connecting" data-status={status}
aria-label={`Gateway ${status}`}
> >
Connecting {status === "connecting" ? "Connecting" : "Connected"}
</span> </span>
) : null} ) : null}
<ThemeToggle /> <ThemeToggle />
+18 -3
View File
@@ -137,7 +137,11 @@ export function HQSidebar({
</div> </div>
{!railOnly ? ( {!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) => { {PRIMARY_TABS.map((tab) => {
const isActive = tab === activeTab; const isActive = tab === activeTab;
const showBadge = tab === "inbox" && inboxCount > 0; const showBadge = tab === "inbox" && inboxCount > 0;
@@ -145,6 +149,10 @@ export function HQSidebar({
<button <button
key={tab} key={tab}
type="button" type="button"
role="tab"
aria-selected={isActive}
aria-controls={`hq-panel-${tab}`}
id={`hq-tab-${tab}`}
onClick={() => onTabChange(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 ${ 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 isActive
@@ -154,7 +162,7 @@ export function HQSidebar({
> >
<span>{TAB_LABELS[tab]}</span> <span>{TAB_LABELS[tab]}</span>
{showBadge ? ( {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} {inboxCount}
</span> </span>
) : null} ) : null}
@@ -164,7 +172,14 @@ export function HQSidebar({
</div> </div>
) : null} ) : 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> </div>
) : null} ) : null}
</aside> </aside>
@@ -349,23 +349,25 @@ export function DeskNameplates({
return ( return (
<Billboard key={`nameplate-${index}`} position={[wx, 0.55, wz]}> <Billboard key={`nameplate-${index}`} position={[wx, 0.55, wz]}>
<mesh position={[0, 0, -0.001]}> <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} /> <meshBasicMaterial color="#0a0804" transparent opacity={0.75} />
</mesh> </mesh>
<mesh position={[-0.32, 0, 0]}> <mesh position={[-0.52, 0, 0]}>
<planeGeometry args={[0.04, 0.18]} /> <planeGeometry args={[0.04, 0.18]} />
<meshBasicMaterial color={agent.color} /> <meshBasicMaterial color={agent.color} />
</mesh> </mesh>
<Text <Text
position={[0.02, 0, 0.001]} position={[0.02, 0, 0.001]}
fontSize={0.1} fontSize={0.09}
color="#c8a860" color="#c8a860"
anchorX="center" anchorX="center"
anchorY="middle" anchorY="middle"
maxWidth={0.56} maxWidth={1.0}
font={undefined} font={undefined}
overflowWrap="break-word"
whiteSpace="nowrap"
> >
{agent.name} {agent.name.length > 14 ? agent.name.slice(0, 13) + "…" : agent.name}
</Text> </Text>
</Billboard> </Billboard>
); );