diff --git a/canvas/src/app/page.tsx b/canvas/src/app/page.tsx
index e785cb9a..b8976a35 100644
--- a/canvas/src/app/page.tsx
+++ b/canvas/src/app/page.tsx
@@ -10,6 +10,9 @@ import { api } from "@/lib/api";
import type { WorkspaceData } from "@/store/socket";
export default function Home() {
+ const hydrationError = useCanvasStore((s) => s.hydrationError);
+ const setHydrationError = useCanvasStore((s) => s.setHydrationError);
+
useEffect(() => {
connectSocket();
@@ -23,8 +26,11 @@ export default function Home() {
useCanvasStore.getState().setViewport(viewport);
}
}).catch((err) => {
- // Initial hydration failed — socket reconnect will retry
+ // Initial hydration failed — show error banner to user
console.error("Canvas: initial hydration failed", err);
+ useCanvasStore.getState().setHydrationError(
+ err instanceof Error && err.message ? err.message : "Failed to load canvas"
+ );
});
return () => {
@@ -37,6 +43,23 @@ export default function Home() {
+ {hydrationError && (
+
+
{hydrationError}
+
+
+ )}
>
);
}
diff --git a/canvas/src/components/ContextMenu.tsx b/canvas/src/components/ContextMenu.tsx
index 5e1d2f4f..c03fb8fa 100644
--- a/canvas/src/components/ContextMenu.tsx
+++ b/canvas/src/components/ContextMenu.tsx
@@ -235,6 +235,14 @@ export function ContextMenu() {
closeContextMenu();
}, [contextMenu, nestNode, closeContextMenu]);
+ const handleZoomToTeam = useCallback(() => {
+ if (!contextMenu) return;
+ window.dispatchEvent(
+ new CustomEvent("molecule:zoom-to-team", { detail: { nodeId: contextMenu.nodeId } })
+ );
+ closeContextMenu();
+ }, [contextMenu, closeContextMenu]);
+
if (!contextMenu) return null;
const isOfflineOrFailed = contextMenu.nodeData.status === "offline" || contextMenu.nodeData.status === "failed";
@@ -253,7 +261,10 @@ export function ContextMenu() {
? [{ label: "Extract from Team", icon: "⤴", action: handleRemoveFromTeam }]
: []),
...(hasChildren
- ? [{ label: "Collapse Team", icon: "◁", action: handleCollapse }]
+ ? [
+ { label: "Collapse Team", icon: "◁", action: handleCollapse },
+ { label: "Zoom to Team", icon: "⊕", action: handleZoomToTeam },
+ ]
: [{ label: "Expand to Team", icon: "▷", action: handleExpand }]),
{ label: "", icon: "", action: () => {}, divider: true },
...(isPaused
diff --git a/canvas/src/components/CreateWorkspaceDialog.tsx b/canvas/src/components/CreateWorkspaceDialog.tsx
index 4b0a8065..9c5f4dd0 100644
--- a/canvas/src/components/CreateWorkspaceDialog.tsx
+++ b/canvas/src/components/CreateWorkspaceDialog.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState, useEffect } from "react";
+import { useState, useEffect, useRef, useCallback } from "react";
import * as Dialog from "@radix-ui/react-dialog";
import { api } from "@/lib/api";
@@ -50,6 +50,33 @@ export function CreateWorkspaceButton() {
const [hermesProvider, setHermesProvider] = useState("anthropic");
const [hermesApiKey, setHermesApiKey] = useState("");
+ // Refs for roving tabIndex on the tier radio group (WCAG 2.1 arrow-key nav)
+ const radioRefs = useRef>([]);
+ const TIERS = [
+ { value: 1, label: "T1", desc: "Sandboxed" },
+ { value: 2, label: "T2", desc: "Standard" },
+ { value: 3, label: "T3", desc: "Full Access" },
+ ];
+
+ const handleRadioKeyDown = useCallback(
+ (e: React.KeyboardEvent, currentIndex: number) => {
+ if (e.key === "ArrowDown" || e.key === "ArrowRight") {
+ e.preventDefault();
+ const next = (currentIndex + 1) % TIERS.length;
+ setTier(TIERS[next].value);
+ radioRefs.current[next]?.focus();
+ } else if (e.key === "ArrowUp" || e.key === "ArrowLeft") {
+ e.preventDefault();
+ const prev = (currentIndex - 1 + TIERS.length) % TIERS.length;
+ setTier(TIERS[prev].value);
+ radioRefs.current[prev]?.focus();
+ }
+ },
+ // TIERS is stable (module-level constant pattern), setTier is stable from useState
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ []
+ );
+
const isHermes = template.trim().toLowerCase() === "hermes";
// Reset form and load workspaces whenever dialog opens
@@ -172,16 +199,15 @@ export function CreateWorkspaceButton() {
Tier
- {[
- { value: 1, label: "T1", desc: "Sandboxed" },
- { value: 2, label: "T2", desc: "Standard" },
- { value: 3, label: "T3", desc: "Full Access" },
- ].map((t) => (
+ {TIERS.map((t, idx) => (