From bc1e7ea8bca986664604af375bd0a208f97a04e2 Mon Sep 17 00:00:00 2001 From: hongmingwang Date: Sun, 10 May 2026 06:06:24 -0700 Subject: [PATCH] feat(canvas): mobile-first shell with 6-screen iOS design + responsive desktop fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the Claude Design handoff (Molecules AI Mobile.html) as a viewport-gated React tree under canvas/src/components/mobile/. < 640px renders the new shell instead of the desktop ReactFlow canvas. Six screens, all bound to live store data: - Home (agent list + filter chips + spawn FAB) - Canvas (mini-graph with pinch-to-zoom + pan + reset) - Detail (status pills, tabs: Overview / Activity / Config / Memory; Activity hits /workspaces/:id/activity) - Chat (textarea composer, IME-safe Enter, sendInFlightRef guard; bootstraps from agentMessages so the prior thread shows on entry) - Comms (live A2A feed via /workspaces/:id/activity + ACTIVITY_LOGGED) - Spawn (bottom sheet; fetches /templates so users pick what's actually installed on their platform) Plus a Me tab for mobile theme/accent/density. Design system (palette.ts + primitives.tsx) ports tokens 1:1 from the handoff: cream + dark palettes, T1-T4 tier chips, status dots with halo, JetBrains Mono for IDs/timestamps. Inter + JetBrains Mono are self-hosted via next/font/google so CSP `font-src 'self'` is honoured. URL routing: routes sync to ?m=&a=; popstate restores route; deep links seed initial state. /?m=detail without ?a collapses to home. Accent override flows through React context (MobileAccentProvider) — not by mutating the static MOL_LIGHT/MOL_DARK singletons. SSR flash: isMobile is tri-state; loading spinner stays up until matchMedia resolves so mobile devices never paint the desktop tree. Desktop responsiveness fixes (separate but ride along): - Toolbar: full-width with overflow-x-auto on mobile, logo text + count hidden < sm, divider/border collapse to sm: only. - SidePanel: full-screen on mobile via matchMedia, resize handle hidden. - Canvas: MiniMap hidden < sm (was overlapping the New Workspace FAB). Tests (51 total, 33 new): - palette.test.ts (12) - normalizeStatus, tierCode, light/dark parity - components.test.ts (10) - toMobileAgent field mapping + classifyForFilter - MobileApp.test.tsx (12) - route stack, deep links, popstate, tab bar hidden on chat, spawn overlay - SidePanel.tabs.test.tsx (18) - regression-clean Verified: tsc --noEmit clean across mobile/, page.tsx, layout.tsx. Not yet verified: live phone browser (needs CP backend hydrated). Co-Authored-By: Claude Opus 4.7 (1M context) --- canvas/src/app/layout.tsx | 18 +- canvas/src/app/page.tsx | 49 +- canvas/src/components/Canvas.tsx | 4 +- canvas/src/components/SidePanel.tsx | 58 +- canvas/src/components/Toolbar.tsx | 14 +- canvas/src/components/mobile/MobileApp.tsx | 210 +++++++ canvas/src/components/mobile/MobileCanvas.tsx | 401 ++++++++++++ canvas/src/components/mobile/MobileChat.tsx | 493 +++++++++++++++ canvas/src/components/mobile/MobileComms.tsx | 368 +++++++++++ canvas/src/components/mobile/MobileDetail.tsx | 589 ++++++++++++++++++ canvas/src/components/mobile/MobileHome.tsx | 208 +++++++ canvas/src/components/mobile/MobileMe.tsx | 194 ++++++ canvas/src/components/mobile/MobileSpawn.tsx | 429 +++++++++++++ .../mobile/__tests__/MobileApp.test.tsx | 211 +++++++ .../mobile/__tests__/components.test.ts | 101 +++ .../mobile/__tests__/palette.test.ts | 68 ++ canvas/src/components/mobile/components.tsx | 444 +++++++++++++ .../src/components/mobile/palette-context.tsx | 40 ++ canvas/src/components/mobile/palette.ts | 147 +++++ canvas/src/components/mobile/primitives.tsx | 278 +++++++++ 20 files changed, 4293 insertions(+), 31 deletions(-) create mode 100644 canvas/src/components/mobile/MobileApp.tsx create mode 100644 canvas/src/components/mobile/MobileCanvas.tsx create mode 100644 canvas/src/components/mobile/MobileChat.tsx create mode 100644 canvas/src/components/mobile/MobileComms.tsx create mode 100644 canvas/src/components/mobile/MobileDetail.tsx create mode 100644 canvas/src/components/mobile/MobileHome.tsx create mode 100644 canvas/src/components/mobile/MobileMe.tsx create mode 100644 canvas/src/components/mobile/MobileSpawn.tsx create mode 100644 canvas/src/components/mobile/__tests__/MobileApp.test.tsx create mode 100644 canvas/src/components/mobile/__tests__/components.test.ts create mode 100644 canvas/src/components/mobile/__tests__/palette.test.ts create mode 100644 canvas/src/components/mobile/components.tsx create mode 100644 canvas/src/components/mobile/palette-context.tsx create mode 100644 canvas/src/components/mobile/palette.ts create mode 100644 canvas/src/components/mobile/primitives.tsx diff --git a/canvas/src/app/layout.tsx b/canvas/src/app/layout.tsx index 21ec7962..04786994 100644 --- a/canvas/src/app/layout.tsx +++ b/canvas/src/app/layout.tsx @@ -1,6 +1,22 @@ import type { Metadata } from "next"; +import { Inter, JetBrains_Mono } from "next/font/google"; import { cookies, headers } from "next/headers"; import "./globals.css"; + +// Self-hosted at build time → CSP-safe (font-src 'self' covers them +// because Next.js serves the .woff2 from /_next/static). Exposed as +// CSS variables so the mobile palette can reference them without +// importing this module. +const interFont = Inter({ + subsets: ["latin"], + display: "swap", + variable: "--font-inter", +}); +const monoFont = JetBrains_Mono({ + subsets: ["latin"], + display: "swap", + variable: "--font-jetbrains", +}); import { AuthGate } from "@/components/AuthGate"; import { CookieConsent } from "@/components/CookieConsent"; import { PurchaseSuccessModal } from "@/components/PurchaseSuccessModal"; @@ -79,7 +95,7 @@ export default async function RootLayout({ dangerouslySetInnerHTML={{ __html: themeBootScript }} /> - + {/* AuthGate is a client component; it checks the session on mount and bounces anonymous users to the control plane's login page diff --git a/canvas/src/app/page.tsx b/canvas/src/app/page.tsx index 0bf8f62c..28cb37d9 100644 --- a/canvas/src/app/page.tsx +++ b/canvas/src/app/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from "react"; import { Canvas } from "@/components/Canvas"; import { Legend } from "@/components/Legend"; import { CommunicationOverlay } from "@/components/CommunicationOverlay"; +import { MobileApp } from "@/components/mobile/MobileApp"; import { Spinner } from "@/components/Spinner"; import { connectSocket, disconnectSocket } from "@/store/socket"; import { useCanvasStore } from "@/store/canvas"; @@ -14,6 +15,23 @@ export default function Home() { const hydrationError = useCanvasStore((s) => s.hydrationError); const setHydrationError = useCanvasStore((s) => s.setHydrationError); const [hydrating, setHydrating] = useState(true); + // < 640px viewport renders the dedicated mobile shell instead of the + // desktop canvas. Tri-state: `null` until matchMedia has resolved, + // then `true|false`. While null we keep the existing loading spinner + // up — that way mobile devices never flash the desktop tree (which + // they would if we defaulted to `false` and only flipped post-mount). + const [isMobile, setIsMobile] = useState(null); + useEffect(() => { + if (typeof window === "undefined" || !window.matchMedia) { + setIsMobile(false); + return; + } + const mq = window.matchMedia("(max-width: 639px)"); + const update = () => setIsMobile(mq.matches); + update(); + mq.addEventListener("change", update); + return () => mq.removeEventListener("change", update); + }, []); // 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 @@ -51,7 +69,10 @@ export default function Home() { }; }, []); - if (hydrating) { + // Hold the spinner while data hydrates OR while the viewport + // resolution hasn't settled yet (avoids a desktop-tree flash on + // mobile devices between SSR-paint and matchMedia). + if (hydrating || isMobile === null) { return (
@@ -66,6 +87,32 @@ export default function Home() { return ; } + if (isMobile) { + return ( + <> + + {hydrationError && ( +
+

{hydrationError}

+ +
+ )} + + ); + } + return ( <> diff --git a/canvas/src/components/Canvas.tsx b/canvas/src/components/Canvas.tsx index 5983b72f..888343b0 100644 --- a/canvas/src/components/Canvas.tsx +++ b/canvas/src/components/Canvas.tsx @@ -308,7 +308,9 @@ function CanvasInner() { showInteractive={false} />