diff --git a/apps/web/app/(landing)/layout.tsx b/apps/web/app/(landing)/layout.tsx
index 7132026c5..675ce472e 100644
--- a/apps/web/app/(landing)/layout.tsx
+++ b/apps/web/app/(landing)/layout.tsx
@@ -1,4 +1,4 @@
-import { Instrument_Serif, Noto_Serif_SC } from "next/font/google";
+import { Instrument_Serif, Noto_Serif_SC, Caveat } from "next/font/google";
import { LocaleProvider } from "@/features/landing/i18n";
import { getRequestLocale } from "@/lib/request-locale";
@@ -14,6 +14,15 @@ const notoSerifSC = Noto_Serif_SC({
variable: "--font-serif-zh",
});
+// Handwritten face for the newhome demo's "this is live, try it" hint. Kept in
+// this server layout (not the client newhome component) — next/font in a client
+// component fails to resolve @swc/helpers under pnpm.
+const caveat = Caveat({
+ subsets: ["latin"],
+ weight: "600",
+ variable: "--font-hand",
+});
+
const jsonLd = {
"@context": "https://schema.org",
"@graph": [
@@ -52,7 +61,7 @@ export default async function LandingLayout({
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
-
+
{children}
>
diff --git a/apps/web/app/(landing)/newhome/page.tsx b/apps/web/app/(landing)/newhome/page.tsx
new file mode 100644
index 000000000..d435cb2d9
--- /dev/null
+++ b/apps/web/app/(landing)/newhome/page.tsx
@@ -0,0 +1,15 @@
+import type { Metadata } from "next";
+import { NewHomeLanding } from "@/features/landing/newhome/newhome-landing";
+
+export const metadata: Metadata = {
+ title: "Multica — Landing V2 (sandbox)",
+ description: "Work-in-progress rebuild of the Multica landing page.",
+ robots: { index: false, follow: false },
+ alternates: {
+ canonical: "/newhome",
+ },
+};
+
+export default function NewHomePage() {
+ return
;
+}
diff --git a/apps/web/app/custom.css b/apps/web/app/custom.css
index 21a2f904b..d6136ea56 100644
--- a/apps/web/app/custom.css
+++ b/apps/web/app/custom.css
@@ -43,3 +43,144 @@
--scrollbar-thumb-hover: oklch(0 0 0 / 18%);
--scrollbar-track: transparent;
}
+
+/* newhome value #1 board demo: a card softly "lands" when it advances a
+ * column, so the auto-play reads as movement rather than a teleport. */
+@keyframes newhome-card-land {
+ from {
+ opacity: 0;
+ transform: translateY(-8px) scale(0.97);
+ }
+ to {
+ opacity: 1;
+ transform: none;
+ }
+}
+.newhome-card-land {
+ animation: newhome-card-land 0.45s cubic-bezier(0.2, 0.8, 0.2, 1);
+}
+@media (prefers-reduced-motion: reduce) {
+ .newhome-card-land {
+ animation: none;
+ }
+}
+
+/* newhome "this is live, try it" hint: the arrow draws itself in once, then the
+ * whole annotation gently floats to draw the eye. Respects reduced-motion. */
+@keyframes newhome-hint-bob {
+ 0%,
+ 100% {
+ transform: translateY(0);
+ }
+ 50% {
+ transform: translateY(-7px);
+ }
+}
+.newhome-hint {
+ animation: newhome-hint-bob 2.8s ease-in-out infinite;
+}
+@keyframes newhome-hint-draw {
+ from {
+ stroke-dashoffset: 90;
+ }
+ to {
+ stroke-dashoffset: 0;
+ }
+}
+.newhome-hint-arrow path {
+ stroke-dasharray: 90;
+ animation: newhome-hint-draw 0.9s ease-out 0.25s both;
+}
+@media (prefers-reduced-motion: reduce) {
+ .newhome-hint {
+ animation: none;
+ }
+ .newhome-hint-arrow path {
+ animation: none;
+ stroke-dashoffset: 0;
+ }
+}
+
+/* newhome demo: darken --brand so the selected "working" chip (white text on
+ * bg-brand) has readable contrast. Scoped to the embedded product demo only. */
+.landing-demo {
+ --brand: oklch(0.46 0.17 256);
+}
+
+/* Hide every scrollbar inside the embedded product demo — on a marketing page
+ * native scrollbars read as jarring. Scroll/drag still works; only the bar is
+ * hidden. Scoped to the demo so the rest of the site keeps its scrollbars. */
+.landing-demo,
+.landing-demo * {
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+}
+.landing-demo::-webkit-scrollbar,
+.landing-demo *::-webkit-scrollbar {
+ width: 0;
+ height: 0;
+ display: none;
+}
+
+/* Suppress the board's "Hidden columns" side panel in the demo. We hide the
+ * backlog/blocked columns via the status allowlist, which would otherwise list
+ * them in this panel (w-[240px] is unique to that panel — columns use an inline
+ * width). Keeps the demo board to a clean four columns. */
+.landing-demo [class~="w-[240px]"] {
+ display: none;
+}
+
+/* Popups (menus, dialogs, hover cards, tooltips) portal INTO the scaled demo
+ * box (see PortalContainerProvider) so they inherit the demo's zoom instead of
+ * rendering at 1:1 over the page. Full-screen modals like the transcript are
+ * sized in viewport units (100vh/100vw), which overflow the small demo window;
+ * cap them to the window (their fixed containing block is the transformed box)
+ * so the header/footer stay visible. Auto-height modals stay smaller than the
+ * cap, so they are unaffected.
+ *
+ * Scoped into @layer utilities so this wins the cascade against Tailwind's own
+ * `!`-important sizing utilities (e.g. the transcript's !h-[calc(100vh-4rem)]):
+ * same layer + higher specificity. Height is the only axis that overflows the
+ * window; width already fits, so leave each modal's own max-width intact. */
+@layer utilities {
+ .landing-demo [data-slot="dialog-content"] {
+ max-height: calc(100% - 1.5rem) !important;
+ }
+}
+
+/* newhome (Landing V2 sandbox) — auto-scrolling agent marquee. Pure CSS, no
+ * motion library: the track holds two identical groups and slides left by one
+ * group width for a seamless loop. The wrapper is overflow-hidden, so there is
+ * no horizontal scrollbar. Pauses on hover; respects reduced-motion. */
+@keyframes newhome-marquee {
+ to {
+ transform: translateX(-50%);
+ }
+}
+.newhome-marquee {
+ -webkit-mask-image: linear-gradient(
+ to right,
+ transparent,
+ #000 6%,
+ #000 94%,
+ transparent
+ );
+ mask-image: linear-gradient(
+ to right,
+ transparent,
+ #000 6%,
+ #000 94%,
+ transparent
+ );
+}
+.newhome-marquee-track {
+ animation: newhome-marquee 45s linear infinite;
+}
+.newhome-marquee:hover .newhome-marquee-track {
+ animation-play-state: paused;
+}
+@media (prefers-reduced-motion: reduce) {
+ .newhome-marquee-track {
+ animation: none;
+ }
+}
diff --git a/apps/web/features/landing/newhome/demo/demo-board.tsx b/apps/web/features/landing/newhome/demo/demo-board.tsx
new file mode 100644
index 000000000..ef78156ca
--- /dev/null
+++ b/apps/web/features/landing/newhome/demo/demo-board.tsx
@@ -0,0 +1,191 @@
+"use client";
+
+import { useMemo, useRef, useState } from "react";
+import { ArrowLeft, LayoutList, Bot, Sparkles } from "lucide-react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { cn } from "@multica/ui/lib/utils";
+import { PortalContainerProvider } from "@multica/ui/lib/portal-container";
+import { setApiInstance } from "@multica/core/api";
+import { I18nProvider } from "@multica/core/i18n/react";
+import { WorkspaceSlugProvider } from "@multica/core/paths";
+import { workspaceListOptions } from "@multica/core/workspace/queries";
+import { useIssueViewStore } from "@multica/core/issues/stores/view-store";
+import { RESOURCES } from "@multica/views/locales";
+import {
+ NavigationProvider,
+ type NavigationAdapter,
+} from "@multica/views/navigation";
+import { IssuesPage, IssueDetail } from "@multica/views/issues/components";
+import { ModalRegistry } from "@multica/views/modals/registry";
+import { createMockApi } from "./mock-api";
+import { WORKSPACE } from "./mock-data";
+import { DemoErrorBoundary } from "./demo-error-boundary";
+import { AgentsPanel, SkillsPanel } from "./demo-panels";
+
+// Install the mock client globally (the @multica/core/api singleton). This
+// module is only ever imported client-side via a dynamic ssr:false import,
+// and only on the landing page, so overriding the singleton here is safe.
+setApiInstance(createMockApi());
+
+const ISSUE_PATH = /\/issues\/([^/?#]+)/;
+
+const TABS = [
+ { id: "issues", label: "Issues", Icon: LayoutList },
+ { id: "agents", label: "Agents", Icon: Bot },
+ { id: "skills", label: "Skills", Icon: Sparkles },
+] as const;
+type TabId = (typeof TABS)[number]["id"];
+
+export function DemoBoard() {
+ const [tab, setTab] = useState
("issues");
+ const [detailId, setDetailId] = useState(null);
+
+ // Keep the latest setter in a ref so the navigation adapter is stable.
+ const setDetailRef = useRef(setDetailId);
+ setDetailRef.current = setDetailId;
+
+ // Portal mount for popups (menus, dialogs, hover cards, tooltips). It lives
+ // inside the scaled demo box, so every portaled popup inherits the same zoom
+ // instead of rendering at 1:1 over the page.
+ const portalRef = useRef(null);
+
+ const queryClient = useMemo(() => {
+ const qc = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false, refetchOnWindowFocus: false, staleTime: 30_000 },
+ mutations: { retry: false },
+ },
+ });
+ qc.setQueryData(workspaceListOptions().queryKey, [WORKSPACE]);
+ return qc;
+ }, []);
+
+ // No status filter — show every column (backlog … blocked). Reset on mount in
+ // case a previous session persisted a filter.
+ useState(() => {
+ useIssueViewStore.setState({ statusFilters: [] });
+ return true;
+ });
+
+ const adapter = useMemo(() => {
+ const openFromPath = (path: string) => {
+ const m = path.match(ISSUE_PATH);
+ if (m?.[1]) setDetailRef.current(decodeURIComponent(m[1]));
+ };
+ return {
+ push: openFromPath,
+ replace: openFromPath,
+ back: () => setDetailRef.current(null),
+ pathname: "/demo/issues",
+ searchParams: new URLSearchParams(),
+ getShareableUrl: (p) => p,
+ };
+ }, []);
+
+ const resources = useMemo(() => ({ en: RESOURCES.en }), []);
+
+ return (
+
+
+
+
+
+
+ {/* `landing-demo` darkens --brand so the selected "working" chip
+ stays readable (white-on-brand). */}
+
+
{
+ setTab(t);
+ setDetailId(null);
+ }}
+ />
+
+ {tab === "issues" ? (
+ detailId ? (
+
+
setDetailId(null)}
+ className="flex shrink-0 items-center gap-1.5 px-4 py-2.5 text-[13px] font-medium text-muted-foreground hover:text-foreground"
+ >
+
+ Back to board
+
+
+ setDetailId(null)}
+ onDelete={() => setDetailId(null)}
+ />
+
+
+ ) : (
+ // Hide IssuesPage's own PageHeader — the browser tabs
+ // above already serve as the app header.
+
+
+
+ )
+ ) : tab === "agents" ? (
+
+ ) : (
+
+ )}
+
+ {/* Popups (menus, dialogs, hover cards, tooltips) portal here
+ via PortalContainerProvider, so they share the demo's zoom
+ instead of rendering at 1:1 over the page. */}
+
+
+ {/* Real create-issue dialog host — opened by the board's "+"
+ buttons via the global modal store. Portals into the scaled
+ box (see PortalContainerProvider) so it matches the demo zoom. */}
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function BrowserBar({
+ tab,
+ onTab,
+}: {
+ tab: TabId;
+ onTab: (t: TabId) => void;
+}) {
+ return (
+
+
+
+
+
+
+
+ {TABS.map(({ id, label, Icon }) => (
+ onTab(id)}
+ className={cn(
+ "inline-flex h-7 items-center gap-1.5 rounded-[7px] px-2.5 text-[13px] font-medium transition-colors",
+ tab === id
+ ? "bg-white text-[#0a0d12] shadow-[0_1px_2px_rgba(10,13,18,0.08)] ring-1 ring-[#0a0d12]/8"
+ : "text-[#0a0d12]/55 hover:bg-[#0a0d12]/[0.04] hover:text-[#0a0d12]/80",
+ )}
+ >
+
+ {label}
+
+ ))}
+
+
+ );
+}
diff --git a/apps/web/features/landing/newhome/demo/demo-error-boundary.tsx b/apps/web/features/landing/newhome/demo/demo-error-boundary.tsx
new file mode 100644
index 000000000..595eb11cc
--- /dev/null
+++ b/apps/web/features/landing/newhome/demo/demo-error-boundary.tsx
@@ -0,0 +1,55 @@
+"use client";
+
+import { Component, type ReactNode } from "react";
+import { RotateCcw } from "lucide-react";
+
+interface Props {
+ children: ReactNode;
+ // When provided, render this on error instead of the default reset panel.
+ // Pass `null` to fail silently (used for the portaled modal host).
+ fallback?: ReactNode;
+}
+interface State {
+ hasError: boolean;
+}
+
+/**
+ * Isolates the live product demo. If any interaction throws during render,
+ * we show a small reset panel instead of letting the error bubble up and
+ * white-screen the whole landing page. "Reset" remounts the demo subtree.
+ */
+export class DemoErrorBoundary extends Component {
+ state: State = { hasError: false };
+
+ static getDerivedStateFromError(): State {
+ return { hasError: true };
+ }
+
+ componentDidCatch() {
+ // Swallow — the demo is non-critical marketing UI. Nothing to report.
+ }
+
+ private reset = () => this.setState({ hasError: false });
+
+ render() {
+ if (this.state.hasError) {
+ if ("fallback" in this.props) return this.props.fallback ?? null;
+ return (
+
+
+ The demo hit a snag.
+
+
+
+ Reset demo
+
+
+ );
+ }
+ return this.props.children;
+ }
+}
diff --git a/apps/web/features/landing/newhome/demo/demo-panels.tsx b/apps/web/features/landing/newhome/demo/demo-panels.tsx
new file mode 100644
index 000000000..abbf16a11
--- /dev/null
+++ b/apps/web/features/landing/newhome/demo/demo-panels.tsx
@@ -0,0 +1,66 @@
+"use client";
+
+// Lightweight panels for the Agents / Skills tabs in the demo browser. These
+// are bespoke presentational views over the same mock data — not the heavy
+// product pages — so the tabs feel real without extra coupling or risk.
+
+import { AGENTS, ISSUES, RUNNING_TASKS, SKILLS } from "./mock-data";
+
+export function AgentsPanel() {
+ return (
+
+
+ {AGENTS.map((a) => {
+ const task = RUNNING_TASKS.find((t) => t.agent_id === a.id);
+ const issue = task && ISSUES.find((i) => i.id === task.issue_id);
+ return (
+
+ {a.avatar_url ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : (
+
+ )}
+
+
+ {a.name}
+
+ {issue ? (
+
+
+ Working on {issue.identifier}
+
+ ) : (
+
Idle
+ )}
+
+
+ );
+ })}
+
+
+ );
+}
+
+export function SkillsPanel() {
+ return (
+
+
+ {SKILLS.map((s) => (
+
+
{s.name}
+
+ {s.description}
+
+
+ ))}
+
+
+ );
+}
diff --git a/apps/web/features/landing/newhome/demo/mock-api.ts b/apps/web/features/landing/newhome/demo/mock-api.ts
new file mode 100644
index 000000000..60c48dc39
--- /dev/null
+++ b/apps/web/features/landing/newhome/demo/mock-api.ts
@@ -0,0 +1,125 @@
+// A stand-in ApiClient for the landing-page product demo. Returns canned,
+// server-shaped responses from mock-data so the real product components run
+// with zero backend. Installed via setApiInstance() (the global injection
+// seam in @multica/core/api). Any method not implemented here resolves to
+// `undefined` via the Proxy fallback, so unanticipated calls never throw.
+
+import type { ApiClient } from "@multica/core/api";
+import {
+ AGENTS,
+ EXEC_LOG,
+ ISSUES,
+ MEMBERS,
+ PULL_REQUESTS,
+ RUNNING_TASKS,
+ TIMELINE,
+ TRANSCRIPT_BY_ISSUE,
+ WORKSPACE,
+ createMockIssue,
+ patchIssue,
+} from "./mock-data";
+
+// Every task (snapshot + per-issue execution log), so a transcript request for
+// any task id can resolve which issue (and thus which transcript) it belongs to.
+const ALL_TASKS = [...RUNNING_TASKS, ...Object.values(EXEC_LOG).flat()];
+
+type AnyParams = Record | undefined;
+
+const handlers: Record Promise> = {
+ // Keep the demo logged-out: the landing's auth init must not think a user
+ // is signed in (that would redirect away from the marketing page).
+ getMe: () => Promise.reject(new Error("demo: unauthenticated")),
+ getBaseUrl: () => "" as unknown as Promise,
+
+ listWorkspaces: () => Promise.resolve([WORKSPACE]),
+ getWorkspace: () => Promise.resolve(WORKSPACE),
+ listMembers: () => Promise.resolve(MEMBERS),
+ listAgents: () => Promise.resolve(AGENTS),
+ listSquads: () => Promise.resolve([]),
+ listRuntimes: () => Promise.resolve([]),
+ getAgent: (id: string) =>
+ Promise.resolve(AGENTS.find((a) => a.id === id) ?? AGENTS[0]),
+
+ // Agent transcript (live run log) — resolve the task's issue, return its
+ // transcript with seq / task_id / issue_id stamped on each message.
+ listTaskMessages: (taskId: string) => {
+ const task = ALL_TASKS.find((t) => t.id === taskId);
+ const tmpl = task ? TRANSCRIPT_BY_ISSUE[task.issue_id] : undefined;
+ if (!task || !tmpl) return Promise.resolve([]);
+ return Promise.resolve(
+ tmpl.map((m, i) => ({
+ ...m,
+ seq: i,
+ task_id: task.id,
+ issue_id: task.issue_id,
+ })),
+ );
+ },
+ listSkills: () => Promise.resolve([]),
+ getAssigneeFrequency: () => Promise.resolve([]),
+
+ listIssues: (params: AnyParams) => {
+ const status = params?.status as string | undefined;
+ const issues = status ? ISSUES.filter((i) => i.status === status) : [...ISSUES];
+ return Promise.resolve({ issues, total: issues.length });
+ },
+ listGroupedIssues: () => Promise.resolve({ groups: [] }),
+ getIssue: (id: string) => {
+ const issue = ISSUES.find((i) => i.id === id);
+ return issue
+ ? Promise.resolve(issue)
+ : Promise.reject(new Error("demo: issue not found"));
+ },
+ getChildIssueProgress: () => Promise.resolve({ progress: [] }),
+ listChildIssues: () => Promise.resolve({ issues: [] }),
+ listChildrenByParents: () => Promise.resolve({ issues: [] }),
+ listTimeline: (issueId: string) => Promise.resolve(TIMELINE[issueId] ?? []),
+ listComments: () => Promise.resolve([]),
+ listIssueSubscribers: () => Promise.resolve([]),
+ listAttachments: () => Promise.resolve([]),
+ getIssueUsage: () =>
+ Promise.resolve({ total_tokens: 0, total_cost_usd: 0, runs: 0 }),
+ listProjects: () => Promise.resolve({ projects: [] }),
+ listLabels: () => Promise.resolve({ labels: [] }),
+ listIssuePullRequests: (issueId: string) =>
+ Promise.resolve({ pull_requests: PULL_REQUESTS[issueId] ?? [] }),
+ listTasksByIssue: (issueId: string) =>
+ Promise.resolve(EXEC_LOG[issueId] ?? []),
+ listAgentTasks: () => Promise.resolve([]),
+ getAgentTaskSnapshot: () => Promise.resolve(RUNNING_TASKS),
+ getActiveTasksForIssue: (issueId: string) =>
+ Promise.resolve({
+ tasks: RUNNING_TASKS.filter((t) => t.issue_id === issueId),
+ }),
+
+ // Writes: mutate the in-memory issue so drag-to-change-status persists
+ // across the refetch that react-query fires after a mutation settles.
+ updateIssue: (id: string, data: AnyParams) => {
+ const updated = patchIssue(id, (data ?? {}) as Record);
+ return Promise.resolve(updated ?? ISSUES.find((i) => i.id === id));
+ },
+ // Create-issue flow: build a card from the dialog input and add it.
+ createIssue: (data: AnyParams) =>
+ Promise.resolve(createMockIssue((data ?? {}) as { title: string })),
+ quickCreateIssue: (data: AnyParams) => {
+ const d = (data ?? {}) as { prompt?: string; agent_id?: string };
+ createMockIssue({
+ title: (d.prompt || "New agent task").slice(0, 80),
+ status: "todo",
+ assignee_type: d.agent_id ? "agent" : undefined,
+ assignee_id: d.agent_id,
+ });
+ return Promise.resolve({ task_id: "task-new" });
+ },
+};
+
+export function createMockApi(): ApiClient {
+ const target = {} as Record;
+ return new Proxy(target, {
+ get(_t, prop: string) {
+ if (prop in handlers) return handlers[prop];
+ // Unknown method → resolve to undefined so no call ever throws.
+ return () => Promise.resolve(undefined);
+ },
+ }) as unknown as ApiClient;
+}
diff --git a/apps/web/features/landing/newhome/demo/mock-data.ts b/apps/web/features/landing/newhome/demo/mock-data.ts
new file mode 100644
index 000000000..ef23fc738
--- /dev/null
+++ b/apps/web/features/landing/newhome/demo/mock-data.ts
@@ -0,0 +1,433 @@
+// Mock data for the interactive product demo embedded in the landing hero.
+// All ids/shapes follow the real backend contracts so the real product
+// components render unchanged. Issues are a MUTABLE module array so demo
+// interactions (drag to change status) persist across refetches.
+
+import type { Agent, AgentTask } from "@multica/core/types/agent";
+import type { TaskMessagePayload } from "@multica/core/types/events";
+import type { TimelineEntry } from "@multica/core/types/activity";
+import type { GitHubPullRequest } from "@multica/core/types/github";
+import type { Issue, IssueStatus, IssuePriority } from "@multica/core/types/issue";
+import type { MemberWithUser, Workspace } from "@multica/core/types/workspace";
+
+const NOW = "2026-06-01T09:00:00Z";
+
+export const WORKSPACE = {
+ id: "ws-demo",
+ name: "Acme",
+ slug: "demo",
+ created_at: NOW,
+ updated_at: NOW,
+} as unknown as Workspace;
+
+export const MEMBERS: MemberWithUser[] = [
+ {
+ id: "m-alex",
+ workspace_id: "ws-demo",
+ user_id: "u-alex",
+ role: "admin",
+ created_at: NOW,
+ name: "Alex Rivera",
+ email: "alex@acme.dev",
+ avatar_url: null,
+ },
+ {
+ id: "m-sam",
+ workspace_id: "ws-demo",
+ user_id: "u-sam",
+ role: "member",
+ created_at: NOW,
+ name: "Sam Chen",
+ email: "sam@acme.dev",
+ avatar_url: null,
+ },
+] as unknown as MemberWithUser[];
+
+// Each agent carries its provider mark as a data-URI avatar so the real
+// ActorAvatar renders the right icon on cards / detail / the working chip.
+const svgUri = (svg: string) => `data:image/svg+xml,${encodeURIComponent(svg)}`;
+
+const CLAUDE_SVG = ` `;
+
+const CODEX_SVG = ` `;
+
+const GEMINI_SVG = ` `;
+
+const KIMI_SVG = ` `;
+
+export const AGENTS: Agent[] = [
+ { id: "a-claude", name: "Claude Code", avatar_url: svgUri(CLAUDE_SVG) },
+ { id: "a-codex", name: "Codex", avatar_url: svgUri(CODEX_SVG) },
+ { id: "a-gemini", name: "Gemini CLI", avatar_url: svgUri(GEMINI_SVG) },
+ { id: "a-kimi", name: "Kimi", avatar_url: svgUri(KIMI_SVG) },
+].map(
+ (a) =>
+ ({
+ ...a,
+ workspace_id: "ws-demo",
+ description: "Autonomous coding agent — assign it an issue and it runs.",
+ instructions: "",
+ // Fields read by the agent hover-card (AgentProfileCard). Without these
+ // (esp. `skills`) hovering an agent throws.
+ skills: [],
+ runtime_id: "rt-demo",
+ runtime_mode: "cloud",
+ runtime_config: {},
+ custom_args: [],
+ owner_id: "u-alex",
+ owner_type: "member",
+ visibility: "workspace",
+ archived_at: null,
+ created_at: NOW,
+ updated_at: NOW,
+ }) as unknown as Agent,
+);
+
+type Seed = {
+ n: number;
+ title: string;
+ status: IssueStatus;
+ priority: IssuePriority;
+ at: "member" | "agent";
+ aid: string;
+ due?: string;
+};
+
+const SEEDS: Seed[] = [
+ { n: 160, title: "Add 2FA / TOTP support", status: "backlog", priority: "medium", at: "member", aid: "u-sam" },
+ { n: 162, title: "Investigate p95 latency on /search", status: "backlog", priority: "high", at: "agent", aid: "a-codex" },
+ { n: 165, title: "Spike: vector search over issues", status: "backlog", priority: "low", at: "agent", aid: "a-gemini" },
+ { n: 168, title: "Audit npm dependencies for CVEs", status: "backlog", priority: "medium", at: "member", aid: "u-alex" },
+ { n: 142, title: "Design pricing page v2", status: "todo", priority: "high", at: "member", aid: "u-alex" },
+ { n: 151, title: "Add SSO (SAML) to enterprise plan", status: "todo", priority: "low", at: "member", aid: "u-sam" },
+ { n: 156, title: "Refactor billing webhooks handler", status: "todo", priority: "medium", at: "agent", aid: "a-kimi" },
+ { n: 129, title: "Implement OAuth login flow", status: "in_progress", priority: "high", at: "agent", aid: "a-claude", due: "2026-06-08" },
+ { n: 133, title: "Migrate analytics events to new schema", status: "in_progress", priority: "medium", at: "agent", aid: "a-gemini" },
+ { n: 138, title: "Fix flaky checkout E2E test", status: "in_progress", priority: "medium", at: "agent", aid: "a-codex" },
+ { n: 147, title: "Polish onboarding empty states", status: "in_progress", priority: "medium", at: "member", aid: "u-alex" },
+ { n: 124, title: "Weekly dependency upgrade sweep", status: "in_review", priority: "low", at: "agent", aid: "a-claude" },
+ { n: 119, title: "Write API docs for webhooks", status: "in_review", priority: "medium", at: "member", aid: "u-sam" },
+ { n: 112, title: "Triage inbound bug reports", status: "done", priority: "low", at: "agent", aid: "a-codex" },
+ { n: 108, title: "Ship dark-mode polish", status: "done", priority: "medium", at: "member", aid: "u-alex" },
+ { n: 103, title: "Nightly DB backup health check", status: "done", priority: "low", at: "agent", aid: "a-gemini" },
+ { n: 170, title: "Enable SSO on the staging environment", status: "blocked", priority: "high", at: "member", aid: "u-sam" },
+ { n: 172, title: "Migrate CI to the new build runners", status: "blocked", priority: "medium", at: "agent", aid: "a-gemini" },
+];
+
+function makeIssue(seed: Seed, index: number): Issue {
+ return {
+ id: `issue-${seed.n}`,
+ workspace_id: "ws-demo",
+ number: seed.n,
+ identifier: `MUL-${seed.n}`,
+ title: seed.title,
+ description:
+ seed.at === "agent"
+ ? `Assigned to an agent. ${seed.title}. The agent picks this up, runs it, and reports back here.`
+ : `${seed.title}.`,
+ status: seed.status,
+ priority: seed.priority,
+ assignee_type: seed.at,
+ assignee_id: seed.aid,
+ creator_type: "member",
+ creator_id: "u-alex",
+ parent_issue_id: null,
+ project_id: null,
+ position: index,
+ start_date: null,
+ due_date: seed.due ?? null,
+ metadata: {},
+ created_at: NOW,
+ updated_at: NOW,
+ };
+}
+
+// Mutable so updateIssue (drag) persists across refetches.
+export const ISSUES: Issue[] = SEEDS.map(makeIssue);
+
+// Agents currently working — drives the "N working" header chip + avatar
+// stack and the agents-working filter. Each points at an in-progress,
+// agent-assigned issue above.
+const WORKING: { agent: string; issue: string }[] = [
+ { agent: "a-claude", issue: "issue-129" },
+ { agent: "a-gemini", issue: "issue-133" },
+ { agent: "a-codex", issue: "issue-138" },
+];
+
+// A few minutes ago, so the "agent is working" timers read naturally (e.g.
+// "6m") and tick up live, instead of an absurd elapsed value from a fixed date.
+const startedAt = (minsAgo: number) =>
+ new Date(Date.now() - minsAgo * 60_000).toISOString();
+
+export const RUNNING_TASKS: AgentTask[] = WORKING.map(
+ ({ agent, issue }, i) =>
+ ({
+ id: `task-${i}`,
+ agent_id: agent,
+ runtime_id: "rt-demo",
+ issue_id: issue,
+ status: "running",
+ priority: 0,
+ dispatched_at: startedAt(4 + i * 3 + 1),
+ started_at: startedAt(4 + i * 3),
+ completed_at: null,
+ result: null,
+ error: null,
+ created_at: NOW,
+ updated_at: NOW,
+ }) as unknown as AgentTask,
+);
+
+// Create-issue flow: build a fresh issue from the dialog input, drop it at the
+// top of its column, and return it so the board shows the new card.
+let nextNumber = 200;
+export function createMockIssue(
+ input: Partial & { title: string },
+): Issue {
+ const n = nextNumber++;
+ const now = new Date().toISOString();
+ const issue: Issue = {
+ id: `issue-new-${n}`,
+ workspace_id: "ws-demo",
+ number: n,
+ identifier: `MUL-${n}`,
+ title: input.title || "Untitled issue",
+ description: input.description ?? null,
+ status: input.status ?? "todo",
+ priority: input.priority ?? "none",
+ assignee_type: input.assignee_type ?? null,
+ assignee_id: input.assignee_id ?? null,
+ creator_type: "member",
+ creator_id: "u-alex",
+ parent_issue_id: input.parent_issue_id ?? null,
+ project_id: input.project_id ?? null,
+ position: -1,
+ start_date: input.start_date ?? null,
+ due_date: input.due_date ?? null,
+ metadata: {},
+ created_at: now,
+ updated_at: now,
+ };
+ ISSUES.unshift(issue);
+ return issue;
+}
+
+export function patchIssue(id: string, patch: Partial): Issue | undefined {
+ const i = ISSUES.findIndex((x) => x.id === id);
+ if (i === -1) return undefined;
+ ISSUES[i] = { ...ISSUES[i]!, ...patch, updated_at: NOW };
+ return ISSUES[i];
+}
+
+// ---------------------------------------------------------------------------
+// Richer issue detail: comments/discussion, linked PRs, execution history.
+// ---------------------------------------------------------------------------
+
+const mins = (m: number) => new Date(Date.now() - m * 60_000).toISOString();
+
+function comment(
+ id: string,
+ actorType: "member" | "agent",
+ actorId: string,
+ content: string,
+ minsAgo: number,
+ parentId: string | null = null,
+): TimelineEntry {
+ return {
+ type: "comment",
+ id,
+ actor_type: actorType,
+ actor_id: actorId,
+ content,
+ comment_type: "comment",
+ parent_id: parentId,
+ reactions: [],
+ attachments: [],
+ created_at: mins(minsAgo),
+ updated_at: mins(minsAgo),
+ resolved_at: null,
+ } as unknown as TimelineEntry;
+}
+
+// Comment / activity threads, keyed by issue id. Issues without an entry just
+// render an empty Activity feed (the real component handles that).
+export const TIMELINE: Record = {
+ // One conversation thread (root + nested replies) reads cleaner than four
+ // separate top-level comments each with its own reply box.
+ "issue-129": [
+ comment("c-129-1", "member", "u-alex", "Let's use the existing session store for the token refresh — no new tables.", 38),
+ comment("c-129-2", "agent", "a-claude", "Done. Implemented the redirect flow and token refresh against the session store, and opened a PR (linked on the right). Working through the edge cases now.", 22, "c-129-1"),
+ comment("c-129-3", "member", "u-alex", "Make sure we validate the state param to prevent CSRF.", 12, "c-129-1"),
+ comment("c-129-4", "agent", "a-claude", "Good catch — added state validation + a regression test. Re-running CI.", 5, "c-129-1"),
+ ],
+ "issue-133": [
+ comment("c-133-1", "member", "u-sam", "Which events are in scope for v1 of the migration?", 90),
+ comment("c-133-2", "agent", "a-gemini", "Starting with page_view, signup, and checkout; the long tail follows once the new schema is verified in staging.", 64, "c-133-1"),
+ ],
+ "issue-138": [
+ comment("c-138-1", "agent", "a-codex", "Reproduced the flake — it's a race on the cart fixture between the checkout poll and the seed step. Adding an explicit wait + idempotent seed.", 30),
+ comment("c-138-2", "member", "u-alex", "Nice, that's been haunting CI for weeks.", 18, "c-138-1"),
+ ],
+ "issue-124": [
+ comment("c-124-1", "agent", "a-claude", "Bumped 14 dependencies, 2 majors held back behind a follow-up. Lockfile + changelog in the PR.", 140),
+ ],
+};
+
+// Real pull requests from github.com/multica-ai/multica, linked to issues.
+function pr(
+ id: string,
+ number: number,
+ title: string,
+ state: "open" | "merged",
+ authorLogin: string,
+ opts: { mergedMinsAgo?: number; checks?: "passed" | "pending" | "failed"; add?: number; del?: number; files?: number } = {},
+): GitHubPullRequest {
+ return {
+ id,
+ workspace_id: "ws-demo",
+ repo_owner: "multica-ai",
+ repo_name: "multica",
+ number,
+ title,
+ state,
+ html_url: `https://github.com/multica-ai/multica/pull/${number}`,
+ branch: null,
+ author_login: authorLogin,
+ author_avatar_url: `https://github.com/${authorLogin}.png`,
+ merged_at: state === "merged" ? mins(opts.mergedMinsAgo ?? 120) : null,
+ closed_at: null,
+ pr_created_at: mins(600),
+ pr_updated_at: mins(opts.mergedMinsAgo ?? 60),
+ mergeable_state: state === "open" ? "clean" : null,
+ checks_conclusion: opts.checks ?? (state === "merged" ? "passed" : "pending"),
+ checks_passed: opts.checks === "failed" ? 11 : 12,
+ checks_failed: opts.checks === "failed" ? 1 : 0,
+ checks_pending: opts.checks === "pending" ? 2 : 0,
+ additions: opts.add ?? 180,
+ deletions: opts.del ?? 40,
+ changed_files: opts.files ?? 7,
+ } as unknown as GitHubPullRequest;
+}
+
+export const PULL_REQUESTS: Record = {
+ "issue-129": [
+ pr("pr-1", 3717, "refactor(server/lark): collapse HTTP_ENABLED + WS_ENABLED into the SECRET_KEY gate", "merged", "Bohan-J", { mergedMinsAgo: 80, add: 96, del: 120, files: 5 }),
+ ],
+ "issue-138": [
+ pr("pr-2", 3712, "test(migrate): concurrent migration race test using real Postgres (MUL-2956)", "open", "ldnvnbl", { checks: "pending", add: 210, del: 8, files: 3 }),
+ ],
+ "issue-133": [
+ pr("pr-3", 3716, "fix(execenv): refresh skills in place on reuse instead of accumulating duplicate dirs", "merged", "Bohan-J", { mergedMinsAgo: 200, add: 64, del: 31, files: 4 }),
+ ],
+ "issue-124": [
+ pr("pr-4", 3718, "fix(lark): use named import for react-qr-code to survive electron-vite interop", "open", "Bohan-J", { checks: "passed", add: 12, del: 6, files: 1 }),
+ ],
+};
+
+// Execution-log history per issue (api.listTasksByIssue) — running task(s)
+// plus a couple of completed past runs, so the panel isn't empty.
+function task(
+ id: string,
+ agentId: string,
+ issueId: string,
+ status: AgentTask["status"],
+ summary: string,
+ startMinsAgo: number,
+ endMinsAgo: number | null,
+): AgentTask {
+ return {
+ id,
+ agent_id: agentId,
+ runtime_id: "rt-demo",
+ issue_id: issueId,
+ status,
+ priority: 0,
+ dispatched_at: mins(startMinsAgo + 1),
+ started_at: mins(startMinsAgo),
+ completed_at: endMinsAgo == null ? null : mins(endMinsAgo),
+ result: null,
+ error: null,
+ trigger_summary: summary,
+ created_at: mins(startMinsAgo + 1),
+ updated_at: mins(endMinsAgo ?? 0),
+ } as unknown as AgentTask;
+}
+
+export const EXEC_LOG: Record = {
+ "issue-129": [
+ task("t-129-run", "a-claude", "issue-129", "running", "Implement OAuth login flow", 4, null),
+ task("t-129-1", "a-claude", "issue-129", "completed", "Scaffold OAuth routes", 95, 78),
+ task("t-129-0", "a-claude", "issue-129", "failed", "Initial run", 180, 150),
+ ],
+ "issue-133": [
+ task("t-133-run", "a-gemini", "issue-133", "running", "Migrate analytics events to new schema", 7, null),
+ task("t-133-1", "a-gemini", "issue-133", "completed", "Draft migration plan", 120, 96),
+ ],
+ "issue-138": [
+ task("t-138-run", "a-codex", "issue-138", "running", "Fix flaky checkout E2E test", 10, null),
+ task("t-138-1", "a-codex", "issue-138", "completed", "Reproduce the flake", 60, 44),
+ ],
+ "issue-124": [
+ task("t-124-1", "a-claude", "issue-124", "completed", "Weekly dependency upgrade sweep", 160, 138),
+ ],
+ "issue-112": [
+ task("t-112-1", "a-codex", "issue-112", "completed", "Triage inbound bug reports", 1400, 1380),
+ ],
+};
+
+// ---------------------------------------------------------------------------
+// Agent transcripts (the "running" agent's live log, opened via the transcript
+// button). Keyed by issue; listTaskMessages resolves a task id to its issue.
+// ---------------------------------------------------------------------------
+type Msg = Omit;
+const think = (content: string): Msg => ({ type: "thinking", content });
+const say = (content: string): Msg => ({ type: "text", content });
+const tool = (t: string, input: Record): Msg => ({ type: "tool_use", tool: t, input });
+const result = (t: string, output: string): Msg => ({ type: "tool_result", tool: t, output });
+
+export const TRANSCRIPT_BY_ISSUE: Record = {
+ "issue-129": [
+ think("Let me see how auth is wired today — the session store and the existing login route — so I can reuse them instead of adding new tables."),
+ tool("Read", { file_path: "server/internal/auth/session.go" }),
+ result("Read", "// session.go — NewSession, Refresh, store-backed cookie sessions (138 lines)"),
+ say("Got it. I'll add an OAuth redirect handler and store the token in the existing session store. Writing it now."),
+ tool("Edit", { file_path: "server/internal/auth/oauth.go", summary: "add Authorize + Callback handlers, token exchange" }),
+ result("Edit", "Created server/internal/auth/oauth.go (+96 −0)"),
+ think("Need to validate the `state` param on the callback to prevent CSRF."),
+ tool("Edit", { file_path: "server/internal/auth/oauth.go", summary: "validate state param against the signed cookie" }),
+ result("Edit", "Updated server/internal/auth/oauth.go (+14 −1)"),
+ tool("Bash", { command: "go test ./internal/auth/..." }),
+ result("Bash", "ok \tmultica/internal/auth\t1.82s"),
+ say("Tests pass. Opened a PR with the redirect flow + token refresh + the state-validation test. Working through the last edge cases (expired token re-auth)."),
+ ],
+ "issue-133": [
+ think("Mapping the old analytics events to the new schema. Let me read the current event definitions first."),
+ tool("Read", { file_path: "packages/analytics/events.ts" }),
+ result("Read", "// 41 event types; page_view / signup / checkout are the high-volume ones"),
+ say("I'll migrate page_view, signup, and checkout first, then backfill the long tail once staging looks clean."),
+ tool("Bash", { command: "pnpm migrate:analytics --events page_view,signup,checkout --dry-run" }),
+ result("Bash", "dry-run: would migrate 3 event types · 1,243,902 rows · est. 4m12s"),
+ say("Dry-run looks right. Running it against staging now and will diff the row counts before touching prod."),
+ ],
+ "issue-138": [
+ think("Reproducing the flake first — running the checkout E2E in a tight loop to catch it."),
+ tool("Bash", { command: "pnpm exec playwright test checkout --repeat-each=20" }),
+ result("Bash", "18 passed, 2 failed — timeout waiting for [data-testid=cart-total]"),
+ say("It's a race between the checkout poll and the cart-seed step. Adding an explicit wait + making the seed idempotent."),
+ tool("Edit", { file_path: "e2e/tests/checkout.spec.ts", summary: "await cart-ready, dedupe seed" }),
+ result("Edit", "Updated e2e/tests/checkout.spec.ts (+9 −3)"),
+ tool("Bash", { command: "pnpm exec playwright test checkout --repeat-each=30" }),
+ result("Bash", "30 passed (0 flaky)"),
+ say("30/30 green now. Pushing the fix and linking the PR."),
+ ],
+};
+
+// Mock skills for the "Skills" tab — reusable workflows agents can run.
+export const SKILLS: { name: string; description: string }[] = [
+ { name: "PR Review", description: "Read a diff, flag bugs & style issues, leave inline review comments." },
+ { name: "Bug Repro", description: "Turn a bug report into a minimal reproduction and a failing test." },
+ { name: "Release Notes", description: "Summarize merged PRs since the last tag into a changelog." },
+ { name: "Dependency Sweep", description: "Bump dependencies, run the suite, open a PR with the lockfile diff." },
+ { name: "Issue Triage", description: "Label, prioritize, and route inbound issues to the right owner." },
+ { name: "Docs Sync", description: "Keep API docs in lockstep with code changes on every merge." },
+];
diff --git a/apps/web/features/landing/newhome/demo/value-board-demo.tsx b/apps/web/features/landing/newhome/demo/value-board-demo.tsx
new file mode 100644
index 000000000..104dc1242
--- /dev/null
+++ b/apps/web/features/landing/newhome/demo/value-board-demo.tsx
@@ -0,0 +1,274 @@
+"use client";
+
+// Value #1 — "See every agent on one board". A focused, auto-playing board:
+// agents are working in In Progress; one by one they finish and their card
+// advances to In Review / Done while the "working" chip ticks down, then it
+// loops. Built from the SAME product components the hero demo uses — real
+// BoardCardContent cards, real per-status column chrome (STATUS_CONFIG), the
+// real WorkspaceAgentWorkingChip — so it stays visually consistent with the
+// hero board. Cards are driven by local state (no api mutation), so it's
+// fully isolated from the hero demo's shared mock data.
+
+import { useEffect, useMemo, useRef, useState } from "react";
+import {
+ QueryClient,
+ QueryClientProvider,
+ useQueryClient,
+} from "@tanstack/react-query";
+import { setApiInstance } from "@multica/core/api";
+import { I18nProvider } from "@multica/core/i18n/react";
+import { WorkspaceSlugProvider } from "@multica/core/paths";
+import { STATUS_CONFIG } from "@multica/core/issues/config";
+import { agentTaskSnapshotKeys } from "@multica/core/agents";
+import {
+ agentListOptions,
+ memberListOptions,
+ workspaceListOptions,
+} from "@multica/core/workspace/queries";
+import { useIssueViewStore } from "@multica/core/issues/stores/view-store";
+import { ViewStoreProvider } from "@multica/core/issues/stores/view-store-context";
+import { RESOURCES } from "@multica/views/locales";
+import {
+ NavigationProvider,
+ type NavigationAdapter,
+} from "@multica/views/navigation";
+import {
+ BoardCardContent,
+ StatusHeading,
+ WorkspaceAgentWorkingChip,
+} from "@multica/views/issues/components";
+import type { AgentTask, Issue, IssueStatus } from "@multica/core/types";
+import { AGENTS, MEMBERS, WORKSPACE } from "./mock-data";
+import { createMockApi } from "./mock-api";
+import { DEMO_ZOOM } from "./zoom";
+
+setApiInstance(createMockApi());
+
+const WS_ID = "ws-demo";
+
+// Natural (unscaled) size of the board canvas. 4 columns × 280 + 3 gaps × 16 +
+// p-1 ×2 = 1176 wide; chip row (44) + board (718) = 762 tall. Scaled by the
+// shared DEMO_ZOOM (0.85) the board ends up ~1000 × 648 — the same height as
+// the hero demo, so the two boards read as the same product, not a squashed
+// variant.
+const NATURAL_W = 1176;
+const NATURAL_H = 762;
+const BOARD_H = 718;
+
+const NOOP_NAV: NavigationAdapter = {
+ push: () => {},
+ replace: () => {},
+ back: () => {},
+ pathname: "/demo/issues",
+ searchParams: new URLSearchParams(),
+ getShareableUrl: (p) => p,
+};
+
+// Mirror the real board's column order/width (BOARD_COL_WIDTH = 280).
+const COLUMNS: IssueStatus[] = ["todo", "in_progress", "in_review", "done"];
+const COL_WIDTH = 280;
+const NOW = "2026-06-01T09:00:00Z";
+const STARTED = "2026-06-04T08:30:00Z";
+
+function mk(
+ n: number,
+ title: string,
+ status: IssueStatus,
+ at: "member" | "agent",
+ aid: string,
+ priority: Issue["priority"],
+): Issue {
+ return {
+ id: `vb-${n}`,
+ workspace_id: WS_ID,
+ number: n,
+ identifier: `MUL-${n}`,
+ title,
+ description: null,
+ status,
+ priority,
+ assignee_type: at,
+ assignee_id: aid,
+ creator_type: "member",
+ creator_id: "u-alex",
+ parent_issue_id: null,
+ project_id: null,
+ position: n,
+ start_date: null,
+ due_date: null,
+ metadata: {},
+ created_at: NOW,
+ updated_at: NOW,
+ };
+}
+
+const INITIAL: Issue[] = [
+ // Todo
+ mk(214, "Design pricing page v2", "todo", "member", "u-alex", "high"),
+ mk(218, "Add SSO (SAML) to enterprise plan", "todo", "member", "u-sam", "low"),
+ mk(156, "Refactor billing webhooks handler", "todo", "agent", "a-kimi", "medium"),
+ mk(241, "Audit npm dependencies for CVEs", "todo", "member", "u-alex", "low"),
+ // In Progress — the agents currently working
+ mk(129, "Implement OAuth login flow", "in_progress", "agent", "a-claude", "high"),
+ mk(133, "Migrate analytics events to new schema", "in_progress", "agent", "a-gemini", "medium"),
+ mk(138, "Fix flaky checkout E2E test", "in_progress", "agent", "a-codex", "medium"),
+ mk(147, "Polish onboarding empty states", "in_progress", "member", "u-alex", "medium"),
+ // In Review
+ mk(119, "Write API docs for webhooks", "in_review", "member", "u-sam", "medium"),
+ mk(124, "Weekly dependency upgrade", "in_review", "agent", "a-claude", "medium"),
+ mk(122, "Improve search relevance scoring", "in_review", "member", "u-alex", "low"),
+ // Done
+ mk(108, "Ship dark-mode polish", "done", "member", "u-alex", "medium"),
+ mk(131, "Nightly DB backup job", "done", "agent", "a-gemini", "medium"),
+ mk(116, "Fix mobile nav overflow", "done", "member", "u-sam", "low"),
+];
+
+// Each step advances one card. The "working" chip ticks 3 → 2 → 1 as agents
+// finish (Codex then Claude); Gemini stays working through the loop so the
+// chip never reads "0", then everything resets.
+const MOVES: { id: string; to: IssueStatus }[] = [
+ { id: "vb-138", to: "in_review" },
+ { id: "vb-138", to: "done" },
+ { id: "vb-129", to: "in_review" },
+ { id: "vb-129", to: "done" },
+];
+const STEP_MS = 2200;
+
+// Build the running-task snapshot the real working chip reads from, derived
+// from whichever agent-assigned cards are currently in progress.
+function runningTasksFor(issues: Issue[]): AgentTask[] {
+ return issues
+ .filter((i) => i.assignee_type === "agent" && i.status === "in_progress")
+ .map(
+ (i) =>
+ ({
+ id: `vt-${i.id}`,
+ agent_id: i.assignee_id,
+ runtime_id: "rt-demo",
+ issue_id: i.id,
+ status: "running",
+ priority: 0,
+ dispatched_at: STARTED,
+ started_at: STARTED,
+ completed_at: null,
+ result: null,
+ error: null,
+ created_at: NOW,
+ updated_at: NOW,
+ }) as unknown as AgentTask,
+ );
+}
+
+export function ValueBoardDemo() {
+ const queryClient = useMemo(() => {
+ const qc = new QueryClient({
+ defaultOptions: { queries: { retry: false, staleTime: Infinity } },
+ });
+ qc.setQueryData(workspaceListOptions().queryKey, [WORKSPACE]);
+ qc.setQueryData(memberListOptions(WS_ID).queryKey, MEMBERS);
+ qc.setQueryData(agentListOptions(WS_ID).queryKey, AGENTS);
+ qc.setQueryData(agentTaskSnapshotKeys.list(WS_ID), runningTasksFor(INITIAL));
+ return qc;
+ }, []);
+
+ const resources = useMemo(() => ({ en: RESOURCES.en }), []);
+
+ return (
+ // Scale the natural-size board down by the shared DEMO_ZOOM so it renders at
+ // the same scale as the hero demo. The visible box is sized to the scaled
+ // dimensions and centered; the inner box lays out at full size.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function Board() {
+ const qc = useQueryClient();
+ const [issues, setIssues] = useState(INITIAL);
+ const [movedId, setMovedId] = useState(null);
+ const stepRef = useRef(0);
+
+ // Auto-play: advance one card per tick, loop after the last move.
+ useEffect(() => {
+ const tick = () => {
+ const step = stepRef.current;
+ if (step >= MOVES.length) {
+ stepRef.current = 0;
+ setIssues(INITIAL);
+ setMovedId(null);
+ return;
+ }
+ const { id, to } = MOVES[step]!;
+ stepRef.current = step + 1;
+ setIssues((prev) => prev.map((i) => (i.id === id ? { ...i, status: to } : i)));
+ setMovedId(id);
+ window.setTimeout(() => setMovedId((m) => (m === id ? null : m)), 650);
+ };
+ const interval = window.setInterval(tick, STEP_MS);
+ return () => window.clearInterval(interval);
+ }, []);
+
+ // Keep the working chip's snapshot in sync with the in-progress agents.
+ useEffect(() => {
+ qc.setQueryData(agentTaskSnapshotKeys.list(WS_ID), runningTasksFor(issues));
+ }, [issues, qc]);
+
+ return (
+ // Non-interactive: this is a playing illustration, not the interactive demo.
+
+
+ {}} />
+
+ {/* Pinned height so the board never reflows as cards move between
+ columns; columns stretch to fill and scroll internally if needed. */}
+
+ {COLUMNS.map((status) => {
+ const cards = issues.filter((i) => i.status === status);
+ const cfg = STATUS_CONFIG[status];
+ return (
+
+
+
+
+
+
+ {cards.map((issue) => (
+
+
+
+ ))}
+
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/apps/web/features/landing/newhome/demo/zoom.ts b/apps/web/features/landing/newhome/demo/zoom.ts
new file mode 100644
index 000000000..1b1e9895b
--- /dev/null
+++ b/apps/web/features/landing/newhome/demo/zoom.ts
@@ -0,0 +1,5 @@
+// Single source of truth for the zoom every embedded live demo renders at, so
+// the hero board and the value-section boards all shrink by the same factor —
+// cards/columns end up the exact same on-screen size across the page. Each demo
+// lays out at its natural size, then scales down by DEMO_ZOOM.
+export const DEMO_ZOOM = 0.85;
diff --git a/apps/web/features/landing/newhome/newhome-landing.tsx b/apps/web/features/landing/newhome/newhome-landing.tsx
new file mode 100644
index 000000000..19162ac83
--- /dev/null
+++ b/apps/web/features/landing/newhome/newhome-landing.tsx
@@ -0,0 +1,405 @@
+"use client";
+
+import Link from "next/link";
+import dynamic from "next/dynamic";
+import { Download, Star } from "lucide-react";
+import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
+import { cn } from "@multica/ui/lib/utils";
+import { ProviderLogo } from "@multica/views/runtimes";
+import { githubUrl } from "../components/shared";
+import { DEMO_ZOOM } from "./demo/zoom";
+
+// The interactive product demo is heavy (the whole issues board subsystem) and
+// must stay client-only — it overrides the API singleton with a mock and uses
+// browser-only providers, so it can't server-render. Lazy-load it so it never
+// blocks the landing's first paint.
+const DemoBoard = dynamic(
+ () => import("./demo/demo-board").then((m) => m.DemoBoard),
+ {
+ ssr: false,
+ loading: () => (
+
+ Loading live demo…
+
+ ),
+ },
+);
+
+// Value #1's auto-playing board micro-demo. Client-only for the same reason as
+// DemoBoard (mock API singleton + browser-only providers).
+const ValueBoardDemo = dynamic(
+ () => import("./demo/value-board-demo").then((m) => m.ValueBoardDemo),
+ {
+ ssr: false,
+ loading: () =>
,
+ },
+);
+
+// GitHub Invertocat (official mark). lucide-react dropped its brand icons, so we
+// inline the silhouette here rather than depend on a removed export.
+function GitHubIcon({ className }: { className?: string }) {
+ return (
+
+
+
+ );
+}
+
+// Static for now — the repo currently has ~35k stars. Swap for a live
+// GitHub API count later if we want it to self-update.
+const GITHUB_STAR_COUNT = "35k";
+
+// Embedded product demo: a slightly-shrunk window (~16:9). transform: scale on
+// an inner box sized up by 1/scale, with the window height clamped so the
+// un-scaled layout box doesn't leave dead space. (transform handles the board's
+// drag better than zoom.) DEMO_ZOOM is shared with the value-section demos so
+// every embedded board renders at one uniform scale.
+const DEMO_WINDOW_H = 648;
+
+/**
+ * Multica Landing Page V2 (sandbox) — served at `/newhome`.
+ *
+ * Isolated rebuild of the landing hero. Shares nothing with the live landing
+ * (`/`), so we can iterate freely here and only swap it in once the V2 design
+ * is finalized.
+ *
+ * Hero copy follows the homepage positioning from MUL-2920:
+ * slogan — "One board for all your agents."
+ * subtitle — assign work, track progress, automate execution.
+ *
+ * Layout follows the ElevenLabs reference: sans-serif headline on the left,
+ * description on the right, a single pair of CTAs below, full-width product
+ * preview (placeholder for now).
+ */
+export function NewHomeLanding() {
+ return (
+
+
+
+
+ );
+}
+
+// Top-level information architecture (see MUL-2932 menu IA):
+// homepage · features · enterprise · pricing · resources(docs/changelog/…)
+const NAV_LINKS = [
+ { href: "#features", label: "Features" },
+ { href: "#enterprise", label: "Enterprise" },
+ { href: "#pricing", label: "Pricing" },
+ { href: "#resources", label: "Resources" },
+];
+
+function NewHomeNav() {
+ return (
+
+ );
+}
+
+function GitHubStars() {
+ return (
+
+
+
+
+ {GITHUB_STAR_COUNT}
+
+
+ );
+}
+
+function NewHomeHero() {
+ return (
+
+
+
+
+ One board for all your agents.
+
+
+ Assign work, track progress, and automate execution across Claude
+ Code, Codex, and every agent you run.
+
+
+
+
+
+
+ Download Desktop
+
+
+ Talk to sales
+
+
+
+
+
+
+
+
+
+ );
+}
+
+// The values section turns the hero's promise into concrete, watchable proof.
+// Each value pairs a short From→To claim with a focused, auto-playing micro-demo
+// built from the REAL product components. Value #1 ships first; #2–#4 follow.
+function ValuesSection() {
+ return (
+
+
+
+ From scattered agent runs to work you can actually manage.
+
+
+
+
+
+
+
+ );
+}
+
+// Value layout: a compact text column (eyebrow + title + From→To) above a
+// full-width canvas, so the board demo gets the room to render at the real
+// product's size and read as the same board the hero shows.
+function ValueBlock({
+ eyebrow,
+ title,
+ from,
+ to,
+ children,
+}: {
+ eyebrow: string;
+ title: string;
+ from: string;
+ to: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
+ {eyebrow}
+
+
+ {title}
+
+
+
+
+ From
+
+ {from}
+
+
+
+ To
+
+ {to}
+
+
+
+
+ {/* Full-width demo canvas. landing-demo scopes the brand override +
+ scrollbar hiding the product components expect. */}
+
+ {children}
+
+
+ );
+}
+
+// Until we have permission to display customer logos, the "logo wall" shows the
+// coding agents Multica already supports instead — mirroring the reference
+// social-proof band, one agent per card. Keys match the backend provider keys
+// (server/pkg/agent/models.go) so ProviderLogo renders the right mark; any key
+// without a logo falls back to a generic placeholder icon.
+const SUPPORTED_AGENTS = [
+ { key: "claude", name: "Claude Code" },
+ { key: "codex", name: "Codex" },
+ { key: "gemini", name: "Gemini CLI" },
+ { key: "cursor", name: "Cursor" },
+ { key: "copilot", name: "GitHub Copilot" },
+ { key: "opencode", name: "OpenCode" },
+ { key: "openclaw", name: "OpenClaw" },
+ { key: "hermes", name: "Hermes" },
+ { key: "kimi", name: "Kimi" },
+ { key: "kiro", name: "Kiro" },
+ { key: "pi", name: "Pi" },
+ { key: "antigravity", name: "Antigravity" },
+];
+
+function SupportedAgents() {
+ return (
+
+
+ Works with the coding agents you already run
+
+ {/* Auto-scrolling marquee. overflow-hidden = no scrollbar; the track is two
+ identical groups sliding left by one group width for a seamless loop. */}
+
+
+ );
+}
+
+function AgentTrackGroup({ ariaHidden = false }: { ariaHidden?: boolean }) {
+ return (
+
+ {SUPPORTED_AGENTS.map(({ key, name }) => (
+
+ {/* Grayscale by default; full brand color on hover of this card. */}
+
+
+ {name}
+
+
+ ))}
+
+ );
+}
+
+function ProductPreviewPlaceholder() {
+ return (
+ // Live, interactive product demo (mock data): browser tabs (Issues /
+ // Agents / Skills), drag cards, click a card to open its issue page. The
+ // browser chrome + tabs live inside DemoBoard. No drop shadow by request.
+ // The demo is laid out on a larger canvas (1/scale) then scaled down so it
+ // shows more content at a smaller size while still filling the 620px
+ // window. transform: scale (not zoom) so it clips to its visual bounds and
+ // never overflows the window.
+
+ );
+}
+
+// Playful "this is live, try it" annotation in the whitespace above the demo's
+// top-right — so the interactive demo doesn't read as a static screenshot. The
+// arrow draws itself in and the whole hint gently floats (see custom.css).
+function DemoLiveHint() {
+ return (
+
+
+ not a screenshot — it’s live. drag a card, try it!
+
+
+ {/* hand-drawn curve sweeping down into the demo's top-right */}
+
+ {/* arrowhead pointing down-left */}
+
+
+
+ );
+}
+
+function navButton(tone: "solid" | "ghost") {
+ return cn(
+ "inline-flex h-9 items-center justify-center rounded-[8px] px-3.5 text-[13.5px] font-semibold transition-colors",
+ tone === "solid"
+ ? "bg-[#0a0d12] text-white hover:bg-[#0a0d12]/90"
+ : "border border-[#0a0d12]/14 bg-white text-[#0a0d12] hover:bg-[#0a0d12]/[0.04]",
+ );
+}
+
+function heroButton(tone: "solid" | "ghost") {
+ return cn(
+ "inline-flex items-center justify-center gap-2 rounded-[8px] px-5 py-3 text-[14px] font-semibold transition-colors",
+ tone === "solid"
+ ? "bg-[#0a0d12] text-white hover:bg-[#0a0d12]/90"
+ : "border border-[#0a0d12]/14 bg-white text-[#0a0d12] hover:bg-[#0a0d12]/[0.04]",
+ );
+}
diff --git a/packages/views/issues/components/index.ts b/packages/views/issues/components/index.ts
index 3a4c7bd59..c0ed03ea4 100644
--- a/packages/views/issues/components/index.ts
+++ b/packages/views/issues/components/index.ts
@@ -1,5 +1,7 @@
export { StatusIcon } from "./status-icon";
export { StatusHeading } from "./status-heading";
+export { BoardCardContent } from "./board-card";
+export { WorkspaceAgentWorkingChip } from "./workspace-agent-working-chip";
export { PriorityIcon } from "./priority-icon";
export { StatusPicker, PriorityPicker, AssigneePicker, canAssignAgent, StartDatePicker, DueDatePicker, LabelPicker } from "./pickers";
export { IssueDetail } from "./issue-detail";
diff --git a/packages/views/runtimes/index.ts b/packages/views/runtimes/index.ts
index c534b94c4..d77890dac 100644
--- a/packages/views/runtimes/index.ts
+++ b/packages/views/runtimes/index.ts
@@ -1 +1,2 @@
export { RuntimesPage, RuntimeDetailPage } from "./components";
+export { ProviderLogo } from "./components/provider-logo";