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);
|
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://<your-tailnet-host></span>
|
URL: <span className="font-mono">wss://<your-tailnet-host></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
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user