Skill: cai-zhuo/materials-workbench (version 1.0.0)

--- name: materials-workbench description: Materials editor workbench — React UI and Express server to render JSON schemas to images and generate schemas with AI (declare-render + materials-agents). version: 1.0.0 metadata: clawdbot: requires: env: - OPENAI_API_KEY bins: - node primaryEnv: OPENAI_API_KEY --- # Materials Workbench Use this skill when the user wants to run the materials editor workbench: a local web app with a React client and Express server that renders JSON schemas to images (declare-render) and can generate or edit schemas using AI (materials-agents). ## What it is - **Client** — React + Vite app for editing and previewing render schemas. - **Server** — Express API that renders schemas to PNG/JPG and uses materials-agents (OpenAI) for schema generation or refinement. ## When to use - User wants to "run the workbench", "start the materials editor", or "open the schema editor UI". - User needs a local dev environment for rendering schemas and AI-assisted schema creation. ## Run From the workbench root: ```bash pnpm install pnpm run install:all # install root, server, and client deps pnpm run dev # start server + client (concurrently) ``` - Client usually: http://localhost:5173 - Server usually: http://localhost:3000 (or port in server config) Set `OPENAI_API_KEY` for AI/generate features. ## Project layout - `client/` — React frontend (Vite). - `server/` — Express backend (declare-render, materials-agents, canvas). ## Schema format Same as materials-cli: declare-render format with `id`, `width`, `height`, `layers` (text, image, container, shape, etc.).

path: README.md
content: # Workbench

A web app to create and edit canvas images by conversing with an AI agent. Upload materials (e.g. images), chat to generate or modify a declarative canvas (RenderData), preview and edit JSON, and download the result.

## Setup

1. Install dependencies from the workbench root:

   ```bash
   cd workbench
   pnpm install
   cd server && pnpm install && cd ..
   cd client && pnpm install && cd ..
   ```

2. Create `server/.env` with your API configuration:

   ```bash
   # Edit server/.env and set:
   # OPENAI_API_KEY=your-key
   # OPENAI_BASE_URL=https://api.hunyuan.cloud.tencent.com/v1  # Optional, for custom endpoints
   # OPENAI_MODEL=hunyuan-lite  # Optional, defaults to gpt-4o
   # PICUI_TOKEN=your-token     # Optional, for image uploads. Get from https://picui.cn personal center
   ```

## Run

From the workbench directory:

```bash
pnpm dev
```

- **Server** runs at http://localhost:3001 (API: `/api/chat`).
- **Client** renders canvas in the browser (no server-side rendering).
- **Client** runs at http://localhost:5173 with Vite proxy so `/api` requests go to the server.

## Usage

1. Use the **left panel** to chat: type messages and optionally attach images (materials). Images are sent as base64 in the message; the server uploads them to [PICUI](https://picui.cn) and passes URLs to the agent. Set `PICUI_TOKEN` in `server/.env` to enable uploads. The agent returns updated canvas JSON (RenderData) when it produces or changes a design.
2. Use the **right panel** tabs:
   - **Preview**: view the current rendered image and click **Download** to save it.
   - **JSON**: edit the RenderData and click **Apply** to re-render and update the preview.
3. Continue the conversation to refine the result; the agent can update the canvas based on your feedback.


path: client/index.html
content: <!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#ffffff" />
    <title>Workbench</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>


path: client/package.json
content: {
  "name": "workbench-client",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "declare-render": "^1.1.5",
    "react": "^18.3.1",
    "react-dom": "^18.3.1"
  },
  "devDependencies": {
    "@types/react": "^18.3.12",
    "@types/react-dom": "^18.3.1",
    "@vitejs/plugin-react": "^4.3.3",
    "typescript": "^5.3.0",
    "vite": "^5.4.10"
  }
}


path: client/src/App.tsx
content: import { useState, useRef, useEffect } from "react";
import ChatboxPanel from "./components/ChatboxPanel";
import MainCanvas from "./components/MainCanvas";
import type { RenderData } from "declare-render";
import type { ChatMessage } from "./api";

export default function App() {
  const [messages, setMessages] = useState<ChatMessage[]>([]);
  const [renderData, setRenderData] = useState<RenderData | null>(null);
  const [error, setError] = useState<string | null>(null);
  const renderDataRef = useRef<RenderData | null>(null);

  useEffect(() => {
    renderDataRef.current = renderData;
  }, [renderData]);

  const onMessagesChange = (next: ChatMessage[]) => setMessages(next);
  const onRenderDataChange = (next: RenderData | null) => {
    if (!next) {
      setRenderData(null);
      return;
    }
    // Same id = update existing canvas; different id = create new canvas
    // structuredClone ensures new reference so React re-renders (handles duplicate emits)
    setRenderData(structuredClone(next));
  };
  const getCurrentRenderData = () => renderDataRef.current;
  const clearError = () => setError(null);

  return (
    <div
      style={{
        height: "100%",
        display: "flex",
        flexDirection: "column",
        overflow: "hidden",
        background: "var(--wb-canvas-bg)",
        color: "var(--wb-text)",
      }}
    >
      {error && (
        <div
          style={{
            flexShrink: 0,
            padding: "8px 12px",
            paddingTop: "max(8px, env(safe-area-inset-top))",
            background: "var(--wb-error-bg)",
            color: "var(--wb-error-text)",
            fontSize: 14,
          }}
        >
          {error}
          <button
            type="button"
            onClick={clearError}
            style={{
              marginLeft: 8,
              cursor: "pointer",
              background: "transparent",
              border: "none",
              color: "inherit",
            }}
          >
            Dismiss
          </button>
        </div>
      )}
      <MainCanvas renderData={renderData} />
      <ChatboxPanel
        messages={messages}
        onMessagesChange={onMessagesChange}
        onRenderDataChange={onRenderDataChange}
        onError={setError}
        getCurrentRenderData={getCurrentRenderData}
      />
    </div>
  );
}


path: client/src/api.ts
content: import type { RenderData } from "declare-render";

export interface ChatMessage {
  role: "user" | "assistant";
  content: string;
  image?: string;
  /** Agent name — when set, this is an agent step message (display only, not sent to backend). */
  agent?: string;
  /** Thinking content accumulated from the agent's <think> output. */
  thinking?: string;
  /** Hidden messages are sent to backend as context but not displayed. */
  hidden?: boolean;
}

export interface ChatRequestBody {
  messages: ChatMessage[];
  currentRenderData?: RenderData | null;
}

export interface ChatResponse {
  message: { role: "assistant"; content: string };
  renderData?: RenderData;
}

// ── Stream event types (mirrors server StreamEvent) ──

export type StreamEvent =
  | { type: "agent_start"; agent: string; attempt?: number }
  | { type: "agent_end"; agent: string }
  | { type: "thinking_delta"; agent: string; delta: string }
  | { type: "text_delta"; agent: string; delta: string }
  | { type: "text"; agent: string; content: string }
  | { type: "render_data"; renderData: RenderData; attempt?: number; isNewCanvas?: boolean }
  | { type: "verification"; passed: boolean; errors: string[]; feedback: string; attempt: number }
  | { type: "done" }
  | { type: "error"; message: string };

// ── SSE streaming chat ──

export async function postChatStream(
  body: ChatRequestBody,
  onEvent: (event: StreamEvent) => void,
): Promise<void> {
  const res = await fetch("/api/chat", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });

  if (!res.ok) {
    const err = await res.json().catch(() => ({ error: res.statusText }));
    throw new Error((err as { error?: string }).error ?? "Chat failed");
  }

  const reader = res.body?.getReader();
  if (!reader) throw new Error("No response body");

  const decoder = new TextDecoder();
  let buffer = "";

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    buffer += decoder.decode(value, { stream: true });

    const lines = buffer.split("\n");
    buffer = lines.pop() ?? "";

    for (const line of lines) {
      const trimmed = line.trim();
      if (!trimmed || !trimmed.startsWith("data: ")) continue;
      try {
        const event = JSON.parse(trimmed.slice(6)) as StreamEvent;
        onEvent(event);
      } catch {
        // skip malformed
      }
    }
  }

  if (buffer.trim().startsWith("data: ")) {
    try {
      const event = JSON.parse(buffer.trim().slice(6)) as StreamEvent;
      onEvent(event);
    } catch {
      // ignore
    }
  }
}



path: client/src/components/ChatboxPanel.tsx
content: import {
  useState,
  useRef,
  useCallback,
  useLayoutEffect,
  useEffect,
} from "react";
import { postChatStream } from "../api";
import {
  PADDING,
  GAP,
  INPUT_ROW_HEIGHT,
  ATTACH_BUTTON_WIDTH,
  SEND_BUTTON_WIDTH,
} from "../utils/chatSchema";
import { useIsMobile } from "../hooks/useMediaQuery";
import type { ChatMessage, StreamEvent } from "../api";
import type { RenderData } from "declare-render";

const PICUI_PROXY_PREFIXES: [string, string][] = [
  ["https://free.picui.cn", "/picui-free"],
  ["https://picui.cn", "/picui-cn"],
];

/** Rewrite picui image URLs to Vite proxy paths so dev server can proxy them (avoids CORS). */
function rewritePicuiUrlsToProxy(
  data: RenderData | null | undefined,
): RenderData | null {
  if (!data?.layers) return data ?? null;
  function rewriteLayers(layers: RenderData["layers"]): RenderData["layers"] {
    if (!Array.isArray(layers)) return layers;
    return layers.map((layer) => {
      const next = { ...layer } as (typeof layers)[0];
      if (
        next.type === "img" &&
        typeof (next as { url?: string }).url === "string"
      ) {
        const url = (next as { url: string }).url;
        for (const [origin, prefix] of PICUI_PROXY_PREFIXES) {
          if (url.startsWith(origin + "/")) {
            (next as { url: string }).url = prefix + url.slice(origin.length);
            break;
          }
        }
      }
      if ("layers" in next && Array.isArray(next.layers))
        (next as { layers: RenderData["layers"] }).layers = rewriteLayers(
          (next as { layers: RenderData["layers"] }).layers,
        );
      return next;
    });
  }
  return { ...data, layers: rewriteLayers(data.layers) };
}

const AGENT_LABELS: Record<string, string> = {
  orchestrator: "Orchestrator",
  design: "Design Agent",
  canvas: "Canvas Agent",
  verification: "Verification",
  "orchestrator-replan": "Re-planning",
};

const MIN_WIDTH = 280;
const MIN_HEIGHT = 200;
const MAX_WIDTH_RATIO = 0.9;
const MAX_HEIGHT_RATIO = 0.8;
const MIN_VISIBLE = 100;
/** On mobile, chat panel height as fraction of viewport */
const MOBILE_SHEET_HEIGHT_RATIO = 0.65;
const MOBILE_MIN_SHEET_HEIGHT = 280;
/** Height of the chat bar when minimized on mobile */
const MOBILE_MINIMIZED_HEIGHT = 52;
/** PC: minimized FAB (circle button) size and inset */
const FAB_SIZE = 56;
const FAB_INSET = 20;

function clampPosition(
  left: number,
  top: number,
  _width: number,
  _height: number
): { left: number; top: number } {
  const vw = window.innerWidth;
  const vh = window.innerHeight;
  const maxLeft = vw - MIN_VISIBLE;
  const maxTop = vh - MIN_VISIBLE;
  return {
    left: Math.max(0, Math.min(left, maxLeft)),
    top: Math.max(0, Math.min(top, maxTop)),
  };
}

function clampSize(
  width: number,
  height: number
): { width: number; height: number } {
  const vw = window.innerWidth;
  const vh = window.innerHeight;
  return {
    width: Math.max(MIN_WIDTH, Math.min(width, vw * MAX_WIDTH_RATIO)),
    height: Math.max(MIN_HEIGHT, Math.min(height, vh * MAX_HEIGHT_RATIO)),
  };
}

export interface ChatboxPanelProps {
  messages: ChatMessage[];
  onMessagesChange: (m: ChatMessage[]) => void;
  onRenderDataChange: (d: RenderData | null) => void;
  onError: (err: string) => void;
  getCurrentRenderData: () => RenderData | null;
}

export default function ChatboxPanel({
  messages,
  onMessagesChange,
  onRenderDataChange,
  onError,
  getCurrentRenderData,
}: ChatboxPanelProps) {
  const [input, setInput] = useState("");
  const [attachedImage, setAttachedImage] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);
  const [streamSteps, setStreamSteps] = useState<ChatMessage[]>([]);
  const [position, setPosition] = useState({ left: 0, top: 0 });
  const [size, setSize] = useState({ width: 380, height: 480 });
  const [isMounted, setIsMounted] = useState(false);
  const [dragState, setDragState] = useState<{
    startX: number;
    startY: number;
    startLeft: number;
    startTop: number;
  } | null>(null);
  const [touchDragState, setTouchDragState] = useState<{
    startX: number;
    startY: number;
    startLeft: number;
    startTop: number;
  } | null>(null);
  const [resizeState, setResizeState] = useState<{
    startX: number;
    startY: number;
    startWidth: number;
    startHeight: number;
  } | null>(null);
  const [mobileResizeState, setMobileResizeState] = useState<{
    startY: number;
    startHeight: number;
  } | null>(null);
  const [minimized, setMinimized] = useState(false);
  const fileInputRef = useRef<HTMLInputElement>(null);
  const stepsSnapshotRef = useRef<ChatMessage[]>([]);
  const bottomRef = useRef<HTMLDivElement>(null);
  const panelRef = useRef<HTMLDivElement>(null);
  const isMobile = useIsMobile();

  const toggleMinimized = useCallback(() => setMinimized((m) => !m), []);

  const displayMessages = [
    ...messages.filter((m) => !m.hidden),
    ...streamSteps,
  ];

  useLayoutEffect(() => {
    if (isMounted) return;
    const vw = window.innerWidth;
    const vh = window.innerHeight;
    if (vw <= 768) {
      const sheetHeight = Math.max(
        MOBILE_MIN_SHEET_HEIGHT,
        Math.min(vh * MOBILE_SHEET_HEIGHT_RATIO, vh - 60)
      );
      setSize({ width: vw, height: sheetHeight });
      // On mobile, panel is part of flex layout, no position needed
    } else {
      const initialSize = clampSize(380, 480);
      setSize(initialSize);
      setPosition({
        left: vw - initialSize.width - 20,
        top: vh - initialSize.height - 20,
      });
    }
    setIsMounted(true);
  }, [isMounted]);

  useLayoutEffect(() => {
    if (!isMobile) return;
    if (minimized) return;
    const vw = window.innerWidth;
    const vh = window.innerHeight;
    const sheetHeight = Math.max(
      MOBILE_MIN_SHEET_HEIGHT,
      Math.min(vh * MOBILE_SHEET_HEIGHT_RATIO, vh - 60)
    );
    setSize({ width: vw, height: sheetHeight });
  }, [isMobile, minimized]);

  const handleEvent = useCallback(
    (event: StreamEvent) => {
      switch (event.type) {
        case "agent_start":
          setStreamSteps((prev) => {
            const next = [
              ...prev,
              {
                role: "assistant" as const,
                content: "",
                agent: event.agent,
                thinking: "",
              },
            ];
            stepsSnapshotRef.current = next;
            return next;
          });
          break;
        case "thinking_delta":
          setStreamSteps((prev) => {
            if (prev.length === 0) return prev;
            const next = [...prev];
            const last = { ...next[next.length - 1] };
            last.thinking = (last.thinking ?? "") + event.delta;
            next[next.length - 1] = last;
            stepsSnapshotRef.current = next;
            return next;
          });
          break;
        case "text_delta":
          setStreamSteps((prev) => {
            if (prev.length === 0) return prev;
            const next = [...prev];
            const last = { ...next[next.length - 1] };
            last.content += event.delta;
            next[next.length - 1] = last;
            stepsSnapshotRef.current = next;
            return next;
          });
          break;
        case "text":
          setStreamSteps((prev) => {
            if (prev.length === 0) return prev;
            const next = [...prev];
            const last = { ...next[next.length - 1] };
            last.content = event.content;
            next[next.length - 1] = last;
            stepsSnapshotRef.current = next;
            return next;
          });
          break;
        case "render_data": {
          const hasLayers = !!event.renderData?.layers?.length;
          console.debug("[Chatbox] render_data received, layers:", hasLayers ? event.renderData!.layers!.length : 0);
          onRenderDataChange(rewritePicuiUrlsToProxy(event.renderData));
          // Chat was showing streamed JSON (with {{IMAGE_1}}). Update canvas step to show
          // the same renderData the server sent (placeholders already replaced).
          if (event.renderData != null) {
            setStreamSteps((prev) => {
              if (prev.length === 0) return prev;
              const next = [...prev];
              const last = next[next.length - 1];
              if (last.agent === "canvas") {
                next[next.length - 1] = {
                  ...last,
                  content: JSON.stringify(event.renderData, null, 2),
                };
                stepsSnapshotRef.current = next;
              }
              return next;
            });
          }
          break;
        }
        case "verification":
          setStreamSteps((prev) => {
            if (prev.length === 0) return prev;
            const next = [...prev];
            const last = { ...next[next.length - 1] };
            last.content = event.passed
              ? "Verification passed"
              : `Verification failed: ${event.errors.join("; ")}`;
            next[next.length - 1] = last;
            stepsSnapshotRef.current = next;
            return next;
          });
          break;
        case "error":
          onError(event.message);
          break;
        default:
          break;
      }
    },
    [onRenderDataChange, onError]
  );

  const sendMessage = useCallback(async () => {
    const text = input.trim();
    if (!text && !attachedImage) return;
    setLoading(true);
    onError("");

    const userMessage: ChatMessage = {
      role: "user",
      content: text || "See attached image.",
      ...(attachedImage && { image: attachedImage }),
    };
    const nextMessages = [...messages, userMessage];
    onMessagesChange(nextMessages);
    setInput("");
    setAttachedImage(null);

    stepsSnapshotRef.current = [];
    setStreamSteps([]);

    try {
      const messagesToSend = nextMessages
        .filter((m) => !m.agent && !m.hidden)
        .map((m) => ({ role: m.role, content: m.content, image: m.image }));

      await postChatStream(
        {
          messages: messagesToSend,
          currentRenderData: getCurrentRenderData() ?? undefined,
        },
        handleEvent
      );

      const finalSteps = stepsSnapshotRef.current;
      const lastContent =
        finalSteps
          .filter(
            (s) =>
              s.agent !== "verification" && s.agent !== "orchestrator"
          )
          .map((s) => s.content)
          .filter(Boolean)
          .pop() ?? "";
      const summaryMsg: ChatMessage = {
        role: "assistant",
        content: lastContent,
        hidden: true,
      };
      onMessagesChange([...nextMessages, ...finalSteps, summaryMsg]);
      setStreamSteps([]);
    } catch (err) {
      onError(err instanceof Error ? err.message : "Request failed");
      if (stepsSnapshotRef.current.length > 0) {
        onMessagesChange([...nextMessages, ...stepsSnapshotRef.current]);
      }
      setStreamSteps([]);
    } finally {
      setLoading(false);
    }
  }, [
    input,
    attachedImage,
    messages,
    onMessagesChange,
    onError,
    getCurrentRenderData,
    handleEvent,
  ]);

  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (!file || !file.type.startsWith("image/")) return;
    const reader = new FileReader();
    reader.onload = () => setAttachedImage(reader.result as string);
    reader.readAsDataURL(file);
    e.target.value = "";
  };

  const onDragStart = useCallback((e: React.MouseEvent) => {
    e.preventDefault();
    setDragState({
      startX: e.clientX,
      startY: e.clientY,
      startLeft: position.left,
      startTop: position.top,
    });
  }, [position.left, position.top]);

  const onTouchStartDrag = useCallback(
    (e: React.TouchEvent) => {
      const t = e.touches[0];
      if (!t) return;
      setTouchDragState({
        startX: t.clientX,
        startY: t.clientY,
        startLeft: position.left,
        startTop: position.top,
      });
    },
    [position.left, position.top]
  );

  const onResizeStart = useCallback((e: React.MouseEvent) => {
    e.preventDefault();
    e.stopPropagation();
    setResizeState({
      startX: e.clientX,
      startY: e.clientY,
      startWidth: size.width,
      startHeight: size.height,
    });
  }, [size.width, size.height]);

  useEffect(() => {
    if (!resizeState) return;
    const onMove = (e: MouseEvent) => {
      const dx = e.clientX - resizeState.startX;
      const dy = e.clientY - resizeState.startY;
      const next = clampSize(
        resizeState.startWidth + dx,
        resizeState.startHeight + dy
      );
      setSize(next);
      setPosition((p) =>
        clampPosition(p.left, p.top, next.width, next.height)
      );
    };
    const onUp = () => setResizeState(null);
    const onLeave = () => setResizeState(null);
    window.addEventListener("mousemove", onMove);
    window.addEventListener("mouseup", onUp);
    document.body.addEventListener("mouseleave", onLeave);
    return () => {
      window.removeEventListener("mousemove", onMove);
      window.removeEventListener("mouseup", onUp);
      document.body.removeEventListener("mouseleave", onLeave);
    };
  }, [resizeState]);

  useEffect(() => {
    if (!dragState) return;
    const onMove = (e: MouseEvent) => {
      const left = dragState.startLeft + e.clientX - dragState.startX;
      const top = dragState.startTop + e.clientY - dragState.startY;
      const next = clampPosition(left, top, size.width, size.height);
      setPosition(next);
    };
    const onUp = () => setDragState(null);
    const onLeave = () => setDragState(null);
    window.addEventListener("mousemove", onMove);
    window.addEventListener("mouseup", onUp);
    document.body.addEventListener("mouseleave", onLeave);
    return () => {
      window.removeEventListener("mousemove", onMove);
      window.removeEventListener("mouseup", onUp);
      document.body.removeEventListener("mouseleave", onLeave);
    };
  }, [dragState, size.width, size.height]);

  useEffect(() => {
    if (!touchDragState) return;
    const onMove = (e: TouchEvent) => {
      e.preventDefault();
      const t = e.touches[0];
      if (!t) return;
      const left = touchDragState.startLeft + t.clientX - touchDragState.startX;
      const top = touchDragState.startTop + t.clientY - touchDragState.startY;
      const next = clampPosition(left, top, size.width, size.height);
      setPosition(next);
    };
    const onEnd = () => setTouchDragState(null);
    window.addEventListener("touchmove", onMove, { passive: false });
    window.addEventListener("touchend", onEnd);
    window.addEventListener("touchcancel", onEnd);
    return () => {
      window.removeEventListener("touchmove", onMove);
      window.removeEventListener("touchend", onEnd);
      window.removeEventListener("touchcancel", onEnd);
    };
  }, [touchDragState, size.width, size.height]);

  useEffect(() => {
    const handleResize = () => {
      const vw = window.innerWidth;
      if (vw <= 768) {
        if (minimized) return;
        // On mobile, only update width; height is controlled by user drag
        setSize((s) => ({ width: vw, height: s.height }));
      } else {
        setPosition((p) =>
          clampPosition(p.left, p.top, size.width, size.height)
        );
        setSize((s) => clampSize(s.width, s.height));
      }
    };
    window.addEventListener("resize", handleResize);
    return () => window.removeEventListener("resize", handleResize);
  }, [size.width, size.height, minimized]);

  useEffect(() => {
    bottomRef.current?.scrollIntoView({ behavior: "smooth" });
  }, [streamSteps]);

  const onMobileHeaderResizeStart = useCallback(
    (e: React.MouseEvent | React.TouchEvent) => {
      e.preventDefault();
      e.stopPropagation();
      const clientY =
        "touches" in e ? e.touches[0]?.clientY ?? 0 : e.clientY;
      setMobileResizeState({
        startY: clientY,
        startHeight: size.height,
      });
    },
    [size.height]
  );

  useEffect(() => {
    if (!mobileResizeState || !isMobile) return;
    const onMove = (e: MouseEvent | TouchEvent) => {
      e.preventDefault();
      const clientY =
        "touches" in e ? e.touches[0]?.clientY ?? 0 : e.clientY;
      const dy = mobileResizeState.startY - clientY; // Negative when dragging up
      const vh = window.innerHeight;
      const newHeight = Math.max(
        MOBILE_MIN_SHEET_HEIGHT,
        Math.min(
          mobileResizeState.startHeight + dy,
          vh * MAX_HEIGHT_RATIO
        )
      );
      setSize({ width: window.innerWidth, height: newHeight });
    };
    const onEnd = () => setMobileResizeState(null);
    window.addEventListener("mousemove", onMove);
    window.addEventListener("touchmove", onMove, { passive: false });
    window.addEventListener("mouseup", onEnd);
    window.addEventListener("touchend", onEnd);
    window.addEventListener("touchcancel", onEnd);
    return () => {
      window.removeEventListener("mousemove", onMove);
      window.removeEventListener("touchmove", onMove);
      window.removeEventListener("mouseup", onEnd);
      window.removeEventListener("touchend", onEnd);
      window.removeEventListener("touchcancel", onEnd);
    };
  }, [mobileResizeState, isMobile]);

  if (!isMounted) return null;

  const isMinimizedMobile = isMobile && minimized;
  const isMinimizedPc = !isMobile && minimized;

  if (isMinimizedPc) {
    return (
      <button
        type="button"
        onClick={toggleMinimized}
        aria-label="Open chat"
        title="Open chat"
        style={{
          position: "fixed",
          right: `max(${FAB_INSET}px, env(safe-area-inset-right))`,
          bottom: `max(${FAB_INSET}px, env(safe-area-inset-bottom))`,
          width: FAB_SIZE,
          height: FAB_SIZE,
          borderRadius: "50%",
          border: "1px solid var(--wb-border)",
          background: "var(--wb-chat-bg)",
          color: "var(--wb-text)",
          boxShadow: "0 4px 20px rgba(0,0,0,0.15)",
          cursor: "pointer",
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          fontSize: 24,
          zIndex: 1000,
        }}
      >
        💬
      </button>
    );
  }

  const panelStyle: React.CSSProperties = isMinimizedMobile
    ? {
        position: "fixed",
        left: 0,
        right: 0,
        bottom: 0,
        width: "100%",
        minHeight: MOBILE_MINIMIZED_HEIGHT,
        height: `calc(${MOBILE_MINIMIZED_HEIGHT}px + env(safe-area-inset-bottom))`,
        paddingBottom: "env(safe-area-inset-bottom)",
        display: "flex",
        flexDirection: "column",
        background: "var(--wb-chat-bg)",
        border: "none",
        borderTopLeftRadius: 12,
        borderTopRightRadius: 12,
        boxShadow: "0 -4px 24px rgba(0,0,0,0.15)",
        zIndex: 1000,
        overflow: "hidden",
      }
    : isMobile
      ? {
          flexShrink: 0,
          width: "100%",
          height: size.height,
          minHeight: MOBILE_MIN_SHEET_HEIGHT,
          maxHeight: window.innerHeight * MAX_HEIGHT_RATIO,
          paddingBottom: "env(safe-area-inset-bottom)",
          display: "flex",
          flexDirection: "column",
          background: "var(--wb-chat-bg)",
          border: "none",
          borderTopLeftRadius: 12,
          borderTopRightRadius: 12,
          boxShadow: "0 -4px 24px rgba(0,0,0,0.15)",
          overflow: "hidden",
        }
      : {
          position: "fixed",
          left: position.left,
          top: position.top,
          width: size.width,
          height: size.height,
          minWidth: MIN_WIDTH,
          minHeight: MIN_HEIGHT,
          maxWidth: window.innerWidth * MAX_WIDTH_RATIO,
          maxHeight: window.innerHeight * MAX_HEIGHT_RATIO,
          display: "flex",
          flexDirection: "column",
          background: "var(--wb-chat-bg)",
          border: "1px solid var(--wb-border)",
          borderRadius: 8,
          boxShadow: "0 8px 32px rgba(0,0,0,0.12)",
          zIndex: 1000,
          overflow: "hidden",
        };

  const onHeaderPointerDown = isMobile ? undefined : onDragStart;
  const onHeaderTouchStart = isMobile
    ? minimized
      ? undefined
      : onMobileHeaderResizeStart
    : onTouchStartDrag;
  const onHeaderMouseDown = isMobile && !minimized
    ? onMobileHeaderResizeStart
    : onHeaderPointerDown;
  const onHeaderClick = isMinimizedMobile ? toggleMinimized : undefined;

  return (
    <div ref={panelRef} style={panelStyle}>
      <div
        onMouseDown={onHeaderMouseDown}
        onTouchStart={onHeaderTouchStart}
        onClick={onHeaderClick}
        style={{
          padding: isMobile ? "12px 16px" : "8px 10px",
          minHeight: isMobile ? 44 : undefined,
          borderBottom: minimized ? "none" : "1px solid var(--wb-border)",
          fontWeight: 600,
          fontSize: 12,
          color: "var(--wb-text)",
          cursor: isMinimizedMobile
            ? "pointer"
            : isMobile
              ? "ns-resize"
              : "grab",
          background: "transparent",
          touchAction: isMobile && !minimized ? "none" : "manipulation",
          display: "flex",
          alignItems: "center",
          justifyContent: "space-between",
          gap: 8,
          userSelect: "none",
        }}
      >
        <span>Chat</span>
        <button
          type="button"
          onClick={(e) => {
            e.stopPropagation();
            toggleMinimized();
          }}
          aria-label={minimized ? "Expand chat" : "Minimize chat"}
          title={minimized ? "Expand chat" : "Minimize chat"}
          style={{
            flexShrink: 0,
            width: isMobile ? 44 : 32,
            height: isMobile ? 44 : 32,
            minWidth: isMobile ? 44 : 32,
            minHeight: isMobile ? 44 : 32,
            padding: 0,
            border: "none",
            borderRadius: 6,
            background: "transparent",
            color: "var(--wb-text-muted)",
            cursor: "pointer",
            fontSize: 18,
            display: "flex",
            alignItems: "center",
            justifyContent: "center",
          }}
        >
          {minimized ? "⊕" : "−"}
        </button>
      </div>
      {!minimized && (
      <div
        style={{
          flex: 1,
          display: "flex",
          flexDirection: "column",
          minHeight: 0,
        }}
      >
        <div
          className="hide-scrollbar"
          style={{
            flex: 1,
            overflow: "auto",
            minHeight: 0,
            padding: PADDING,
            display: "flex",
            flexDirection: "column",
            gap: GAP,
          }}
        >
          {displayMessages.length === 0 && (
            <div
              style={{
                color: "var(--wb-text-muted)",
                fontSize: 12,
              }}
            >
              Send a message to create or edit a canvas. You can attach
              images as materials.
            </div>
          )}
          {displayMessages.map((m, i) => (
            <MessageBubble
              key={i}
              message={m}
              isLast={
                i === displayMessages.length - 1 && loading
              }
            />
          ))}
          {loading && streamSteps.length === 0 && (
            <div
              style={{
                color: "var(--wb-text-muted)",
                fontSize: 12,
              }}
            >
              Thinking…
            </div>
          )}
          <div ref={bottomRef} />
        </div>
        <div
          style={{
            flexShrink: 0,
            padding: PADDING,
            paddingTop: GAP,
            borderTop: "1px solid var(--wb-border)",
            display: "flex",
            flexDirection: "column",
            gap: GAP,
          }}
        >
          {attachedImage && (
            <div
              style={{
                display: "flex",
                alignItems: "center",
                gap: 8,
              }}
            >
              <img
                src={attachedImage}
                alt="Attached preview"
                style={{
                  width: 36,
                  height: 36,
                  objectFit: "cover",
                  borderRadius: 4,
                  border: "1px solid var(--wb-border)",
                }}
              />
              <button
                type="button"
                onClick={() => setAttachedImage(null)}
                style={{
                  fontSize: 11,
                  cursor: "pointer",
                  background: "transparent",
                  border: "none",
                  color: "var(--wb-text)",
                }}
              >
                Remove
              </button>
            </div>
          )}
          <div
            style={{
              display: "flex",
              gap: GAP,
              alignItems: "stretch",
            }}
          >
          <div>
            <input
              type="file"
              ref={fileInputRef}
              accept="image/*"
              onChange={handleFileChange}
              style={{ display: "none" }}
            />
            <button
              type="button"
              onClick={() => fileInputRef.current?.click()}
              style={{
                width: ATTACH_BUTTON_WIDTH,
                height: isMobile ? 44 : INPUT_ROW_HEIGHT,
                minHeight: isMobile ? 44 : undefined,
                padding: "6px 10px",
                cursor: "pointer",
                border: "1px solid var(--wb-input-border)",
                borderRadius: 6,
                background: "var(--wb-input-bg)",
                color: "var(--wb-text)",
                fontSize: 11,
              }}
            >
              Attach image
            </button>
          </div>
          <input
            value={input}
            onChange={(e) => setInput(e.target.value)}
            onKeyDown={(e) =>
              e.key === "Enter" && !e.shiftKey && sendMessage()
            }
            placeholder="Type a message…"
            disabled={loading}
            style={{
              flex: 1,
              minWidth: 0,
              padding: isMobile ? "10px 12px" : "6px 10px",
              minHeight: isMobile ? 44 : undefined,
              border: "1px solid var(--wb-input-border)",
              borderRadius: 6,
              fontSize: 16,
              background: "var(--wb-input-bg)",
              color: "var(--wb-text)",
            }}
          />
          <button
            type="button"
            onClick={sendMessage}
            disabled={loading}
            style={{
              width: SEND_BUTTON_WIDTH,
              height: isMobile ? 44 : INPUT_ROW_HEIGHT,
              minHeight: isMobile ? 44 : undefined,
              padding: "6px 10px",
              cursor: loading ? "not-allowed" : "pointer",
              border: "none",
              borderRadius: 6,
              background: "var(--wb-btn-primary-bg)",
              color: "var(--wb-btn-primary-color)",
              fontWeight: 500,
              fontSize: 11,
            }}
          >
            Send
          </button>
          </div>
        </div>
      </div>
      )}
      {!isMobile && (
        <div
          onMouseDown={onResizeStart}
          style={{
            position: "absolute",
            right: 0,
            bottom: 0,
            width: 16,
            height: 16,
            cursor: "nwse-resize",
            background:
              "linear-gradient(135deg, transparent 50%, var(--wb-border) 50%)",
          }}
          title="Resize"
        />
      )}
    </div>
  );
}

function MessageBubble({
  message: m,
  isLast,
}: {
  message: ChatMessage;
  isLast?: boolean;
}) {
  if (m.role === "user") {
    return (
      <div
        style={{
          alignSelf: "flex-end",
          maxWidth: "90%",
          padding: "6px 10px",
          borderRadius: 8,
          background: "var(--wb-bubble-user)",
          color: "var(--wb-text)",
          whiteSpace: "pre-wrap",
          wordBreak: "break-word",
          fontSize: 12,
        }}
      >
        {m.image && (
          <img
            src={m.image}
            alt="Attachment"
            style={{
              maxWidth: 120,
              maxHeight: 120,
              borderRadius: 4,
              marginBottom: 4,
            }}
          />
        )}
        {m.content}
      </div>
    );
  }

  if (m.agent) {
    const label = AGENT_LABELS[m.agent] ?? m.agent;
    return (
      <div
        style={{
          alignSelf: "flex-start",
          maxWidth: "95%",
          padding: "5px 8px",
          borderRadius: 8,
          background: "var(--wb-bubble-assistant)",
          color: "var(--wb-text)",
          fontSize: 11,
          borderLeft: "3px solid var(--wb-border)",
        }}
      >
        <div
          style={{
            fontSize: 10,
            fontWeight: 600,
            color: "var(--wb-text-muted)",
            marginBottom: 2,
            textTransform: "uppercase",
            letterSpacing: "0.05em",
          }}
        >
          {label}
          {isLast && !m.content && " …"}
        </div>
        {m.thinking && (
          <details
            style={{
              marginBottom: 4,
              fontSize: 11,
              color: "var(--wb-text-muted)",
            }}
          >
            <summary style={{ cursor: "pointer", userSelect: "none" }}>
              Thinking
            </summary>
            <div
              className="hide-scrollbar"
              style={{
                marginTop: 2,
                paddingLeft: 8,
                borderLeft: "2px solid var(--wb-border)",
                whiteSpace: "pre-wrap",
                wordBreak: "break-word",
                maxHeight: 200,
                overflow: "auto",
              }}
            >
              {m.thinking}
            </div>
          </details>
        )}
        {m.content && (
          <div
            style={{
              whiteSpace: "pre-wrap",
              wordBreak: "break-word",
            }}
          >
            {m.content}
          </div>
        )}
      </div>
    );
  }

  return (
    <div
      style={{
        alignSelf: "flex-start",
        maxWidth: "90%",
        padding: "6px 10px",
        borderRadius: 8,
        background: "var(--wb-bubble-assistant)",
        color: "var(--wb-text)",
        whiteSpace: "pre-wrap",
        wordBreak: "break-word",
        fontSize: 12,
      }}
    >
      {m.content}
    </div>
  );
}


path: client/src/components/MainCanvas.tsx
content: import {
  useRef,
  useLayoutEffect,
  useState,
  useCallback,
  useEffect,
} from "react";
import { Renderer } from "declare-render/browser";
import type { RenderData } from "declare-render";
import { useIsMobile } from "../hooks/useMediaQuery";

const ZOOM_MIN = 0.25;
const ZOOM_MAX = 4;
const ZOOM_STEP_SLOW = 0.01;
const ZOOM_STEP_FAST = 0.05;
const FAST_THRESHOLD_MS = 400;

export interface MainCanvasProps {
  renderData: RenderData | null;
}

const ZOOM_BUTTON_MIN_TOUCH = 44;

export default function MainCanvas({ renderData }: MainCanvasProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const canvasHostRef = useRef<HTMLDivElement>(null);
  const lastZoomTimeRef = useRef(0);
  const [dimensions, setDimensions] = useState({ width: 800, height: 600 });
  const [zoom, setZoom] = useState(0.5);
  const [renderError, setRenderError] = useState<string | null>(null);
  const isMobile = useIsMobile();

  useLayoutEffect(() => {
    const container = containerRef.current;
    if (!container) return;
    const ro = new ResizeObserver(() => {
      if (containerRef.current) {
        setDimensions({
          width: containerRef.current.clientWidth || 800,
          height: containerRef.current.clientHeight || 600,
        });
      }
    });
    ro.observe(container);
    setDimensions({
      width: container.clientWidth || 800,
      height: container.clientHeight || 600,
    });
    return () => ro.disconnect();
  }, []);

  useLayoutEffect(() => {
    const canvasHost = canvasHostRef.current;
    if (!canvasHost) return;

    setRenderError(null);

    if (!renderData?.layers?.length) {
      canvasHost.innerHTML = "";
      return;
    }

    let cancelled = false;
    const run = async () => {
      try {
        console.debug("[MainCanvas] render start, layers:", renderData.layers.length);
        const r = new Renderer(renderData);
        await r.render();
        if (cancelled) return;
        setRenderError(null);
        console.debug("[MainCanvas] render success");
        const el = r.getCanvasElement();
        if (el && canvasHost) {
          canvasHost.innerHTML = "";
          el.style.display = "block";
          el.style.width = "100%";
          el.style.height = "100%";
          el.style.objectFit = "contain";
          el.style.objectPosition = "center";
          canvasHost.appendChild(el);
        }
      } catch (err) {
        if (!cancelled) {
          let msg: string;
          if (err instanceof Error) {
            msg = err.message;
          } else if (err && typeof err === "object" && "type" in err && (err as Event).type === "error") {
            const ev = err as Event;
            const target = ev.target as HTMLImageElement | null;
            msg = target?.src
              ? `Image failed to load: ${target.src.slice(0, 80)}${target.src.length > 80 ? "…" : ""}`
              : "Image load error";
          } else {
            msg = String(err);
          }
          console.error("[MainCanvas] render failed:", err);
          setRenderError(msg);
        }
      }
    };
    run();
    return () => {
      cancelled = true;
    };
  }, [renderData, dimensions.width, dimensions.height]);

  const zoomIn = useCallback(() => {
    const now = Date.now();
    const step =
      now - lastZoomTimeRef.current < FAST_THRESHOLD_MS
        ? ZOOM_STEP_FAST
        : ZOOM_STEP_SLOW;
    lastZoomTimeRef.current = now;
    setZoom((z) => Math.min(ZOOM_MAX, Math.round((z + step) * 100) / 100));
  }, []);

  const zoomOut = useCallback(() => {
    const now = Date.now();
    const step =
      now - lastZoomTimeRef.current < FAST_THRESHOLD_MS
        ? ZOOM_STEP_FAST
        : ZOOM_STEP_SLOW;
    lastZoomTimeRef.current = now;
    setZoom((z) =>
      Math.max(ZOOM_MIN, Math.round((z - step) * 100) / 100)
    );
  }, []);

  const resetZoom = useCallback(() => setZoom(1), []);

  useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      if (!(e.metaKey || e.ctrlKey)) return;
      if (e.key === "=" || e.key === "+") {
        e.preventDefault();
        zoomIn();
      } else if (e.key === "-") {
        e.preventDefault();
        zoomOut();
      }
    };
    window.addEventListener("keydown", handleKeyDown);
    return () => window.removeEventListener("keydown", handleKeyDown);
  }, [zoomIn, zoomOut]);

  // Wheel zoom: Ctrl/Cmd + scroll, step scales with scroll speed (deltaY)
  useEffect(() => {
    const WHEEL_SENSITIVITY = 0.0008; // deltaY 100 ≈ 8% zoom change
    const onWheel = (e: WheelEvent) => {
      if (!(e.ctrlKey || e.metaKey)) return;
      e.preventDefault();
      const deltaZoom = -e.deltaY * WHEEL_SENSITIVITY;
      setZoom((z) =>
        Math.max(
          ZOOM_MIN,
          Math.min(ZOOM_MAX, Math.round((z + deltaZoom) * 100) / 100)
        )
      );
    };
    window.addEventListener("wheel", onWheel, { passive: false });
    return () => window.removeEventListener("wheel", onWheel);
  }, []);

  return (
    <div
      ref={containerRef}
      style={{
        flex: 1,
        display: "flex",
        alignItems: "center",
        justifyContent: "center",
        minHeight: 0,
        overflow: "hidden",
        background: "var(--wb-bg-preview)",
      }}
    >
      {renderError ? (
        <div
          style={{
            color: "var(--wb-error-text)",
            fontSize: 12,
            padding: 12,
            maxWidth: 400,
            textAlign: "center",
          }}
        >
          Render failed: {renderError}
        </div>
      ) : !renderData?.layers?.length ? (
        <div
          style={{
            color: "var(--wb-text-muted-soft)",
            fontSize: 12,
          }}
        >
          {renderData
            ? "Canvas has no layers."
            : "No canvas. Send a message to generate."}
        </div>
      ) : (
        <div
          style={{
            flex: 1,
            display: "flex",
            alignItems: "center",
            justifyContent: "center",
            minHeight: 0,
            overflow: "hidden",
            position: "relative",
          }}
        >
          <div
            ref={canvasHostRef}
            style={{
              width: "100%",
              height: "100%",
              display: "flex",
              alignItems: "center",
              justifyContent: "center",
              transform: `scale(${zoom})`,
              transformOrigin: "center center",
            }}
          />
          <div
            style={{
              position: "absolute",
              bottom: isMobile ? "max(12px, env(safe-area-inset-bottom))" : 12,
              right: isMobile ? "max(12px, env(safe-area-inset-right))" : 12,
              display: "flex",
              alignItems: "center",
              gap: 4,
            }}
          >
            {isMobile && (
              <>
                <button
                  type="button"
                  onClick={zoomOut}
                  aria-label="Zoom out"
                  style={{
                    minWidth: ZOOM_BUTTON_MIN_TOUCH,
                    minHeight: ZOOM_BUTTON_MIN_TOUCH,
                    padding: 8,
                    background: "var(--wb-bg-elevated)",
                    border: "1px solid var(--wb-border)",
                    borderRadius: 8,
                    fontSize: 18,
                    color: "var(--wb-text-muted)",
                    cursor: "pointer",
                    font: "inherit",
                  }}
                >
                  −
                </button>
                <button
                  type="button"
                  onClick={zoomIn}
                  aria-label="Zoom in"
                  style={{
                    minWidth: ZOOM_BUTTON_MIN_TOUCH,
                    minHeight: ZOOM_BUTTON_MIN_TOUCH,
                    padding: 8,
                    background: "var(--wb-bg-elevated)",
                    border: "1px solid var(--wb-border)",
                    borderRadius: 8,
                    fontSize: 18,
                    color: "var(--wb-text-muted)",
                    cursor: "pointer",
                    font: "inherit",
                  }}
                >
                  +
                </button>
              </>
            )}
            <button
              type="button"
              onClick={resetZoom}
              style={{
                minWidth: isMobile ? ZOOM_BUTTON_MIN_TOUCH : undefined,
                minHeight: isMobile ? ZOOM_BUTTON_MIN_TOUCH : undefined,
                padding: isMobile ? "8px 12px" : "4px 8px",
                background: "var(--wb-bg-elevated)",
                border: "1px solid var(--wb-border)",
                borderRadius: isMobile ? 8 : 4,
                fontSize: 12,
                color: "var(--wb-text-muted)",
                cursor: "pointer",
                font: "inherit",
              }}
              title="Click to reset to 100%"
            >
              {Math.round(zoom * 100)}%
            </button>
          </div>
        </div>
      )}
    </div>
  );
}


path: client/src/hooks/useMediaQuery.ts
content: import { useState, useEffect } from "react";

/**
 * Match a media query and re-evaluate on change (e.g. resize / orientation).
 * Use for responsive layout (e.g. mobile vs desktop).
 */
export function useMediaQuery(query: string): boolean {
  const [matches, setMatches] = useState(() => {
    if (typeof window === "undefined") return false;
    return window.matchMedia(query).matches;
  });

  useEffect(() => {
    const m = window.matchMedia(query);
    const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
    setMatches(m.matches);
    m.addEventListener("change", handler);
    return () => m.removeEventListener("change", handler);
  }, [query]);

  return matches;
}

/** Breakpoint for mobile-first: treat as mobile (single column, bottom sheet, etc.) */
export const MOBILE_BREAKPOINT = 768;

export function useIsMobile(): boolean {
  return useMediaQuery(`(max-width: ${MOBILE_BREAKPOINT}px)`);
}


path: client/src/index.css
content: * {
  box-sizing: border-box;
}
html, body, #root {
  height: 100%;
  min-height: 100dvh;
  min-height: -webkit-fill-available;
  margin: 0;
  font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
  background: var(--wb-bg);
  color: var(--wb-text);
  -webkit-tap-highlight-color: transparent;
  touch-action: manipulation;
}
/* Prevent overscroll bounce on iOS when not needed */
body {
  overscroll-behavior-y: contain;
}

/* Light mode (default) */
:root {
  --wb-bg: #fff;
  --wb-bg-elevated: #f5f5f5;
  --wb-bg-preview: #fafafa;
  --wb-canvas-bg: #fff;
  --wb-chat-bg: rgba(255, 255, 255, 0.95);
  --wb-border: #e0e0e0;
  --wb-text: #1a1a1a;
  --wb-text-muted: #666;
  --wb-text-muted-soft: #999;
  --wb-bubble-user: #e3f2fd;
  --wb-bubble-assistant: #f5f5f5;
  --wb-input-bg: #fff;
  --wb-input-border: #ccc;
  --wb-btn-primary-bg: #1976d2;
  --wb-btn-primary-color: #fff;
  --wb-error-bg: #ffebee;
  --wb-error-text: #c62828;
}

/* Hide scrollbar while keeping scroll */
.hide-scrollbar {
  scrollbar-width: none;
  -ms-overflow-style: none;
}
.hide-scrollbar::-webkit-scrollbar {
  display: none;
}

/* Dark mode – follow system */
@media (prefers-color-scheme: dark) {
  :root {
    --wb-bg: #1a1a1a;
    --wb-bg-elevated: #2d2d2d;
    --wb-bg-preview: #121212;
    --wb-canvas-bg: #1a1a1a;
    --wb-chat-bg: rgba(26, 26, 26, 0.95);
    --wb-border: #404040;
    --wb-text: #e8e8e8;
    --wb-text-muted: #b0b0b0;
    --wb-text-muted-soft: #808080;
    --wb-bubble-user: #1e3a5f;
    --wb-bubble-assistant: #2d2d2d;
    --wb-input-bg: #2d2d2d;
    --wb-input-border: #505050;
    --wb-btn-primary-bg: #1976d2;
    --wb-btn-primary-color: #fff;
    --wb-error-bg: #3d2020;
    --wb-error-text: #ef9a9a;
  }
}


path: client/src/main.tsx
content: import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./index.css";

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <App />
  </StrictMode>
);


path: client/src/render.ts
content: /**
 * Client-side rendering using declare-render browser engine.
 */
import { Renderer, type RenderData } from "declare-render/browser";

/**
 * Render RenderData to a data URL (for img src display).
 */
export async function renderToDataUrl(schema: RenderData): Promise<string> {
  const renderer = new Renderer(schema);
  const blob = (await renderer.draw()) as Blob;
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = () => resolve(reader.result as string);
    reader.onerror = reject;
    reader.readAsDataURL(blob);
  });
}


path: client/src/utils/chatSchema.ts
content: import type {
  RenderData,
  TextRenderData,
  ContainerRenderData,
  ShapeRenderData,
} from "declare-render/browser";
import type { ChatMessage } from "../api";

/** Layout constants for chat UI. Used for overlay positioning. */
export const HEADER_HEIGHT = 44;
export const PADDING = 10;
export const GAP = 10;
export const INPUT_ROW_HEIGHT = 36;
export const ATTACH_BUTTON_WIDTH = 72;
export const SEND_BUTTON_WIDTH = 52;

/** Read a CSS variable value from the document (theme token). */
export function getTokenValue([REDACTED_SECRET]): string {
  if (typeof document === "undefined") return "#000";
  const value = getComputedStyle(document.documentElement)
    .getPropertyValue(token)
    .trim();
  return value || "#000";
}

export function getThemeColors(): {
  bubbleUser: string;
  bubbleAssistant: string;
  text: string;
  inputBg: string;
  inputBorder: string;
  btnPrimaryBg: string;
  btnPrimaryColor: string;
  textMuted: string;
} {
  return {
    bubbleUser: getTokenValue("--wb-bubble-user"),
    bubbleAssistant: getTokenValue("--wb-bubble-assistant"),
    text: getTokenValue("--wb-text"),
    inputBg: getTokenValue("--wb-input-bg"),
    inputBorder: getTokenValue("--wb-input-border"),
    btnPrimaryBg: getTokenValue("--wb-btn-primary-bg"),
    btnPrimaryColor: getTokenValue("--wb-btn-primary-color"),
    textMuted: getTokenValue("--wb-text-muted"),
  };
}

/**
 * Build a RenderData schema for the full chat UI (header, messages, input area).
 * Used by declare-render to layout and draw the entire chat canvas.
 */
export function buildChatSchema(
  messages: ChatMessage[],
  width: number,
  height: number,
  colors: ReturnType<typeof getThemeColors>
): RenderData {
  const displayMessages = messages.filter((m) => !m.hidden);
  const contentWidth = Math.max(1, width - PADDING * 2);
  const messagesHeight = Math.max(
    120,
    displayMessages.length * 60 + GAP * Math.max(0, displayMessages.length - 1)
  );
  // Header background and title
  const headerBg: ShapeRenderData = {
    id: "header-bg",
    type: "shape",
    x: 0,
    y: 0,
    shapes: [
      {
        type: "fillRect",
        x: 0,
        y: 0,
        width,
        height: HEADER_HEIGHT,
        style: { fillStyle: colors.inputBg },
      },
    ],
  };

  const headerLayer: TextRenderData = {
    id: "chat-header",
    type: "text",
    x: PADDING,
    y: (HEADER_HEIGHT - 20) / 2,
    width: width - PADDING * 2,
    height: 20,
    content: "Chat",
    style: {
      fontName: "sans-serif",
      fontSize: 16,
      color: colors.text,
      fontWeight: "600",
      verticalGap: 4,
    },
  };

  // Message layers
  const messageLayers: TextRenderData[] = displayMessages.map((m, i) => {
    const isUser = m.role === "user";
    const backgroundColor = isUser ? colors.bubbleUser : colors.bubbleAssistant;
    return {
      id: `msg-${i}`,
      type: "text",
      x: PADDING,
      y: undefined as unknown as number,
      width: contentWidth,
      height: 40,
      content: m.content || (m.agent ? "" : " "),
      style: {
        fontName: "sans-serif",
        fontSize: 14,
        color: colors.text,
        backgroundColor,
        padding: 8,
        radius: 8,
        verticalGap: 6,
      },
    };
  });

  // Empty state when no messages
  const emptyStateLayer: TextRenderData | null =
    displayMessages.length === 0
      ? {
          id: "empty-state",
          type: "text",
          x: PADDING,
          y: 0,
          width: contentWidth,
          height: 40,
          content: "Send a message to create or edit a canvas. You can attach images as materials.",
          style: {
            fontName: "sans-serif",
            fontSize: 14,
            color: colors.textMuted,
            verticalGap: 6,
          },
        }
      : null;

  const messagesContainerLayers = emptyStateLayer
    ? [emptyStateLayer, ...messageLayers]
    : messageLayers;

  // Messages container
  const messagesContainer: ContainerRenderData = {
    id: "chat-messages",
    type: "container",
    x: 0,
    y: HEADER_HEIGHT + PADDING,
    width,
    height: messagesHeight,
    direction: "column",
    gap: GAP,
    layers: messagesContainerLayers,
  };

  const totalHeight = HEADER_HEIGHT + PADDING + messagesHeight + PADDING;

  const rootContainer: ContainerRenderData = {
    id: "chat-root",
    type: "container",
    x: 0,
    y: 0,
    width,
    height: totalHeight,
    direction: "column",
    layers: [
      headerBg,
      headerLayer,
      messagesContainer,
    ],
  };

  return {
    id: "chat",
    width,
    height: Math.max(height, totalHeight),
    layers: [rootContainer],
  };
}


path: client/src/vite-env.d.ts
content: /// <reference types="vite/client" />


path: client/tsconfig.json
content: {
  "compilerOptions": {
    "target": "ES2020",
    "useDefineForClassFields": true,
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "skipLibCheck": true,
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "isolatedModules": true,
    "moduleDetection": "force",
    "noEmit": true,
    "jsx": "react-jsx",
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "baseUrl": ".",
    "paths": {
      "declare-render": ["../../declare-renderer/src/index.ts"],
      "declare-render/*": ["../../declare-renderer/src/*"]
    }
  },
  "include": ["src"]
}


path: client/vite.config.ts
content: import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";
import { fileURLToPath } from "url";

const __dirname = path.dirname(fileURLToPath(import.meta.url));

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      "declare-render/browser": path.resolve(__dirname, "../../declare-renderer/src/browser.ts"),
    },
  },
  server: {
    port: 5173,
    proxy: {
      "/api": {
        target: "http://localhost:3001",
        changeOrigin: true,
      },
      "/picui-free": {
        target: "https://free.picui.cn",
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/picui-free/, ""),
      },
      "/picui-cn": {
        target: "https://picui.cn",
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/picui-cn/, ""),
      },
    },
  },
});


path: package.json
content: {
  "name": "workbench",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "concurrently \"pnpm run dev:server\" \"pnpm run dev:client\"",
    "dev:server": "cd server && pnpm dev",
    "dev:client": "cd client && pnpm dev",
    "install:all": "pnpm install && cd server && pnpm install && cd ../client && pnpm install",
    "publish:clawhub": "clawhub publish \"$(pwd)\" --slug materials-workbench --name \"Materials Workbench\" --version 1.0.0 --tags latest"
  },
  "devDependencies": {
    "concurrently": "^9.1.0"
  }
}


path: server/pnpm-workspace.yaml
content: onlyBuiltDependencies:
  - canvas


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)