diff --git a/canvas/next.config.ts b/canvas/next.config.ts
index 68a6c64d..079e21c2 100644
--- a/canvas/next.config.ts
+++ b/canvas/next.config.ts
@@ -1,7 +1,100 @@
import type { NextConfig } from "next";
+import { existsSync, readFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+
+// Load NEXT_PUBLIC_* vars from the monorepo root .env so a fresh
+// `pnpm dev` works without a per-developer canvas/.env.local. Next.js
+// only auto-loads .env from the project root by default — but our
+// canonical config (NEXT_PUBLIC_PLATFORM_URL, NEXT_PUBLIC_WS_URL,
+// MOLECULE_ENV, etc.) lives at the monorepo root, gitignored, shared
+// by the Go platform binary. Without this, the canvas falls back to
+// `window.location` (`ws://localhost:3000/ws`) and the WS pill stays
+// "Reconnecting" forever because Next.js dev doesn't serve /ws.
+//
+// Mirrors workspace-server/cmd/server/dotenv.go's monorepo-rooted .env
+// loader. Both processes look for the SAME marker (`workspace-server/
+// go.mod`) so a developer renaming or relocating the repo only has to
+// update one heuristic. Production is unaffected: `output: "standalone"`
+// bakes resolved env into the build, and the marker file isn't shipped.
+loadMonorepoEnv();
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;
+
+function loadMonorepoEnv() {
+ const root = findMonorepoRoot(__dirname);
+ if (!root) return;
+ const envPath = join(root, ".env");
+ if (!existsSync(envPath)) return;
+ const body = readFileSync(envPath, "utf8");
+ let loaded = 0;
+ let skipped = 0;
+ for (const line of body.split(/\r?\n/)) {
+ const kv = parseLine(line);
+ if (!kv) continue;
+ const [k, v] = kv;
+ // Existing env wins. NOTE: an explicitly-set empty string
+ // (`KEY=` exported from a parent shell, where Node represents it
+ // as `""` not `undefined`) counts as "set" — we keep the empty
+ // value rather than backfilling from the file. Matches Go's
+ // os.LookupEnv check in workspace-server/cmd/server/dotenv.go so
+ // both processes treat the same input identically. Operators who
+ // want the file value to win must `unset KEY` in the launching
+ // shell.
+ if (process.env[k] !== undefined) {
+ skipped++;
+ continue;
+ }
+ process.env[k] = v;
+ loaded++;
+ }
+ // eslint-disable-next-line no-console
+ console.log(
+ `[next.config] loaded ${loaded} vars from ${envPath} (${skipped} already set in env)`,
+ );
+}
+
+function findMonorepoRoot(start: string): string | null {
+ let dir = start;
+ for (let i = 0; i < 6; i++) {
+ if (existsSync(join(dir, "workspace-server", "go.mod"))) return dir;
+ const parent = dirname(dir);
+ if (parent === dir) break;
+ dir = parent;
+ }
+ return null;
+}
+
+// Mirror of workspace-server/cmd/server/dotenv.go's parseDotEnvLine
+// — same rules so the two loaders agree on every line in the shared
+// .env. If you change one parser, change the other.
+function parseLine(raw: string): [string, string] | null {
+ let line = raw.replace(/^/, "").trim();
+ if (line === "" || line.startsWith("#")) return null;
+ // `export ` prefix uses a literal space — `export\tFOO=bar` with a
+ // tab is intentionally rejected, matching the Go mirror in
+ // workspace-server/cmd/server/dotenv.go. Shells emit the prefix
+ // with a space; tabs would only appear in hand-mangled files.
+ if (line.startsWith("export ")) line = line.slice("export ".length).trimStart();
+ const eq = line.indexOf("=");
+ if (eq <= 0) return null;
+ const k = line.slice(0, eq).trim();
+ let v = line.slice(eq + 1).replace(/^[ \t]+/, "");
+ if (v.length >= 2 && (v[0] === '"' || v[0] === "'")) {
+ const quote = v[0];
+ const end = v.indexOf(quote, 1);
+ if (end >= 0) return [k, v.slice(1, end)];
+ // unterminated — fall through to bare-value handling
+ }
+ for (let i = 0; i < v.length; i++) {
+ if (v[i] !== "#") continue;
+ if (i === 0 || v[i - 1] === " " || v[i - 1] === "\t") {
+ v = v.slice(0, i);
+ break;
+ }
+ }
+ return [k, v.trim()];
+}
diff --git a/canvas/src/app/globals.css b/canvas/src/app/globals.css
index a88ce30a..ee39b125 100644
--- a/canvas/src/app/globals.css
+++ b/canvas/src/app/globals.css
@@ -1,5 +1,9 @@
@import "xterm/css/xterm.css";
+/* Theme tokens MUST load before any feature stylesheet that
+ references them so custom properties are in scope. */
+@import "../styles/theme-tokens.css";
@import "../styles/settings-panel.css";
+@import "../styles/org-deploy.css";
@tailwind base;
@tailwind components;
@@ -38,7 +42,20 @@ body {
}
.react-flow__node {
- transition: box-shadow 0.2s ease;
+ /* Transform transition drives the "spawn from parent" motion —
+ org-deploy sets the node's initial position to the parent's
+ absolute coords, then repositions to the real slot, and this
+ transition interpolates the translate() in between.
+ Non-deploy workspace moves (drag, nest) get the same smoothing
+ for free. */
+ transition:
+ box-shadow var(--mol-duration-fast) ease,
+ transform var(--mol-duration-spawn) var(--mol-easing-bounce-out);
+}
+/* Drag events must feel instant — React Flow adds this class
+ for the lifetime of the gesture. */
+.react-flow__node.dragging {
+ transition: box-shadow var(--mol-duration-fast) ease;
}
/* Scrollbar styling */
diff --git a/canvas/src/app/page.tsx b/canvas/src/app/page.tsx
index 8b79ef83..666923eb 100644
--- a/canvas/src/app/page.tsx
+++ b/canvas/src/app/page.tsx
@@ -7,13 +7,19 @@ import { CommunicationOverlay } from "@/components/CommunicationOverlay";
import { Spinner } from "@/components/Spinner";
import { connectSocket, disconnectSocket } from "@/store/socket";
import { useCanvasStore } from "@/store/canvas";
-import { api } from "@/lib/api";
+import { api, PlatformUnavailableError } 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);
const [hydrating, setHydrating] = useState(true);
+ // Distinct from hydrationError: platform-down is its own UX path
+ // (different copy, different action — the user's next step is to
+ // check local services, not to retry the API call). Tracked
+ // separately rather than encoded into hydrationError so the
+ // generic-error branch can stay simple.
+ const [platformDown, setPlatformDown] = useState(false);
useEffect(() => {
connectSocket();
@@ -28,8 +34,11 @@ export default function Home() {
useCanvasStore.getState().setViewport(viewport);
}
}).catch((err) => {
- // Initial hydration failed — show error banner to user
console.error("Canvas: initial hydration failed", err);
+ if (err instanceof PlatformUnavailableError) {
+ setPlatformDown(true);
+ return;
+ }
useCanvasStore.getState().setHydrationError(
err instanceof Error && err.message ? err.message : "Failed to load canvas"
);
@@ -53,6 +62,10 @@ export default function Home() {
);
}
+ if (platformDown) {
+ return
+ The platform server returned 503 platform_unavailable.
+ That means it can't reach Postgres or Redis to validate your session.
+ Most common cause on a dev host: one of those services stopped.
+
{`brew services start postgresql@14
+brew services start redis`}
+
+ If both are running, check /tmp/molecule-server.log for
+ the underlying error. If you're on hosted SaaS, this is a platform incident — try again in a moment.
+