From 6cfe42a4f22b0199ea2880dbb32634facceb14d3 Mon Sep 17 00:00:00 2001 From: Lambda Date: Fri, 5 Jun 2026 03:17:43 +0800 Subject: [PATCH] feat(web): add /newhome landing V2 sandbox with interactive demos Sandbox landing at /newhome (production / untouched): hero with an embedded, interactive product demo (real board/issue-detail/create-issue/transcripts over mock data) and a Values section whose first pillar is an auto-playing board built from the real product components. All embedded demos render at one shared DEMO_ZOOM, and popups portal into the scaled box so they share the zoom. Co-authored-by: multica-agent --- apps/web/app/(landing)/layout.tsx | 13 +- apps/web/app/(landing)/newhome/page.tsx | 15 + apps/web/app/custom.css | 141 ++++++ .../landing/newhome/demo/demo-board.tsx | 191 ++++++++ .../newhome/demo/demo-error-boundary.tsx | 55 +++ .../landing/newhome/demo/demo-panels.tsx | 66 +++ .../features/landing/newhome/demo/mock-api.ts | 125 +++++ .../landing/newhome/demo/mock-data.ts | 433 ++++++++++++++++++ .../landing/newhome/demo/value-board-demo.tsx | 274 +++++++++++ .../web/features/landing/newhome/demo/zoom.ts | 5 + .../landing/newhome/newhome-landing.tsx | 405 ++++++++++++++++ packages/views/issues/components/index.ts | 2 + packages/views/runtimes/index.ts | 1 + 13 files changed, 1724 insertions(+), 2 deletions(-) create mode 100644 apps/web/app/(landing)/newhome/page.tsx create mode 100644 apps/web/features/landing/newhome/demo/demo-board.tsx create mode 100644 apps/web/features/landing/newhome/demo/demo-error-boundary.tsx create mode 100644 apps/web/features/landing/newhome/demo/demo-panels.tsx create mode 100644 apps/web/features/landing/newhome/demo/mock-api.ts create mode 100644 apps/web/features/landing/newhome/demo/mock-data.ts create mode 100644 apps/web/features/landing/newhome/demo/value-board-demo.tsx create mode 100644 apps/web/features/landing/newhome/demo/zoom.ts create mode 100644 apps/web/features/landing/newhome/newhome-landing.tsx 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)} + 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 }) => ( + + ))} +
+
+ ); +} 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. +

+ +
+ ); + } + 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 ( +
+
+
+ + + + multica + + + +
+ +
+ + + Sign in + + + Download + +
+
+
+ ); +} + +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";