diff --git a/components/app/UpgradeGate.tsx b/components/app/UpgradeGate.tsx
new file mode 100644
index 0000000..f752e12
--- /dev/null
+++ b/components/app/UpgradeGate.tsx
@@ -0,0 +1,69 @@
+import { ArrowUpRight, Lock, Sparkles } from "lucide-react";
+import Link from "next/link";
+
+type UpgradeGateProps = {
+ /** Feature name shown in the headline, e.g. "Projects". */
+ feature: string;
+ /** Optional one-line description of what the paid surface unlocks. */
+ description?: string;
+};
+
+const DEFAULT_BENEFITS = [
+ "Save analyses as versioned projects",
+ "Organize work across team workspaces",
+ "Edit & export generated code",
+] as const;
+
+/**
+ * In-place paywall rendered by paid-only pages for free-tier users instead of a
+ * silent redirect. Lives inside whatever shell already wraps the page.
+ */
+export function UpgradeGate({ feature, description }: UpgradeGateProps) {
+ return (
+
+
+
+
+
+
+ Paid feature
+
+
+ Upgrade to unlock {feature}.
+
+
+ {description ??
+ `${feature} is part of MotionCode Pro. Keep analyzing motion for free, or upgrade to manage saved work and your team.`}
+
+
+
+ {DEFAULT_BENEFITS.map((benefit) => (
+
+
+ {benefit}
+
+ ))}
+
+
+
+
+ View plans
+
+
+
+
+ Back to Analyze
+
+
+
+ );
+}
diff --git a/components/app/Workbench.tsx b/components/app/Workbench.tsx
new file mode 100644
index 0000000..bff866c
--- /dev/null
+++ b/components/app/Workbench.tsx
@@ -0,0 +1,194 @@
+"use client";
+
+import {
+ CreditCard,
+ Gauge,
+ Lock,
+ PanelLeftClose,
+ PanelLeftOpen,
+ Sparkles,
+ UserCircle,
+} from "lucide-react";
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { useState, type ReactNode } from "react";
+
+import { SignOutButton } from "@/components/auth/sign-out-button";
+import type { PlanTier } from "@/lib/contracts/plans";
+import type { WorkspaceTreeNode } from "@/lib/workbench/tree";
+import { cn } from "@/lib/utils";
+
+import { ExplorerTree } from "./explorer/ExplorerTree";
+
+type WorkbenchProps = {
+ tree: WorkspaceTreeNode[];
+ userEmail?: string | null;
+ children: ReactNode;
+ /** Plan tier of the current user; drives the lock badge on paid rail links. */
+ planTier?: PlanTier;
+ /** Full-bleed main pane (no max-width/padding) for the Analyze studio. */
+ bleed?: boolean;
+};
+
+const railItems = [
+ { href: "/app", icon: Sparkles, label: "Analyze", match: "/app", paid: false },
+ {
+ href: "/dashboard",
+ icon: Gauge,
+ label: "Dashboard",
+ match: "/dashboard",
+ paid: true,
+ },
+] as const;
+
+const railUtility = [
+ { href: "/account", icon: UserCircle, label: "Account", match: "/account" },
+ { href: "/billing", icon: CreditCard, label: "Billing", match: "/billing" },
+] as const;
+
+function RailLink({
+ href,
+ icon: Icon,
+ label,
+ active,
+ locked = false,
+}: {
+ href: string;
+ icon: typeof Sparkles;
+ label: string;
+ active: boolean;
+ locked?: boolean;
+}) {
+ return (
+
+
+ {locked ? (
+
+ ) : null}
+
+ );
+}
+
+export function Workbench({
+ tree,
+ userEmail,
+ children,
+ planTier = "free",
+ bleed = false,
+}: WorkbenchProps) {
+ const pathname = usePathname();
+ const [explorerOpen, setExplorerOpen] = useState(true);
+ const isFree = planTier === "free";
+
+ const isActive = (match: string) =>
+ pathname === match || pathname.startsWith(`${match}/`);
+
+ return (
+
+
+
+
+ {/* Icon rail */}
+
+
+ {/* Explorer column */}
+ {explorerOpen ? (
+
+ ) : null}
+
+ {/* Main pane */}
+
+ {children}
+
+
+
+ );
+}
diff --git a/components/app/explorer/ExplorerTree.tsx b/components/app/explorer/ExplorerTree.tsx
new file mode 100644
index 0000000..8f59db5
--- /dev/null
+++ b/components/app/explorer/ExplorerTree.tsx
@@ -0,0 +1,191 @@
+"use client";
+
+import { Plus } from "lucide-react";
+import { usePathname, useRouter } from "next/navigation";
+import { useCallback, useMemo, useState } from "react";
+
+import type { ApiResponse } from "@/lib/contracts/errors";
+import type { Database } from "@/types/database";
+import type { WorkspaceTreeNode } from "@/lib/workbench/tree";
+
+import { InlineCreate } from "./InlineCreate";
+import { WorkspaceNode } from "./WorkspaceNode";
+
+type WorkspaceRow = Database["public"]["Tables"]["workspaces"]["Row"];
+type ProjectRow = Database["public"]["Tables"]["projects"]["Row"];
+
+type ExplorerTreeProps = {
+ tree: WorkspaceTreeNode[];
+};
+
+/** Pull the active workspace/project id straight off the URL. */
+function readSelectionFromPath(pathname: string) {
+ const project = pathname.match(/^\/projects\/([^/]+)/);
+ const workspace = pathname.match(/^\/workspaces\/([^/]+)/);
+ return {
+ activeProjectId: project?.[1] ?? null,
+ activeWorkspaceId: workspace?.[1] ?? null,
+ };
+}
+
+export function ExplorerTree({ tree }: ExplorerTreeProps) {
+ const router = useRouter();
+ const pathname = usePathname();
+ const { activeProjectId, activeWorkspaceId } = readSelectionFromPath(pathname);
+
+ // Workspace that owns the active project — so it auto-expands on deep links.
+ const projectWorkspaceId = useMemo(() => {
+ if (!activeProjectId) return null;
+ for (const node of tree) {
+ if (node.projects.some((project) => project.id === activeProjectId)) {
+ return node.workspace.id;
+ }
+ }
+ return null;
+ }, [activeProjectId, tree]);
+
+ // User-toggled workspaces. The selected branch is always shown open via the
+ // render-time union below, so deep links and the active project stay visible.
+ // The workbench layout keeps this tree mounted, so toggles persist across
+ // in-app navigation without any external storage.
+ const [openSet, setOpenSet] = useState
>(() => new Set());
+ const [creatingWorkspace, setCreatingWorkspace] = useState(false);
+ const [creatingProjectIn, setCreatingProjectIn] = useState(
+ null,
+ );
+
+ const isOpen = useCallback(
+ (workspaceId: string) =>
+ openSet.has(workspaceId) ||
+ workspaceId === projectWorkspaceId ||
+ workspaceId === activeWorkspaceId,
+ [openSet, projectWorkspaceId, activeWorkspaceId],
+ );
+
+ const toggle = useCallback((workspaceId: string) => {
+ setOpenSet((current) => {
+ const next = new Set(current);
+ if (next.has(workspaceId)) {
+ next.delete(workspaceId);
+ } else {
+ next.add(workspaceId);
+ }
+ return next;
+ });
+ }, []);
+
+ const openProjectCreate = useCallback((workspaceId: string) => {
+ setCreatingProjectIn(workspaceId);
+ setOpenSet((current) => {
+ if (current.has(workspaceId)) return current;
+ const next = new Set(current);
+ next.add(workspaceId);
+ return next;
+ });
+ }, []);
+
+ async function createWorkspace(name: string) {
+ const response = await fetch("/api/workspaces", {
+ body: JSON.stringify({ name }),
+ headers: { "content-type": "application/json" },
+ method: "POST",
+ });
+ const json = (await response.json()) as ApiResponse;
+ if (!json.ok) {
+ return { ok: false as const, message: json.message };
+ }
+ router.push(`/workspaces/${json.data.id}`);
+ router.refresh();
+ return { ok: true as const };
+ }
+
+ async function createProject(workspaceId: string, title: string) {
+ const response = await fetch("/api/projects", {
+ body: JSON.stringify({
+ description: "",
+ sourceType: "prompt",
+ title,
+ workspaceId,
+ }),
+ headers: { "content-type": "application/json" },
+ method: "POST",
+ });
+ const json = (await response.json()) as ApiResponse;
+ if (!json.ok) {
+ return { ok: false as const, message: json.message };
+ }
+ router.push(`/projects/${json.data.id}`);
+ router.refresh();
+ return { ok: true as const };
+ }
+
+ return (
+
+
+
+ Explorer
+
+
setCreatingWorkspace(true)}
+ aria-label="New workspace"
+ title="New workspace"
+ className="flex size-6 items-center justify-center border border-[var(--accent-border)] bg-[var(--accent-dim)] text-[var(--text)] transition hover:border-[var(--accent)]"
+ >
+
+
+
+
+
+ {creatingWorkspace ? (
+
setCreatingWorkspace(false)}
+ />
+ ) : null}
+
+ {tree.length === 0 && !creatingWorkspace ? (
+ setCreatingWorkspace(true)}
+ className="mx-3 mt-2 flex w-[calc(100%-1.5rem)] flex-col items-start gap-1 border border-dashed border-[var(--border)] px-3 py-4 text-left transition hover:border-[var(--accent-border)]"
+ >
+
+ Create your first workspace
+
+
+ Group projects, then analyze references inside.
+
+
+ ) : null}
+
+
+ {tree.map((node) => (
+ toggle(node.workspace.id)}
+ onAddProject={() => openProjectCreate(node.workspace.id)}
+ createSlot={
+ creatingProjectIn === node.workspace.id ? (
+
+ createProject(node.workspace.id, title)
+ }
+ onClose={() => setCreatingProjectIn(null)}
+ />
+ ) : null
+ }
+ />
+ ))}
+
+
+
+ );
+}
diff --git a/components/app/explorer/InlineCreate.tsx b/components/app/explorer/InlineCreate.tsx
new file mode 100644
index 0000000..22cdb01
--- /dev/null
+++ b/components/app/explorer/InlineCreate.tsx
@@ -0,0 +1,98 @@
+"use client";
+
+import { Loader2 } from "lucide-react";
+import { useRef, useState, type KeyboardEvent } from "react";
+import { useEffect } from "react";
+
+import { cn } from "@/lib/utils";
+
+type InlineCreateResult = { ok: true } | { ok: false; message: string };
+
+type InlineCreateProps = {
+ placeholder: string;
+ onSubmit: (value: string) => Promise;
+ onClose: () => void;
+ /** Visual indent (px) so nested project inputs line up under their workspace. */
+ indent?: number;
+};
+
+/**
+ * A single-line inline editor used to create a workspace or a project from
+ * inside the explorer tree. Enter submits, Escape cancels, blur cancels when
+ * empty. Errors render inline beneath the field.
+ */
+export function InlineCreate({
+ placeholder,
+ onSubmit,
+ onClose,
+ indent = 0,
+}: InlineCreateProps) {
+ const inputRef = useRef(null);
+ const [value, setValue] = useState("");
+ const [error, setError] = useState(null);
+ const [submitting, setSubmitting] = useState(false);
+
+ useEffect(() => {
+ inputRef.current?.focus();
+ }, []);
+
+ async function submit() {
+ const trimmed = value.trim();
+ if (!trimmed || submitting) {
+ if (!trimmed) onClose();
+ return;
+ }
+
+ setSubmitting(true);
+ setError(null);
+ const result = await onSubmit(trimmed);
+ if (result.ok) {
+ onClose();
+ return;
+ }
+
+ setSubmitting(false);
+ setError(result.message);
+ inputRef.current?.focus();
+ }
+
+ function handleKeyDown(event: KeyboardEvent) {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ void submit();
+ } else if (event.key === "Escape") {
+ event.preventDefault();
+ onClose();
+ }
+ }
+
+ return (
+
+
+ setValue(event.target.value)}
+ onKeyDown={handleKeyDown}
+ onBlur={() => {
+ if (!value.trim()) onClose();
+ }}
+ placeholder={placeholder}
+ className={cn(
+ "h-8 w-full border border-[var(--accent-border)] bg-[#11120d] px-2 font-mono text-xs text-[var(--text)] outline-none",
+ "focus:shadow-[0_0_0_3px_rgba(216,207,188,0.08)]",
+ )}
+ />
+ {submitting ? (
+
+ ) : null}
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ );
+}
diff --git a/components/app/explorer/ProjectNode.tsx b/components/app/explorer/ProjectNode.tsx
new file mode 100644
index 0000000..b9936f4
--- /dev/null
+++ b/components/app/explorer/ProjectNode.tsx
@@ -0,0 +1,32 @@
+"use client";
+
+import { FileCode2 } from "lucide-react";
+import Link from "next/link";
+
+import type { ProjectRow } from "@/app/dashboard/data";
+import { cn } from "@/lib/utils";
+
+type ProjectNodeProps = {
+ project: ProjectRow;
+ isActive: boolean;
+};
+
+export function ProjectNode({ project, isActive }: ProjectNodeProps) {
+ return (
+
+
+ {project.title}
+
+ );
+}
diff --git a/components/app/explorer/WorkspaceNode.tsx b/components/app/explorer/WorkspaceNode.tsx
new file mode 100644
index 0000000..08e9f5b
--- /dev/null
+++ b/components/app/explorer/WorkspaceNode.tsx
@@ -0,0 +1,98 @@
+"use client";
+
+import { ChevronRight, Folder, FolderOpen, Plus } from "lucide-react";
+import Link from "next/link";
+
+import type { WorkspaceTreeNode } from "@/lib/workbench/tree";
+import { cn } from "@/lib/utils";
+
+import { ProjectNode } from "./ProjectNode";
+
+type WorkspaceNodeProps = {
+ node: WorkspaceTreeNode;
+ expanded: boolean;
+ isActive: boolean;
+ activeProjectId: string | null;
+ onToggle: () => void;
+ onAddProject: () => void;
+ /** Inline project-create input, rendered by the parent when adding here. */
+ createSlot?: React.ReactNode;
+};
+
+export function WorkspaceNode({
+ node,
+ expanded,
+ isActive,
+ activeProjectId,
+ onToggle,
+ onAddProject,
+ createSlot,
+}: WorkspaceNodeProps) {
+ const { workspace, projects } = node;
+ const FolderIcon = expanded ? FolderOpen : Folder;
+
+ return (
+
+
+
+
+
+
+
+
{workspace.name}
+
+
+
+
+
+
+ {expanded ? (
+
+ {projects.map((project) => (
+
+
+
+ ))}
+ {createSlot ? {createSlot} : null}
+ {projects.length === 0 && !createSlot ? (
+
+ No projects yet
+
+ ) : null}
+
+ ) : null}
+
+ );
+}
diff --git a/components/app/studio/AnalyzeStudio.tsx b/components/app/studio/AnalyzeStudio.tsx
new file mode 100644
index 0000000..d6884b6
--- /dev/null
+++ b/components/app/studio/AnalyzeStudio.tsx
@@ -0,0 +1,324 @@
+"use client";
+
+import { PanelLeftClose, SlidersHorizontal } from "lucide-react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
+
+import { MotionSpecPanel } from "@/components/app/MotionSpecPanel";
+import { Scorecard } from "@/components/app/Scorecard";
+import type { ScoreKey } from "@/components/app/types";
+import type { AnalysisResult } from "@/lib/contracts/motion";
+import {
+ CODE_TABS,
+ type CodeTab,
+ getDownloadFilename,
+ getFrameworkForTab,
+ getGeneratedOutput,
+ prettifyCode,
+} from "@/lib/generatedCode";
+import type { MotionSpecEditableField } from "@/lib/motionSpecEditor";
+import { buildPreviewDoc } from "@/lib/preview/buildPreviewDoc";
+import type { ConsoleEntry, ConsoleLevel } from "@/lib/preview/types";
+import { isPreviewMessage } from "@/lib/preview/types";
+import { cn } from "@/lib/utils";
+
+import type { EditorLanguage } from "./CodeMirrorEditor";
+import { EditorPane } from "./EditorPane";
+import { PreviewPane, type PreviewStatus } from "./PreviewPane";
+
+type AnalyzeStudioProps = {
+ result: AnalysisResult;
+ intentColor: string;
+ activeTab: CodeTab;
+ /** When false (free tier) the studio is read-only: preview + copy only. */
+ editable: boolean;
+ onTabChange: (tab: CodeTab) => void;
+ onNewAnalysis: () => void;
+ onSpecChange: (field: MotionSpecEditableField, value: unknown) => void;
+};
+
+function seedFromResult(result: AnalysisResult): Record {
+ return CODE_TABS.reduce(
+ (acc, tab) => {
+ acc[tab] = getGeneratedOutput(result, tab)?.code ?? "";
+ return acc;
+ },
+ {} as Record,
+ );
+}
+
+function languageForTab(tab: CodeTab): EditorLanguage {
+ return tab === "CSS" ? "css" : "javascript";
+}
+
+export function AnalyzeStudio({
+ result,
+ intentColor,
+ activeTab,
+ editable,
+ onTabChange,
+ onNewAnalysis,
+ onSpecChange,
+}: AnalyzeStudioProps) {
+ // Seeded from the result via the useState initializer. AppShell remounts this
+ // component (key={result.id}) on each new analysis, so no seeding effect is needed.
+ const [original] = useState>(() => seedFromResult(result));
+ const [editorCode, setEditorCode] = useState>(() =>
+ seedFromResult(result),
+ );
+ const [copied, setCopied] = useState(false);
+ const [drawerOpen, setDrawerOpen] = useState(false);
+ const [hoveredScore, setHoveredScore] = useState(null);
+
+ // Preview runtime state.
+ const [srcDoc, setSrcDoc] = useState("");
+ const [runId, setRunId] = useState(0);
+ const [status, setStatus] = useState("idle");
+ const [elapsedMs, setElapsedMs] = useState(null);
+ const [consoleEntries, setConsoleEntries] = useState([]);
+
+ const iframeRef = useRef(null);
+ const runIdRef = useRef(0);
+ const runStartRef = useRef(0);
+ const consoleIdRef = useRef(0);
+
+ const errorCount = useMemo(
+ () => consoleEntries.filter((entry) => entry.level === "error").length,
+ [consoleEntries],
+ );
+
+ const run = useCallback(
+ (tab: CodeTab, code: string) => {
+ const nextRunId = runIdRef.current + 1;
+ runIdRef.current = nextRunId;
+ runStartRef.current = performance.now();
+
+ const doc = buildPreviewDoc({
+ framework: getFrameworkForTab(tab),
+ code,
+ runId: nextRunId,
+ spec: {
+ durationMs: result.spec.durationMs,
+ delayMs: result.spec.delayMs,
+ easing: result.spec.easing,
+ loops: result.spec.loops,
+ element: result.spec.element,
+ intent: result.spec.intent,
+ },
+ });
+
+ setConsoleEntries([]);
+ setElapsedMs(null);
+ setStatus("running");
+ setRunId(nextRunId);
+ setSrcDoc(doc);
+ },
+ [result.spec],
+ );
+
+ // Kick off the preview on mount and re-run it when switching framework tabs.
+ // This synchronizes the external iframe runtime with editor state — the
+ // legitimate role of an effect.
+ useEffect(() => {
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ run(activeTab, editorCode[activeTab] ?? "");
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [activeTab]);
+
+ // Listen for messages from the preview iframe.
+ useEffect(() => {
+ const handler = (event: MessageEvent) => {
+ const data = event.data;
+ if (!isPreviewMessage(data)) return;
+ if (data.runId !== runIdRef.current) return;
+
+ if (data.type === "ready") {
+ setElapsedMs(Math.max(0, Math.round(performance.now() - runStartRef.current)));
+ setStatus((current) => (current === "error" ? "error" : "ready"));
+ return;
+ }
+ if (data.type === "console") {
+ appendConsole(data.level, data.text);
+ return;
+ }
+ if (data.type === "error") {
+ appendConsole("error", data.text);
+ setStatus("error");
+ }
+ };
+
+ function appendConsole(level: ConsoleLevel, text: string) {
+ consoleIdRef.current += 1;
+ setConsoleEntries((current) => [
+ ...current.slice(-199),
+ { id: consoleIdRef.current, level, text, at: Date.now() },
+ ]);
+ }
+
+ window.addEventListener("message", handler);
+ return () => window.removeEventListener("message", handler);
+ }, []);
+
+ const activeCode = editorCode[activeTab] ?? "";
+ const dirty = activeCode !== (original[activeTab] ?? "");
+
+ const handleChange = useCallback(
+ (code: string) => {
+ setEditorCode((current) => ({ ...current, [activeTab]: code }));
+ },
+ [activeTab],
+ );
+
+ const handleRun = useCallback(() => {
+ run(activeTab, editorCode[activeTab] ?? "");
+ }, [activeTab, editorCode, run]);
+
+ const handleFormat = useCallback(() => {
+ setEditorCode((current) => ({
+ ...current,
+ [activeTab]: prettifyCode(current[activeTab] ?? "", activeTab),
+ }));
+ }, [activeTab]);
+
+ const handleReset = useCallback(() => {
+ const code = original[activeTab] ?? "";
+ setEditorCode((current) => ({ ...current, [activeTab]: code }));
+ run(activeTab, code);
+ }, [activeTab, original, run]);
+
+ const handleCopy = useCallback(() => {
+ void navigator.clipboard.writeText(editorCode[activeTab] ?? "");
+ setCopied(true);
+ window.setTimeout(() => setCopied(false), 2000);
+ }, [activeTab, editorCode]);
+
+ const handleDownload = useCallback(() => {
+ const blob = new Blob([editorCode[activeTab] ?? ""], { type: "text/plain" });
+ const url = URL.createObjectURL(blob);
+ const anchor = document.createElement("a");
+ anchor.href = url;
+ anchor.download = getDownloadFilename(activeTab);
+ document.body.appendChild(anchor);
+ anchor.click();
+ document.body.removeChild(anchor);
+ URL.revokeObjectURL(url);
+ }, [activeTab, editorCode]);
+
+ return (
+
+ {/* Studio header */}
+
+
+
+
+
+ Studio · {result.spec.intent}
+
+
+ {result.spec.element}
+
+
+
+
+
setDrawerOpen((open) => !open)}
+ className={cn(
+ "inline-flex h-8 items-center gap-1.5 rounded-md border px-2.5 font-mono text-[11px] transition",
+ drawerOpen
+ ? "border-[var(--accent-border)] bg-[var(--accent-dim)] text-[var(--text)]"
+ : "border-[var(--border)] text-[var(--accent)] hover:text-[var(--text)]",
+ )}
+ >
+
+ Spec & audit
+
+
+
+ New analysis
+
+
+
+
+ {/* Split body */}
+
+
+
+
+
+
+
+
+
+ setConsoleEntries([])}
+ onReplay={handleRun}
+ iframeRef={iframeRef}
+ />
+
+
+
+
+ {/* Spec & audit drawer */}
+ {drawerOpen ? (
+
+
+
+ Spec & audit
+
+ setDrawerOpen(false)}
+ className="font-mono text-[11px] text-[var(--accent)] hover:text-[var(--text)]"
+ >
+ Close
+
+
+
+
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/components/app/studio/CodeMirrorEditor.tsx b/components/app/studio/CodeMirrorEditor.tsx
new file mode 100644
index 0000000..8758245
--- /dev/null
+++ b/components/app/studio/CodeMirrorEditor.tsx
@@ -0,0 +1,122 @@
+"use client";
+
+import { css } from "@codemirror/lang-css";
+import { javascript } from "@codemirror/lang-javascript";
+import { Compartment, EditorState } from "@codemirror/state";
+import { keymap } from "@codemirror/view";
+import { basicSetup, EditorView } from "codemirror";
+import { useEffect, useRef } from "react";
+
+import { motionCodeEditorTheme } from "./codemirror-theme";
+
+export type EditorLanguage = "css" | "javascript";
+
+type CodeMirrorEditorProps = {
+ value: string;
+ language: EditorLanguage;
+ onChange: (value: string) => void;
+ onRun?: () => void;
+ /** When false the document is read-only (free tier). Defaults to true. */
+ editable?: boolean;
+};
+
+function languageExtension(language: EditorLanguage) {
+ return language === "css" ? css() : javascript({ jsx: true, typescript: true });
+}
+
+function editableExtension(editable: boolean) {
+ return [EditorState.readOnly.of(!editable), EditorView.editable.of(editable)];
+}
+
+export function CodeMirrorEditor({
+ value,
+ language,
+ onChange,
+ onRun,
+ editable = true,
+}: CodeMirrorEditorProps) {
+ const hostRef = useRef(null);
+ const viewRef = useRef(null);
+ const languageRef = useRef(new Compartment());
+ const editableRef = useRef(new Compartment());
+ const onChangeRef = useRef(onChange);
+ const onRunRef = useRef(onRun);
+
+ // Keep latest callbacks without re-running the mount effect.
+ useEffect(() => {
+ onChangeRef.current = onChange;
+ onRunRef.current = onRun;
+ });
+
+ // Mount once.
+ useEffect(() => {
+ if (!hostRef.current) return;
+
+ const runKeymap = keymap.of([
+ {
+ key: "Mod-Enter",
+ run: () => {
+ onRunRef.current?.();
+ return true;
+ },
+ },
+ ]);
+
+ const state = EditorState.create({
+ doc: value,
+ extensions: [
+ basicSetup,
+ runKeymap,
+ languageRef.current.of(languageExtension(language)),
+ editableRef.current.of(editableExtension(editable)),
+ motionCodeEditorTheme,
+ EditorView.lineWrapping,
+ EditorView.updateListener.of((update) => {
+ if (update.docChanged) {
+ onChangeRef.current(update.state.doc.toString());
+ }
+ }),
+ ],
+ });
+
+ const view = new EditorView({ state, parent: hostRef.current });
+ viewRef.current = view;
+
+ return () => {
+ view.destroy();
+ viewRef.current = null;
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ // Sync language when the active framework tab changes.
+ useEffect(() => {
+ const view = viewRef.current;
+ if (!view) return;
+ view.dispatch({
+ effects: languageRef.current.reconfigure(languageExtension(language)),
+ });
+ }, [language]);
+
+ // Sync read-only state when the plan tier changes (e.g. upgrade mid-session).
+ useEffect(() => {
+ const view = viewRef.current;
+ if (!view) return;
+ view.dispatch({
+ effects: editableRef.current.reconfigure(editableExtension(editable)),
+ });
+ }, [editable]);
+
+ // Sync external value changes (tab switch / reset) without clobbering typing.
+ useEffect(() => {
+ const view = viewRef.current;
+ if (!view) return;
+ const current = view.state.doc.toString();
+ if (current === value) return;
+ view.dispatch({
+ changes: { from: 0, to: current.length, insert: value },
+ });
+ }, [value]);
+
+ return
;
+}
diff --git a/components/app/studio/ConsolePanel.tsx b/components/app/studio/ConsolePanel.tsx
new file mode 100644
index 0000000..2e35534
--- /dev/null
+++ b/components/app/studio/ConsolePanel.tsx
@@ -0,0 +1,71 @@
+"use client";
+
+import { Trash2 } from "lucide-react";
+import { useEffect, useRef } from "react";
+
+import type { ConsoleEntry } from "@/lib/preview/types";
+import { cn } from "@/lib/utils";
+
+const LEVEL_STYLES: Record = {
+ log: "text-[var(--text)]",
+ info: "text-[#9ef0c0]",
+ warn: "text-[#ffd166]",
+ error: "text-[#f58f7c]",
+};
+
+const LEVEL_LABEL: Record = {
+ log: "›",
+ info: "i",
+ warn: "!",
+ error: "✕",
+};
+
+export function ConsolePanel({
+ entries,
+ onClear,
+}: {
+ entries: ConsoleEntry[];
+ onClear: () => void;
+}) {
+ const endRef = useRef(null);
+
+ useEffect(() => {
+ endRef.current?.scrollIntoView({ block: "end" });
+ }, [entries.length]);
+
+ return (
+
+
+
+ {entries.length} message{entries.length === 1 ? "" : "s"}
+
+
+
+ Clear
+
+
+
+ {entries.length === 0 ? (
+
No console output. Logs and errors from the preview appear here.
+ ) : (
+ entries.map((entry) => (
+
+
+ {LEVEL_LABEL[entry.level]}
+
+
+ {entry.text}
+
+
+ ))
+ )}
+
+
+
+ );
+}
diff --git a/components/app/studio/EditorPane.tsx b/components/app/studio/EditorPane.tsx
new file mode 100644
index 0000000..bb8e670
--- /dev/null
+++ b/components/app/studio/EditorPane.tsx
@@ -0,0 +1,193 @@
+"use client";
+
+import { Check, Copy, Download, Lock, Play, RotateCcw, Wand2 } from "lucide-react";
+import Link from "next/link";
+
+import type { CodeTab } from "@/lib/generatedCode";
+import { cn } from "@/lib/utils";
+
+import { CodeMirrorEditor, type EditorLanguage } from "./CodeMirrorEditor";
+
+type EditorPaneProps = {
+ tabs: readonly CodeTab[];
+ activeTab: CodeTab;
+ onTabChange: (tab: CodeTab) => void;
+ value: string;
+ language: EditorLanguage;
+ dirty: boolean;
+ copied: boolean;
+ /** When false (free tier) the editor is read-only: Copy only, no edit/export. */
+ editable: boolean;
+ onChange: (value: string) => void;
+ onRun: () => void;
+ onFormat: () => void;
+ onReset: () => void;
+ onCopy: () => void;
+ onDownload: () => void;
+};
+
+function fileLabel(tab: CodeTab): string {
+ switch (tab) {
+ case "CSS":
+ return "animation.css";
+ case "GSAP":
+ return "animation.gsap.js";
+ default:
+ return "AnimatedComponent.tsx";
+ }
+}
+
+export function EditorPane({
+ tabs,
+ activeTab,
+ onTabChange,
+ value,
+ language,
+ dirty,
+ copied,
+ editable,
+ onChange,
+ onRun,
+ onFormat,
+ onReset,
+ onCopy,
+ onDownload,
+}: EditorPaneProps) {
+ return (
+
+ {/* Toolbar */}
+
+
+ {fileLabel(activeTab)}
+ {editable && dirty ? (
+
+ ) : null}
+ {!editable ? (
+
+
+ Read-only
+
+ ) : null}
+
+
+ {editable ? (
+ <>
+
+
+
+
+
+
+ Run
+
+ >
+ ) : (
+ <>
+
+ Upgrade to edit
+
+
+ >
+ )}
+
+
+
+ {/* Framework tabs */}
+
+ {tabs.map((tab) => {
+ const isActive = tab === activeTab;
+ return (
+ onTabChange(tab)}
+ className={cn(
+ "relative shrink-0 px-3 py-2 font-mono text-[11px] transition-colors",
+ isActive
+ ? "text-[var(--text)]"
+ : "text-[var(--muted)] hover:text-[var(--accent)]",
+ )}
+ >
+ {tab}
+ {isActive ? (
+
+ ) : null}
+
+ );
+ })}
+
+
+ {/* Editor */}
+
+
+
+
+ );
+}
+
+function ToolbarButton({
+ label,
+ onClick,
+ icon: Icon,
+ active,
+ disabled,
+}: {
+ label: string;
+ onClick: () => void;
+ icon: typeof Copy;
+ active?: boolean;
+ disabled?: boolean;
+}) {
+ return (
+
+
+ {label}
+
+ );
+}
diff --git a/components/app/studio/PreviewPane.tsx b/components/app/studio/PreviewPane.tsx
new file mode 100644
index 0000000..3f223e2
--- /dev/null
+++ b/components/app/studio/PreviewPane.tsx
@@ -0,0 +1,155 @@
+"use client";
+
+import { RotateCw } from "lucide-react";
+import { useState } from "react";
+
+import type { ConsoleEntry } from "@/lib/preview/types";
+import { cn } from "@/lib/utils";
+
+import { ConsolePanel } from "./ConsolePanel";
+
+export type PreviewStatus = "idle" | "running" | "ready" | "error";
+
+type PreviewPaneProps = {
+ srcDoc: string;
+ runId: number;
+ status: PreviewStatus;
+ elapsedMs: number | null;
+ consoleEntries: ConsoleEntry[];
+ errorCount: number;
+ onClearConsole: () => void;
+ onReplay: () => void;
+ iframeRef: React.RefObject;
+};
+
+type PreviewTab = "preview" | "console";
+
+const STATUS_TEXT: Record = {
+ idle: "STANDING BY",
+ running: "RUNNING",
+ ready: "READY",
+ error: "ERROR",
+};
+
+export function PreviewPane({
+ srcDoc,
+ runId,
+ status,
+ elapsedMs,
+ consoleEntries,
+ errorCount,
+ onClearConsole,
+ onReplay,
+ iframeRef,
+}: PreviewPaneProps) {
+ const [tab, setTab] = useState("preview");
+
+ return (
+
+ {/* Tab bar */}
+
+
+
setTab("preview")}
+ label="Preview"
+ />
+ setTab("console")}
+ label="Console"
+ badge={errorCount > 0 ? errorCount : undefined}
+ />
+
+
+
+ Replay
+
+
+
+ {/* Body */}
+
+
+ {/* Status strip */}
+
+
+
+ {STATUS_TEXT[status]}
+ {status === "ready" && elapsedMs !== null ? ` · ${elapsedMs}MS` : ""}
+
+ v1.0.0
+
+
+ );
+}
+
+function PreviewTabButton({
+ active,
+ onClick,
+ label,
+ badge,
+}: {
+ active: boolean;
+ onClick: () => void;
+ label: string;
+ badge?: number;
+}) {
+ return (
+
+ {label}
+ {badge ? (
+
+ {badge}
+
+ ) : null}
+ {active ? : null}
+
+ );
+}
diff --git a/components/app/studio/codemirror-theme.ts b/components/app/studio/codemirror-theme.ts
new file mode 100644
index 0000000..aa16926
--- /dev/null
+++ b/components/app/studio/codemirror-theme.ts
@@ -0,0 +1,67 @@
+import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
+import { EditorView } from "@codemirror/view";
+import { tags as t } from "@lezer/highlight";
+
+/** Warm-dark MotionCode terminal theme for CodeMirror 6. */
+const motionCodeTheme = EditorView.theme(
+ {
+ "&": {
+ color: "#FFFBF4",
+ backgroundColor: "transparent",
+ fontSize: "12.5px",
+ height: "100%",
+ },
+ ".cm-content": {
+ fontFamily:
+ "var(--font-space-mono, ui-monospace), SFMono-Regular, Menlo, Consolas, monospace",
+ caretColor: "#00ff88",
+ padding: "12px 0",
+ },
+ ".cm-scroller": {
+ fontFamily:
+ "var(--font-space-mono, ui-monospace), SFMono-Regular, Menlo, Consolas, monospace",
+ lineHeight: "1.65",
+ },
+ "&.cm-focused": { outline: "none" },
+ ".cm-gutters": {
+ backgroundColor: "transparent",
+ color: "rgba(86,84,73,0.85)",
+ border: "none",
+ fontSize: "11px",
+ },
+ ".cm-activeLineGutter": {
+ backgroundColor: "rgba(216,207,188,0.05)",
+ color: "#D8CFBC",
+ },
+ ".cm-activeLine": { backgroundColor: "rgba(255,251,244,0.025)" },
+ ".cm-cursor, .cm-dropCursor": { borderLeftColor: "#00ff88" },
+ "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection":
+ { backgroundColor: "rgba(0,255,136,0.16)" },
+ ".cm-selectionMatch": { backgroundColor: "rgba(216,207,188,0.12)" },
+ ".cm-line": { padding: "0 14px" },
+ ".cm-matchingBracket, &.cm-focused .cm-matchingBracket": {
+ backgroundColor: "rgba(0,255,136,0.18)",
+ color: "inherit",
+ outline: "1px solid rgba(0,255,136,0.3)",
+ },
+ },
+ { dark: true },
+);
+
+const motionCodeHighlight = HighlightStyle.define([
+ { tag: t.comment, color: "#565449", fontStyle: "italic" },
+ { tag: [t.keyword, t.operatorKeyword, t.modifier], color: "#00ff88" },
+ { tag: [t.string, t.special(t.string)], color: "#D8CFBC" },
+ { tag: [t.number, t.bool, t.null, t.unit], color: "#f5b97f" },
+ { tag: [t.propertyName, t.attributeName], color: "#9ef0c0" },
+ { tag: [t.function(t.variableName), t.function(t.propertyName)], color: "#cbe7d3" },
+ { tag: [t.typeName, t.className, t.tagName], color: "#8fd9c9" },
+ { tag: [t.variableName, t.definition(t.variableName)], color: "#FFFBF4" },
+ { tag: [t.punctuation, t.bracket], color: "rgba(255,251,244,0.6)" },
+ { tag: t.invalid, color: "#f58f7c" },
+]);
+
+export const motionCodeEditorTheme = [
+ motionCodeTheme,
+ syntaxHighlighting(motionCodeHighlight),
+];
diff --git a/components/auth/login-experience.tsx b/components/auth/login-experience.tsx
new file mode 100644
index 0000000..c6e8c3a
--- /dev/null
+++ b/components/auth/login-experience.tsx
@@ -0,0 +1,133 @@
+"use client";
+
+import { X } from "lucide-react";
+import Link from "next/link";
+import { useRef } from "react";
+
+import { MotionField } from "@/components/auth/motion-field";
+import { LoginForm } from "@/components/dashboard/login-form";
+
+const displayFont = { fontFamily: "var(--font-bridge-display)" } as const;
+const monoFont = { fontFamily: "var(--font-bridge-mono)" } as const;
+
+type LoginExperienceProps = {
+ nextPath: string;
+ signedOut?: boolean;
+ authError?: boolean;
+ /** Renders a close affordance when shown inside the landing-page modal. */
+ onClose?: () => void;
+};
+
+export function LoginExperience({
+ nextPath,
+ signedOut = false,
+ authError = false,
+ onClose,
+}: LoginExperienceProps) {
+ const typingImpulseRef = useRef(0);
+
+ return (
+
+ {/* left — the motion-capture figure */}
+
+
+
+
+
+
+ MotionCode
+
+
+ move the cursor to sample
+
+
+
+
+ {/* right — the form */}
+
+ {onClose ? (
+
+
+
+ ) : null}
+
+
+
+
+ ⟨/⟩ MotionCode
+
+
+ auth kernel
+
+
+
+
+ welcome back
+
+
+ Sign in
+
+
+ Access your workspaces, projects, and generated motion versions.
+
+
+
+
+ {signedOut ? (
+
+ Signed out.
+
+ ) : null}
+ {authError ? (
+
+ Sign in could not be completed. Try again.
+
+ ) : null}
+
+
+
+
+
+ );
+}
diff --git a/components/auth/login-modal.tsx b/components/auth/login-modal.tsx
new file mode 100644
index 0000000..bddaf24
--- /dev/null
+++ b/components/auth/login-modal.tsx
@@ -0,0 +1,68 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+import { createPortal } from "react-dom";
+
+import { LoginExperience } from "@/components/auth/login-experience";
+import { DEFAULT_AUTH_NEXT_PATH } from "@/lib/auth/redirects";
+
+type LoginModalProps = {
+ open: boolean;
+ onClose: () => void;
+ nextPath?: string;
+};
+
+export function LoginModal({
+ open,
+ onClose,
+ nextPath = DEFAULT_AUTH_NEXT_PATH,
+}: LoginModalProps) {
+ const dialogRef = useRef(null);
+
+ useEffect(() => {
+ if (!open) return;
+
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") onClose();
+ };
+ document.addEventListener("keydown", onKeyDown);
+
+ const previousOverflow = document.body.style.overflow;
+ document.body.style.overflow = "hidden";
+
+ const focusTimer = window.setTimeout(() => {
+ dialogRef.current
+ ?.querySelector("input, button, a[href]")
+ ?.focus();
+ }, 60);
+
+ return () => {
+ document.removeEventListener("keydown", onKeyDown);
+ document.body.style.overflow = previousOverflow;
+ window.clearTimeout(focusTimer);
+ };
+ }, [open, onClose]);
+
+ if (!open || typeof document === "undefined") return null;
+
+ return createPortal(
+ ,
+ document.body,
+ );
+}
diff --git a/components/auth/motion-field.tsx b/components/auth/motion-field.tsx
new file mode 100644
index 0000000..4115ef0
--- /dev/null
+++ b/components/auth/motion-field.tsx
@@ -0,0 +1,546 @@
+"use client";
+
+/* MotionField — "Easing Studio".
+ *
+ * A living motion graph-editor that speaks MotionCode's own language: the
+ * product turns a motion reference into a normalized spec + code (easing,
+ * duration, transforms) for CSS / GSAP / Framer Motion. So the art is a
+ * cubic-bezier easing curve being authored in real time — breathing between
+ * real named easings, with control-point handles, a playhead, a sample dot that
+ * rides the curve, and a stage marker above that travels with that exact easing
+ * (curve → motion). A mono readout prints the live `cubic-bezier(...)` spec. The
+ * cursor grabs the nearest handle and bends the curve ("move the cursor to
+ * sample"). Everything is procedural — no source image. */
+
+import { type MutableRefObject, useEffect, useRef } from "react";
+
+export type MotionFieldProps = {
+ className?: string;
+ /** background void color */
+ background?: string;
+ /** structural / grid / handle / text color */
+ base?: string;
+ /** the brand accent — the curve and live values */
+ accent?: string;
+ /** grab radius for the cursor, in css px (unused name kept for the call site) */
+ sampleRadius?: number;
+ /** parent bumps `current` on keydown; field reads + decays it (pulses the curve) */
+ typingImpulseRef?: MutableRefObject;
+};
+
+type CP = { x1: number; y1: number; x2: number; y2: number };
+type Preset = { name: string; cp: CP };
+type RGB = [number, number, number];
+
+const PRESETS: Preset[] = [
+ { name: "easeInOut", cp: { x1: 0.42, y1: 0, x2: 0.58, y2: 1 } },
+ { name: "easeOutExpo", cp: { x1: 0.16, y1: 1, x2: 0.3, y2: 1 } },
+ { name: "easeOutBack", cp: { x1: 0.34, y1: 1.56, x2: 0.64, y2: 1 } },
+ { name: "easeInOutQuint", cp: { x1: 0.83, y1: 0, x2: 0.17, y2: 1 } },
+ { name: "backInOut", cp: { x1: 0.6, y1: -0.28, x2: 0.32, y2: 1.28 } },
+ { name: "easeOutCirc", cp: { x1: 0, y1: 0.55, x2: 0.45, y2: 1 } },
+];
+
+const V_MIN = -0.3;
+const V_MAX = 1.32;
+const GHOSTS = 5;
+
+const parseRGB = (c: string): RGB => {
+ const m = c.match(/(\d+(?:\.\d+)?)/g);
+ return m ? [Number(m[0]), Number(m[1]), Number(m[2])] : [255, 255, 255];
+};
+const clamp = (v: number, lo: number, hi: number) =>
+ v < lo ? lo : v > hi ? hi : v;
+/** CSS-style number: 0.34 → ".34", -0.18 → "-.18". */
+const css = (v: number) =>
+ v.toFixed(2).replace(/^(-?)0\./, "$1.").replace(/\.00$/, "");
+
+/** WebKit UnitBezier: sample the curve, and solve progress at a given time. */
+function bezier(cp: CP) {
+ const cx = 3 * cp.x1;
+ const bx = 3 * (cp.x2 - cp.x1) - cx;
+ const ax = 1 - cx - bx;
+ const cy = 3 * cp.y1;
+ const by = 3 * (cp.y2 - cp.y1) - cy;
+ const ay = 1 - cy - by;
+ const sx = (t: number) => ((ax * t + bx) * t + cx) * t;
+ const sy = (t: number) => ((ay * t + by) * t + cy) * t;
+ const dx = (t: number) => (3 * ax * t + 2 * bx) * t + cx;
+ const solveX = (x: number) => {
+ let t = x;
+ for (let i = 0; i < 8; i++) {
+ const e = sx(t) - x;
+ if (Math.abs(e) < 1e-6) return t;
+ const d = dx(t);
+ if (Math.abs(d) < 1e-6) break;
+ t -= e / d;
+ }
+ let lo = 0;
+ let hi = 1;
+ t = x;
+ for (let i = 0; i < 24; i++) {
+ const e = sx(t) - x;
+ if (Math.abs(e) < 1e-6) break;
+ if (e < 0) lo = t;
+ else hi = t;
+ t = (lo + hi) / 2;
+ }
+ return t;
+ };
+ return { sx, sy, progress: (x: number) => sy(solveX(x)) };
+}
+
+export function MotionField({
+ className,
+ background = "rgba(7, 8, 6, 1)",
+ base = "rgba(216, 207, 188, 1)",
+ accent = "rgba(0, 255, 136, 1)",
+ typingImpulseRef,
+}: MotionFieldProps) {
+ const wrapperRef = useRef(null);
+ const canvasRef = useRef(null);
+ const pointerRef = useRef({ x: -9999, y: -9999, active: false });
+ const cfg = useRef({ background, base, accent });
+ useEffect(() => {
+ cfg.current = { background, base, accent };
+ }, [background, base, accent]);
+
+ useEffect(() => {
+ const wrapper = wrapperRef.current;
+ const canvas = canvasRef.current;
+ if (!wrapper || !canvas) return;
+ const ctx = canvas.getContext("2d", { alpha: false });
+ if (!ctx) return;
+
+ const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+ const fontFamily =
+ getComputedStyle(wrapper).fontFamily || "ui-monospace, monospace";
+
+ let w = 0;
+ let h = 0;
+ let dpr = 1;
+ let raf = 0;
+ let destroyed = false;
+
+ // live (eased) control points + the target they chase
+ const cp: CP = { ...PRESETS[0].cp };
+ const target: CP = { ...PRESETS[0].cp };
+ let label = PRESETS[0].name;
+ let custom = false;
+ let presetIdx = 0;
+ let dwell = 0;
+ const DWELL = 150; // frames a preset is held before morphing
+ let loopT = 0;
+ let holdT = 0;
+ let t = 0;
+ const ghosts: CP[] = [];
+ const stageTrail: { x: number; y: number }[] = [];
+
+ // plot geometry (css px), recomputed on resize
+ let plotL = 0;
+ let plotR = 0;
+ let plotTop = 0;
+ let plotBot = 0;
+ let stageY = 0;
+
+ const layout = () => {
+ plotL = w * 0.15;
+ plotR = w * 0.87;
+ plotTop = h * 0.41;
+ plotBot = h * 0.82;
+ stageY = h * 0.25;
+ };
+
+ const X = (tt: number) => plotL + tt * (plotR - plotL);
+ const Y = (v: number) =>
+ plotBot - ((v - V_MIN) / (V_MAX - V_MIN)) * (plotBot - plotTop);
+
+ const resize = () => {
+ const rect = wrapper.getBoundingClientRect();
+ w = Math.max(1, Math.floor(rect.width));
+ h = Math.max(1, Math.floor(rect.height));
+ dpr = Math.min(window.devicePixelRatio || 1, 2);
+ canvas.width = w * dpr;
+ canvas.height = h * dpr;
+ canvas.style.width = `${w}px`;
+ canvas.style.height = `${h}px`;
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ layout();
+ };
+
+ const handlePos = (which: 1 | 2) =>
+ which === 1
+ ? { x: X(cp.x1), y: Y(cp.y1) }
+ : { x: X(cp.x2), y: Y(cp.y2) };
+
+ const updateTarget = () => {
+ const p = pointerRef.current;
+ // hover-grab: nearest handle within radius follows the cursor
+ if (p.active && p.x > plotL - 40 && p.x < plotR + 40) {
+ const h1 = handlePos(1);
+ const h2 = handlePos(2);
+ const d1 = Math.hypot(p.x - h1.x, p.y - h1.y);
+ const d2 = Math.hypot(p.x - h2.x, p.y - h2.y);
+ const grab = 30;
+ if (Math.min(d1, d2) < grab) {
+ const tt = clamp((p.x - plotL) / (plotR - plotL), -0.1, 1.1);
+ const vv = clamp(
+ (plotBot - p.y) / (plotBot - plotTop) * (V_MAX - V_MIN) + V_MIN,
+ V_MIN + 0.04,
+ V_MAX - 0.04,
+ );
+ if (d1 <= d2) {
+ target.x1 = tt;
+ target.y1 = vv;
+ } else {
+ target.x2 = tt;
+ target.y2 = vv;
+ }
+ custom = true;
+ label = "custom";
+ dwell = 0;
+ return;
+ }
+ }
+ // auto: dwell on a preset, then morph to the next
+ if (!custom || (!p.active && dwell === 0)) {
+ custom = false;
+ dwell += 1;
+ if (dwell > DWELL) {
+ dwell = 0;
+ presetIdx = (presetIdx + 1) % PRESETS.length;
+ const next = PRESETS[presetIdx];
+ target.x1 = next.cp.x1;
+ target.y1 = next.cp.y1;
+ target.x2 = next.cp.x2;
+ target.y2 = next.cp.y2;
+ label = next.name;
+ }
+ }
+ };
+
+ const easeCP = () => {
+ const k = 0.07;
+ cp.x1 += (target.x1 - cp.x1) * k;
+ cp.y1 += (target.y1 - cp.y1) * k;
+ cp.x2 += (target.x2 - cp.x2) * k;
+ cp.y2 += (target.y2 - cp.y2) * k;
+ };
+
+ const drawScene = (animate: boolean) => {
+ const { background: bgC, base: baseC, accent: accC } = cfg.current;
+ const [bgR, bgG, bgB] = parseRGB(bgC);
+ const [bR, bG, bB] = parseRGB(baseC);
+ const [aR, aG, aB] = parseRGB(accC);
+ const boneA = (a: number) => `rgba(${bR},${bG},${bB},${a})`;
+ const grnA = (a: number) => `rgba(${aR},${aG},${aB},${a})`;
+
+ const typing = typingImpulseRef?.current ?? 0;
+ if (typingImpulseRef && typing > 1e-4) typingImpulseRef.current *= 0.93;
+
+ ctx.fillStyle = `rgb(${bgR},${bgG},${bgB})`;
+ ctx.fillRect(0, 0, w, h);
+
+ // ---- grid + axes -------------------------------------------------
+ ctx.lineWidth = 1;
+ // vertical time gridlines
+ for (let i = 0; i <= 4; i++) {
+ const gx = X(i / 4);
+ ctx.strokeStyle = boneA(i === 0 || i === 4 ? 0.14 : 0.06);
+ ctx.beginPath();
+ ctx.moveTo(gx, Y(V_MAX) - 2);
+ ctx.lineTo(gx, Y(V_MIN));
+ ctx.stroke();
+ }
+ // value baselines: 0 (start) solid faint, 1 (target) dashed green
+ ctx.strokeStyle = boneA(0.16);
+ ctx.beginPath();
+ ctx.moveTo(plotL, Y(0));
+ ctx.lineTo(plotR, Y(0));
+ ctx.stroke();
+ ctx.save();
+ ctx.setLineDash([2, 5]);
+ ctx.strokeStyle = grnA(0.22);
+ ctx.beginPath();
+ ctx.moveTo(plotL, Y(1));
+ ctx.lineTo(plotR, Y(1));
+ ctx.stroke();
+ ctx.restore();
+ // bottom time ruler
+ for (let i = 0; i <= 16; i++) {
+ const gx = X(i / 16);
+ const major = i % 4 === 0;
+ ctx.strokeStyle = boneA(major ? 0.22 : 0.1);
+ ctx.beginPath();
+ ctx.moveTo(gx, Y(V_MIN));
+ ctx.lineTo(gx, Y(V_MIN) + (major ? 8 : 4));
+ ctx.stroke();
+ }
+
+ // ---- ghost curves (recent history → breathing trail) -------------
+ ctx.lineWidth = 1;
+ for (let g = 0; g < ghosts.length; g++) {
+ const gb = bezier(ghosts[g]);
+ const a = ((g + 1) / ghosts.length) * 0.12;
+ ctx.strokeStyle = grnA(a);
+ ctx.beginPath();
+ for (let i = 0; i <= 48; i++) {
+ const s = i / 48;
+ const x = X(gb.sx(s));
+ const y = Y(gb.sy(s));
+ if (i === 0) ctx.moveTo(x, y);
+ else ctx.lineTo(x, y);
+ }
+ ctx.stroke();
+ }
+
+ // ---- the easing curve --------------------------------------------
+ const b = bezier(cp);
+ const path = () => {
+ ctx.beginPath();
+ for (let i = 0; i <= 72; i++) {
+ const s = i / 72;
+ const x = X(b.sx(s));
+ const y = Y(b.sy(s));
+ if (i === 0) ctx.moveTo(x, y);
+ else ctx.lineTo(x, y);
+ }
+ };
+ ctx.save();
+ ctx.lineJoin = "round";
+ ctx.lineCap = "round";
+ ctx.shadowColor = grnA(0.9);
+ ctx.shadowBlur = 14 + typing * 18;
+ ctx.strokeStyle = grnA(0.5);
+ ctx.lineWidth = 4.5;
+ path();
+ ctx.stroke();
+ ctx.restore();
+ ctx.strokeStyle = grnA(0.95);
+ ctx.lineWidth = 1.6;
+ path();
+ ctx.stroke();
+
+ // ---- control handles ---------------------------------------------
+ const p0 = { x: X(0), y: Y(0) };
+ const p3 = { x: X(1), y: Y(1) };
+ const h1 = handlePos(1);
+ const h2 = handlePos(2);
+ ctx.strokeStyle = boneA(0.4);
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.moveTo(p0.x, p0.y);
+ ctx.lineTo(h1.x, h1.y);
+ ctx.moveTo(p3.x, p3.y);
+ ctx.lineTo(h2.x, h2.y);
+ ctx.stroke();
+ // endpoint dots
+ ctx.fillStyle = boneA(0.7);
+ for (const p of [p0, p3]) {
+ ctx.beginPath();
+ ctx.arc(p.x, p.y, 2.4, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ // handle squares
+ const grabbed = custom && pointerRef.current.active;
+ for (const hp of [h1, h2]) {
+ ctx.fillStyle = `rgb(${bgR},${bgG},${bgB})`;
+ ctx.strokeStyle = grabbed ? grnA(0.95) : boneA(0.8);
+ ctx.lineWidth = 1.3;
+ const s = 4.5;
+ ctx.beginPath();
+ ctx.rect(hp.x - s, hp.y - s, s * 2, s * 2);
+ ctx.fill();
+ ctx.stroke();
+ }
+
+ // ---- playhead + sample dot + stage motion ------------------------
+ let prog = b.progress(clamp(loopT, 0, 1));
+ if (animate) {
+ // playhead vertical line
+ const phx = X(clamp(loopT, 0, 1));
+ ctx.strokeStyle = boneA(0.18);
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.moveTo(phx, Y(V_MAX) - 2);
+ ctx.lineTo(phx, Y(V_MIN));
+ ctx.stroke();
+
+ // leader from curve point to the value axis on the right
+ const dotY = Y(prog);
+ ctx.save();
+ ctx.setLineDash([2, 4]);
+ ctx.strokeStyle = grnA(0.3);
+ ctx.beginPath();
+ ctx.moveTo(phx, dotY);
+ ctx.lineTo(plotR + 14, dotY);
+ ctx.stroke();
+ ctx.restore();
+ ctx.fillStyle = grnA(0.85);
+ ctx.fillRect(plotR + 12, dotY - 1, 5, 2);
+
+ // glowing sample dot on the curve
+ ctx.save();
+ ctx.shadowColor = grnA(0.9);
+ ctx.shadowBlur = 12;
+ ctx.fillStyle = grnA(1);
+ ctx.beginPath();
+ ctx.arc(phx, dotY, 3.4, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.restore();
+ } else {
+ prog = b.progress(0.42);
+ }
+
+ // ---- stage: the resulting motion ---------------------------------
+ const sL = plotL;
+ const sR = plotR;
+ ctx.strokeStyle = boneA(0.16);
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.moveTo(sL, stageY);
+ ctx.lineTo(sR, stageY);
+ ctx.stroke();
+ for (const ex of [sL, sR]) {
+ ctx.strokeStyle = boneA(0.3);
+ ctx.beginPath();
+ ctx.moveTo(ex, stageY - 5);
+ ctx.lineTo(ex, stageY + 5);
+ ctx.stroke();
+ }
+ const mx = sL + prog * (sR - sL);
+ if (animate) {
+ stageTrail.push({ x: mx, y: stageY });
+ if (stageTrail.length > 16) stageTrail.shift();
+ for (let i = 0; i < stageTrail.length; i++) {
+ const a = (i / stageTrail.length) * 0.5;
+ ctx.fillStyle = grnA(a);
+ ctx.beginPath();
+ ctx.arc(stageTrail[i].x, stageTrail[i].y, 1.5, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ }
+ ctx.save();
+ ctx.shadowColor = grnA(0.9);
+ ctx.shadowBlur = 12;
+ ctx.fillStyle = grnA(1);
+ ctx.beginPath();
+ ctx.arc(mx, stageY, 4, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.restore();
+ // a hairline ring marks the motion's target
+ ctx.strokeStyle = grnA(0.4);
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.arc(sR, stageY, 6, 0, Math.PI * 2);
+ ctx.stroke();
+
+ // ---- mono readout: the spec the product emits --------------------
+ ctx.textBaseline = "alphabetic";
+ ctx.textAlign = "left";
+ const rx = plotL;
+ const ry = h * 0.335;
+ ctx.font = `600 12px ${fontFamily}`;
+ ctx.fillStyle = boneA(0.92);
+ ctx.fillText(label.toUpperCase(), rx, ry);
+ ctx.font = `13px ${fontFamily}`;
+ ctx.fillStyle = grnA(0.92);
+ ctx.fillText(
+ `cubic-bezier(${css(cp.x1)}, ${css(cp.y1)}, ${css(cp.x2)}, ${css(cp.y2)})`,
+ rx,
+ ry + 19,
+ );
+ ctx.font = `10px ${fontFamily}`;
+ ctx.fillStyle = boneA(0.34);
+ ctx.fillText("css · gsap · framer", rx, ry + 36);
+ };
+
+ const loop = () => {
+ if (destroyed) return;
+ t += 1;
+ updateTarget();
+ easeCP();
+ // record ghosts of the curve as it morphs
+ if (t % 7 === 0) {
+ ghosts.push({ ...cp });
+ if (ghosts.length > GHOSTS) ghosts.shift();
+ }
+ // playhead time with a hold at each end for readability
+ const HOLD = 26;
+ const SPAN = 118;
+ if (holdT > 0) {
+ holdT -= 1;
+ } else {
+ loopT += 1 / SPAN;
+ if (loopT >= 1) {
+ loopT = 0;
+ holdT = HOLD;
+ stageTrail.length = 0;
+ }
+ }
+ drawScene(true);
+ raf = requestAnimationFrame(loop);
+ };
+
+ const onMove = (e: PointerEvent) => {
+ const rect = wrapper.getBoundingClientRect();
+ pointerRef.current.x = e.clientX - rect.left;
+ pointerRef.current.y = e.clientY - rect.top;
+ pointerRef.current.active = true;
+ };
+ const onLeave = () => {
+ pointerRef.current.active = false;
+ pointerRef.current.x = -9999;
+ pointerRef.current.y = -9999;
+ custom = false;
+ };
+
+ const ro = new ResizeObserver(() => resize());
+ ro.observe(wrapper);
+ resize();
+ wrapper.addEventListener("pointermove", onMove);
+ wrapper.addEventListener("pointerleave", onLeave);
+
+ if (reduce) {
+ target.x1 = PRESETS[2].cp.x1;
+ target.y1 = PRESETS[2].cp.y1;
+ target.x2 = PRESETS[2].cp.x2;
+ target.y2 = PRESETS[2].cp.y2;
+ cp.x1 = target.x1;
+ cp.y1 = target.y1;
+ cp.x2 = target.x2;
+ cp.y2 = target.y2;
+ label = PRESETS[2].name;
+ drawScene(false);
+ } else {
+ raf = requestAnimationFrame(loop);
+ }
+
+ return () => {
+ destroyed = true;
+ cancelAnimationFrame(raf);
+ ro.disconnect();
+ wrapper.removeEventListener("pointermove", onMove);
+ wrapper.removeEventListener("pointerleave", onLeave);
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ return (
+
+
+
+ );
+}
diff --git a/components/auth/particle-field.tsx b/components/auth/particle-field.tsx
new file mode 100644
index 0000000..00df841
--- /dev/null
+++ b/components/auth/particle-field.tsx
@@ -0,0 +1,616 @@
+"use client";
+
+/* Vendored from the devl.dev auth/login registry (image-sampled particle
+ field). It deliberately syncs props into refs during render so the animation
+ effect stays mounted across prop changes without tearing down particle
+ state — that trips react-hooks/refs, which we disable for this file only. */
+/* eslint-disable react-hooks/refs */
+
+import {
+ type MutableRefObject,
+ useEffect,
+ useRef,
+ useSyncExternalStore,
+} from "react";
+
+type Particle = {
+ ox: number;
+ oy: number;
+ x: number;
+ y: number;
+ vx: number;
+ vy: number;
+ size: number;
+ alpha: number;
+ phase: number;
+ /** Per-particle spring multiplier (0.75..1.25) — decorrelates arrival times during a morph. */
+ springJitter: number;
+ /** 0 = invisible, 1 = fully painted. Eases toward 1, or toward 0 while fading. */
+ appear: number;
+ /** Surplus particle from a prior shape — fade out and cull. */
+ fading: boolean;
+};
+
+type ParticleTarget = {
+ ox: number;
+ oy: number;
+ size: number;
+ alpha: number;
+};
+
+export type ParticleFieldProps = {
+ src: string;
+ /** pixel step when sampling the source image. Lower = denser */
+ sampleStep?: number;
+ /** alpha cutoff 0-255 for including a pixel as a particle */
+ threshold?: number;
+ /** multiplier applied to the canvas rendering versus the sampled image */
+ renderScale?: number;
+ /** base dot size in device pixels */
+ dotSize?: number;
+ /** how strong the cursor repels dots */
+ mouseForce?: number;
+ /** radius around the cursor that has repelling force, in device pixels */
+ mouseRadius?: number;
+ /** spring constant pulling dots back to their origin */
+ spring?: number;
+ /** viscous damping on velocity */
+ damping?: number;
+ className?: string;
+ /** alignment of the particle cluster inside the canvas */
+ align?: "center" | "bottom";
+ /** optional color override when `adaptToTheme` is false; defaults to white */
+ color?: string;
+ /** sample dark pixels instead of bright ones (for dark-on-light source images) */
+ invert?: boolean;
+ /**
+ * When true (default), dot fill follows `html.dark` (light dots on dark, dark dots on light)
+ * without resampling the image — only the paint color changes, so toggling theme stays smooth.
+ */
+ adaptToTheme?: boolean;
+ /**
+ * POC: parent bumps `current` on keydown (e.g. +0.12); field decays each frame and uses it
+ * to add extra drift / twinkle so typing on the auth column subtly animates the figure.
+ */
+ typingImpulseRef?: MutableRefObject;
+ /**
+ * When true, keeps every sampled pixel above `threshold` (skips the random luminance thinning).
+ * Use for small fixed-size embeds where the default sparse falloff erases the figure.
+ */
+ denseParticles?: boolean;
+};
+
+function subscribeDocumentDark(callback: () => void) {
+ const el = document.documentElement;
+ const mo = new MutationObserver(callback);
+ mo.observe(el, { attributes: true, attributeFilter: ["class"] });
+ const mq = window.matchMedia("(prefers-color-scheme: dark)");
+ mq.addEventListener("change", callback);
+ return () => {
+ mo.disconnect();
+ mq.removeEventListener("change", callback);
+ };
+}
+
+function getDocumentDarkSnapshot() {
+ return document.documentElement.classList.contains("dark");
+}
+
+function getServerDarkSnapshot() {
+ return true;
+}
+
+const TYPING_IMPULSE_ADD = 0.14;
+const TYPING_IMPULSE_CAP = 1.35;
+
+const SUBMIT_IMPULSE_PRIMARY = 0.52;
+const SUBMIT_IMPULSE_SECOND_MS = 120;
+const SUBMIT_IMPULSE_SECONDARY = 0.2;
+
+/** Add energy to `typingImpulseRef` (keyboard, preset chips, etc.). */
+export function pulseParticleTypingImpulse(
+ impulseRef: MutableRefObject,
+ amount = TYPING_IMPULSE_ADD,
+) {
+ impulseRef.current = Math.min(
+ impulseRef.current + amount,
+ TYPING_IMPULSE_CAP,
+ );
+}
+
+/**
+ * Stronger two-beat pulse when a form is sent — primary hit plus a quick
+ * follow-up while the first is still decaying (reads like a soft “launch”).
+ */
+export function pulseParticleSubmitImpulse(
+ impulseRef: MutableRefObject,
+) {
+ pulseParticleTypingImpulse(impulseRef, SUBMIT_IMPULSE_PRIMARY);
+ window.setTimeout(() => {
+ pulseParticleTypingImpulse(impulseRef, SUBMIT_IMPULSE_SECONDARY);
+ }, SUBMIT_IMPULSE_SECOND_MS);
+}
+
+/** Bump `typingImpulseRef` from a `keydown` handler (used with `ParticleField` typing POC). */
+export function bumpParticleTypingImpulse(
+ impulseRef: MutableRefObject,
+ e: Pick,
+) {
+ if (e.repeat) return;
+ if (e.metaKey || e.ctrlKey || e.altKey) return;
+ if (e.key === "Tab" || e.key === "Escape") return;
+ pulseParticleTypingImpulse(impulseRef, TYPING_IMPULSE_ADD);
+}
+
+function useDocumentDark() {
+ return useSyncExternalStore(
+ subscribeDocumentDark,
+ getDocumentDarkSnapshot,
+ getServerDarkSnapshot,
+ );
+}
+
+export function ParticleField({
+ src,
+ sampleStep = 3,
+ threshold = 50,
+ renderScale = 1,
+ dotSize = 1.15,
+ mouseForce = 90,
+ mouseRadius = 110,
+ spring = 0.035,
+ damping = 0.86,
+ className,
+ align = "center",
+ color = "rgba(255, 255, 255, 0.92)",
+ invert = false,
+ adaptToTheme = true,
+ typingImpulseRef,
+ denseParticles = false,
+}: ParticleFieldProps) {
+ const isDark = useDocumentDark();
+ const fillColorRef = useRef(color);
+ fillColorRef.current = adaptToTheme
+ ? isDark
+ ? "rgba(255, 255, 255, 0.92)"
+ : "rgba(10, 12, 16, 1)"
+ : color;
+
+ const canvasRef = useRef(null);
+ const wrapperRef = useRef(null);
+ const pointerRef = useRef({ x: -9999, y: -9999, active: false });
+ const srcRef = useRef(src);
+ srcRef.current = src;
+ const applySrcRef = useRef<((nextSrc: string) => void) | null>(null);
+
+ // Tuning props live in refs so the main effect can stay mounted across
+ // prop changes (e.g. onboarding step tweaks threshold + dotSize + src
+ // simultaneously). Rebuilding the effect would defeat the morph.
+ const sampleStepRef = useRef(sampleStep);
+ sampleStepRef.current = sampleStep;
+ const thresholdRef = useRef(threshold);
+ thresholdRef.current = threshold;
+ const renderScaleRef = useRef(renderScale);
+ renderScaleRef.current = renderScale;
+ const dotSizeRef = useRef(dotSize);
+ dotSizeRef.current = dotSize;
+ const mouseForceRef = useRef(mouseForce);
+ mouseForceRef.current = mouseForce;
+ const mouseRadiusRef = useRef(mouseRadius);
+ mouseRadiusRef.current = mouseRadius;
+ const springRef = useRef(spring);
+ springRef.current = spring;
+ const dampingRef = useRef(damping);
+ dampingRef.current = damping;
+ const alignRef = useRef(align);
+ alignRef.current = align;
+ const invertRef = useRef(invert);
+ invertRef.current = invert;
+ const denseParticlesRef = useRef(denseParticles);
+ denseParticlesRef.current = denseParticles;
+
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ const wrapper = wrapperRef.current;
+ if (!canvas || !wrapper) return;
+
+ const ctx = canvas.getContext("2d", { alpha: true });
+ if (!ctx) return;
+
+ let particles: Particle[] = [];
+ let dpr = Math.min(window.devicePixelRatio || 1, 2);
+ let width = 0;
+ let height = 0;
+ let clusterW = 0;
+ let clusterH = 0;
+ let offsetX = 0;
+ let offsetY = 0;
+ let rafId = 0;
+ let time = 0;
+ let destroyed = false;
+ let resizeRaf = 0;
+ let resizeTimer: ReturnType | null = null;
+ let currentImage: HTMLImageElement | null = null;
+ let loadToken = 0;
+
+ const ensureCanvasSize = () => {
+ const rect = wrapper.getBoundingClientRect();
+ width = Math.max(1, Math.floor(rect.width));
+ height = Math.max(1, Math.floor(rect.height));
+ dpr = Math.min(window.devicePixelRatio || 1, 2);
+
+ canvas.width = width * dpr;
+ canvas.height = height * dpr;
+ canvas.style.width = `${width}px`;
+ canvas.style.height = `${height}px`;
+ };
+
+ const sampleTargets = (image: HTMLImageElement): ParticleTarget[] => {
+ if (!image.width || !image.height) return [];
+
+ const srcRatio = image.width / image.height;
+ const dstRatio = width / height;
+
+ let drawW = width;
+ let drawH = height;
+ if (srcRatio > dstRatio) {
+ drawH = height;
+ drawW = height * srcRatio;
+ } else {
+ drawW = width;
+ drawH = width / srcRatio;
+ }
+
+ drawW *= renderScaleRef.current;
+ drawH *= renderScaleRef.current;
+
+ const sampleW = Math.max(80, Math.floor(drawW / sampleStepRef.current));
+ const sampleH = Math.max(80, Math.floor(drawH / sampleStepRef.current));
+
+ const off = document.createElement("canvas");
+ off.width = sampleW;
+ off.height = sampleH;
+ const offCtx = off.getContext("2d", { willReadFrequently: true });
+ if (!offCtx) return [];
+ offCtx.drawImage(image, 0, 0, sampleW, sampleH);
+ const data = offCtx.getImageData(0, 0, sampleW, sampleH).data;
+
+ const cellW = drawW / sampleW;
+ const cellH = drawH / sampleH;
+
+ clusterW = drawW;
+ clusterH = drawH;
+ offsetX = (width - clusterW) / 2;
+ offsetY =
+ alignRef.current === "bottom"
+ ? height - clusterH - Math.min(40, height * 0.04)
+ : (height - clusterH) / 2;
+
+ const thresholdV = thresholdRef.current;
+ const invertV = invertRef.current;
+ const denseV = denseParticlesRef.current;
+ const dotSizeV = dotSizeRef.current;
+
+ const targets: ParticleTarget[] = [];
+ for (let y = 0; y < sampleH; y++) {
+ for (let x = 0; x < sampleW; x++) {
+ const idx = (y * sampleW + x) * 4;
+ const r = data[idx];
+ const g = data[idx + 1];
+ const b = data[idx + 2];
+ const a = data[idx + 3];
+ const rawBrightness = (r + g + b) / 3;
+ const brightness = invertV ? 255 - rawBrightness : rawBrightness;
+ if (a < 200 || brightness < thresholdV) continue;
+
+ const lum = brightness / 255;
+ if (!denseV) {
+ // density falloff: skip some mid-gray pixels to keep dots sparse (hero layouts)
+ const keep =
+ lum > 0.8
+ ? true
+ : lum > 0.5
+ ? Math.random() < 0.85
+ : lum > 0.25
+ ? Math.random() < 0.55
+ : Math.random() < 0.28;
+ if (!keep) continue;
+ }
+
+ const px = (offsetX + x * cellW + cellW / 2) * dpr;
+ const py = (offsetY + y * cellH + cellH / 2) * dpr;
+
+ targets.push({
+ ox: px,
+ oy: py,
+ size: (dotSizeV + lum * 0.9) * dpr,
+ alpha: 0.35 + lum * 0.6,
+ });
+ }
+ }
+ return targets;
+ };
+
+ const randomSpringJitter = () => 0.9 + Math.random() * 0.2;
+
+ const buildFresh = (image: HTMLImageElement) => {
+ if (!image.width || !image.height) return;
+ ensureCanvasSize();
+ const targets = sampleTargets(image);
+ particles = targets.map((t) => ({
+ ox: t.ox,
+ oy: t.oy,
+ x: t.ox + (Math.random() - 0.5) * 40,
+ y: t.oy + (Math.random() - 0.5) * 40,
+ vx: 0,
+ vy: 0,
+ size: t.size,
+ alpha: t.alpha,
+ phase: Math.random() * Math.PI * 2,
+ springJitter: randomSpringJitter(),
+ appear: 1,
+ fading: false,
+ }));
+ };
+
+ // In-place Fisher–Yates — used to decorrelate the raster scan order of
+ // both the current particle array and the freshly sampled targets so
+ // morph transitions don't sweep in a visible diagonal line.
+ const shuffleIndices = (n: number): number[] => {
+ const arr = new Array(n);
+ for (let i = 0; i < n; i++) arr[i] = i;
+ for (let i = n - 1; i > 0; i--) {
+ const j = (Math.random() * (i + 1)) | 0;
+ const tmp = arr[i];
+ arr[i] = arr[j];
+ arr[j] = tmp;
+ }
+ return arr;
+ };
+
+ // Re-home existing particles onto a new figure so they spring-migrate
+ // instead of snapping.
+ //
+ // Pairing particles↔targets by raster index (the naive approach) created
+ // a very visible sweep line: both images are sampled top-left →
+ // bottom-right, so every row's density delta moved particles in lockstep.
+ // We randomly permute both arrays before pairing so motion is spatially
+ // decorrelated. Surplus particles just fade in place; new particles
+ // spawn on a small ring around their destination.
+ const morphTo = (image: HTMLImageElement) => {
+ if (!image.width || !image.height) return;
+ if (particles.length === 0) {
+ buildFresh(image);
+ return;
+ }
+ ensureCanvasSize();
+ const targets = sampleTargets(image);
+
+ const n = particles.length;
+ const m = targets.length;
+ const matched = Math.min(n, m);
+ const pOrder = shuffleIndices(n);
+ const tOrder = shuffleIndices(m);
+
+ for (let k = 0; k < matched; k++) {
+ const p = particles[pOrder[k]];
+ const t = targets[tOrder[k]];
+ p.ox = t.ox;
+ p.oy = t.oy;
+ p.size = t.size;
+ p.alpha = t.alpha;
+ p.fading = false;
+ p.springJitter = randomSpringJitter();
+ }
+
+ // Surplus particles just fade in place — the spring keeps them near
+ // their previous home while alpha eases to 0. No outward kick: that
+ // read as "explosion" rather than "settle".
+ for (let k = matched; k < n; k++) {
+ particles[pOrder[k]].fading = true;
+ }
+
+ // Extras: spawn new particles on a small random ring around each
+ // target so the incoming cloud reads as a soft gather, not a sweep.
+ for (let k = matched; k < m; k++) {
+ const t = targets[tOrder[k]];
+ const angle = Math.random() * Math.PI * 2;
+ const dist = (20 + Math.random() * 40) * dpr;
+ particles.push({
+ ox: t.ox,
+ oy: t.oy,
+ x: t.ox + Math.cos(angle) * dist,
+ y: t.oy + Math.sin(angle) * dist,
+ vx: 0,
+ vy: 0,
+ size: t.size,
+ alpha: t.alpha,
+ phase: Math.random() * Math.PI * 2,
+ springJitter: randomSpringJitter(),
+ appear: 0,
+ fading: false,
+ });
+ }
+ };
+
+ const render = () => {
+ if (destroyed) return;
+ time += 0.016;
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ ctx.fillStyle = fillColorRef.current;
+
+ const mouseForceV = mouseForceRef.current;
+ const mouseRadiusV = mouseRadiusRef.current;
+ const springV = springRef.current;
+ const dampingV = dampingRef.current;
+
+ const px = pointerRef.current.x * dpr;
+ const py = pointerRef.current.y * dpr;
+ const mr = mouseRadiusV * dpr;
+ const mr2 = mr * mr;
+
+ const typing = typingImpulseRef?.current ?? 0;
+ if (typingImpulseRef && typing > 1e-4) {
+ typingImpulseRef.current *= 0.93;
+ }
+ const typingBoost = 1 + typing * 10;
+ const rippleCx = (offsetX + clusterW * 0.5) * dpr;
+ const rippleCy = (offsetY + clusterH * 0.48) * dpr;
+
+ let writeIdx = 0;
+ for (let i = 0; i < particles.length; i++) {
+ const p = particles[i];
+
+ const dxo = p.ox - p.x;
+ const dyo = p.oy - p.y;
+ const s = springV * p.springJitter;
+ p.vx += dxo * s;
+ p.vy += dyo * s;
+
+ if (pointerRef.current.active) {
+ const dx = p.x - px;
+ const dy = p.y - py;
+ const d2 = dx * dx + dy * dy;
+ if (d2 < mr2 && d2 > 0.0001) {
+ const d = Math.sqrt(d2);
+ const force = (1 - d / mr) * mouseForceV;
+ p.vx += (dx / d) * force * 0.04;
+ p.vy += (dy / d) * force * 0.04;
+ }
+ }
+
+ const drift = Math.sin(time * 0.8 + p.phase) * 0.08;
+ p.vx += drift * 0.05 * typingBoost;
+ p.vy += Math.cos(time * 0.9 + p.phase) * 0.04 * typingBoost;
+
+ if (typing > 1e-4) {
+ p.vx += (Math.random() - 0.5) * typing * 2.8;
+ p.vy += (Math.random() - 0.5) * typing * 2.8;
+ const rdx = p.x - rippleCx;
+ const rdy = p.y - rippleCy;
+ const rd = Math.sqrt(rdx * rdx + rdy * rdy) + 0.5;
+ const ripple = (typing * 22 * dpr) / rd;
+ p.vx += (rdx / rd) * ripple * 0.018;
+ p.vy += (rdy / rd) * ripple * 0.018;
+ }
+
+ p.vx *= dampingV;
+ p.vy *= dampingV;
+ p.x += p.vx;
+ p.y += p.vy;
+
+ const appearTarget = p.fading ? 0 : 1;
+ p.appear += (appearTarget - p.appear) * 0.08;
+
+ if (p.fading && p.appear < 0.02) {
+ // cull from particles[] as part of the render pass
+ continue;
+ }
+
+ const twinkle =
+ 0.85 +
+ Math.sin(time * (1.4 + typing * 2.2) + p.phase) *
+ (0.15 + typing * 0.35);
+ ctx.globalAlpha = p.alpha * p.appear * twinkle;
+ ctx.beginPath();
+ ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
+ ctx.fill();
+
+ if (writeIdx !== i) particles[writeIdx] = p;
+ writeIdx++;
+ }
+ if (writeIdx !== particles.length) particles.length = writeIdx;
+ ctx.globalAlpha = 1;
+ rafId = requestAnimationFrame(render);
+ };
+
+ const onPointerMove = (e: PointerEvent) => {
+ const rect = wrapper.getBoundingClientRect();
+ pointerRef.current.x = e.clientX - rect.left;
+ pointerRef.current.y = e.clientY - rect.top;
+ pointerRef.current.active = true;
+ };
+ const onPointerLeave = () => {
+ pointerRef.current.active = false;
+ pointerRef.current.x = -9999;
+ pointerRef.current.y = -9999;
+ };
+
+ const ro = new ResizeObserver(() => {
+ if (resizeRaf) cancelAnimationFrame(resizeRaf);
+ resizeRaf = requestAnimationFrame(() => {
+ if (resizeTimer) clearTimeout(resizeTimer);
+ // Drag-resizing can fire continuously; debounce expensive resampling.
+ resizeTimer = setTimeout(() => {
+ if (currentImage) buildFresh(currentImage);
+ }, 120);
+ });
+ });
+
+ const loadAndApply = (nextSrc: string, asMorph: boolean) => {
+ const token = ++loadToken;
+ const image = new Image();
+ image.crossOrigin = "anonymous";
+ image.decoding = "async";
+ image.onload = () => {
+ if (destroyed || token !== loadToken) return;
+ currentImage = image;
+ if (asMorph) morphTo(image);
+ else buildFresh(image);
+ };
+ image.src = nextSrc;
+ };
+
+ applySrcRef.current = (nextSrc: string) => loadAndApply(nextSrc, true);
+
+ // Start render loop + resize observer up-front — drawing an empty
+ // particle array is a no-op, and starting eagerly avoids races where a
+ // second load (e.g. parallel src-change effect) supersedes the initial
+ // load's onload before it had a chance to kick off the RAF.
+ ro.observe(wrapper);
+ rafId = requestAnimationFrame(render);
+
+ loadAndApply(srcRef.current, false);
+
+ wrapper.addEventListener("pointermove", onPointerMove);
+ wrapper.addEventListener("pointerleave", onPointerLeave);
+
+ return () => {
+ destroyed = true;
+ cancelAnimationFrame(rafId);
+ if (resizeRaf) cancelAnimationFrame(resizeRaf);
+ if (resizeTimer) clearTimeout(resizeTimer);
+ ro.disconnect();
+ wrapper.removeEventListener("pointermove", onPointerMove);
+ wrapper.removeEventListener("pointerleave", onPointerLeave);
+ applySrcRef.current = null;
+ };
+ // Tuning props (threshold, dotSize, align, etc.) are read from refs,
+ // so we intentionally don't include them here — re-running this effect
+ // would tear down the canvas and particle state, defeating morphs.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ // Morph into a new source without tearing down the particle system. The
+ // main effect installs `applySrcRef`; we invoke it when `src` changes so
+ // existing particles spring-migrate to the new shape instead of snapping.
+ const lastAppliedSrcRef = useRef(src);
+ useEffect(() => {
+ if (lastAppliedSrcRef.current === src) return;
+ lastAppliedSrcRef.current = src;
+ applySrcRef.current?.(src);
+ }, [src]);
+
+ return (
+
+
+
+ );
+}
diff --git a/components/auth/sign-out-button.tsx b/components/auth/sign-out-button.tsx
index 47fb3c4..295b600 100644
--- a/components/auth/sign-out-button.tsx
+++ b/components/auth/sign-out-button.tsx
@@ -1,22 +1,33 @@
"use client";
import { LogOut } from "lucide-react";
+import { useEffect, useRef, useState } from "react";
+import { createPortal } from "react-dom";
import { cn } from "@/lib/utils";
type SignOutButtonProps = {
className?: string;
label?: string;
+ /** When true (default) clicking opens a confirm dialog before signing out. */
+ confirm?: boolean;
};
export function SignOutButton({
className,
label = "Sign out",
+ confirm = true,
}: SignOutButtonProps) {
+ const [confirming, setConfirming] = useState(false);
+ const formRef = useRef(null);
+
+ const submit = () => formRef.current?.requestSubmit();
+
return (
-
);
}
+
+function SignOutConfirmDialog({
+ open,
+ onCancel,
+ onConfirm,
+}: {
+ open: boolean;
+ onCancel: () => void;
+ onConfirm: () => void;
+}) {
+ const confirmRef = useRef(null);
+
+ useEffect(() => {
+ if (!open) return;
+
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") onCancel();
+ };
+ document.addEventListener("keydown", onKeyDown);
+
+ const previousOverflow = document.body.style.overflow;
+ document.body.style.overflow = "hidden";
+ const focusTimer = window.setTimeout(() => confirmRef.current?.focus(), 60);
+
+ return () => {
+ document.removeEventListener("keydown", onKeyDown);
+ document.body.style.overflow = previousOverflow;
+ window.clearTimeout(focusTimer);
+ };
+ }, [open, onCancel]);
+
+ if (!open || typeof document === "undefined") return null;
+
+ return createPortal(
+
+
+
+
+
+
+
+
+
+ Sign out of MotionCode?
+
+
+ You'll need to sign in again to get back to your workspace.
+
+
+
+
+
+
+ Cancel
+
+
+ Sign out
+
+
+
+
,
+ document.body,
+ );
+}
diff --git a/components/billing/BillingContent.tsx b/components/billing/BillingContent.tsx
new file mode 100644
index 0000000..40a9617
--- /dev/null
+++ b/components/billing/BillingContent.tsx
@@ -0,0 +1,283 @@
+import Link from "next/link";
+import { CreditCard, Receipt, Shield } from "lucide-react";
+
+import { CancelSubscriptionButton } from "@/app/billing/CancelSubscriptionButton";
+import { ChangePlanButton } from "@/app/billing/ChangePlanButton";
+import { type PlanTier } from "@/lib/contracts/plans";
+import { getEntitlementSummary } from "@/lib/server/entitlements";
+import {
+ getRazorpaySubscriptionSchedule,
+ listRazorpaySubscriptionInvoices,
+ type RazorpayInvoiceSummary,
+} from "@/lib/server/razorpay";
+import { getCurrentUser } from "@/lib/supabase/server";
+
+const MANAGEABLE_STATUSES = new Set([
+ "active",
+ "authenticated",
+ "past_due",
+ "trialing",
+]);
+
+type BillingContentProps = {
+ /** Optional flash notice (rendered above the plan card) for the full page. */
+ notice?: string | null;
+};
+
+/**
+ * The body of the Billing surface — current plan, subscription management, and
+ * payment history. Shared by the standalone `/billing` page and the `@modal`
+ * intercept so both render identical content.
+ */
+export async function BillingContent({
+ notice = null,
+}: BillingContentProps = {}) {
+ const user = await getCurrentUser();
+ if (!user) {
+ return (
+
+ Billing is available after authentication.
+
+ );
+ }
+
+ const summary = await getEntitlementSummary(user.id);
+ const subscription = summary.subscription;
+ const subscriptionId = subscription?.razorpay_subscription_id ?? null;
+ const planTier = isPaidPlanTier(subscription?.plan_tier)
+ ? subscription.plan_tier
+ : summary.planTier;
+ const isManageable =
+ Boolean(subscriptionId) &&
+ typeof subscription?.status === "string" &&
+ MANAGEABLE_STATUSES.has(subscription.status);
+ const renewalLabel = subscription?.current_period_end
+ ? formatDate(subscription.current_period_end)
+ : "the end of the current billing period";
+
+ const invoices = subscriptionId ? await safeListInvoices(subscriptionId) : [];
+ const hasScheduledChange =
+ subscriptionId && !subscription?.cancel_at_period_end
+ ? await safeHasScheduledChange(subscriptionId)
+ : false;
+
+ return (
+
+ {notice ? (
+
+ {notice}
+
+ ) : null}
+
+
+
+
+
+ Current plan
+
+
+
+
+
+
+
+
+
+
+ {isManageable && isPaidPlanTier(planTier) ? (
+
+
+
+
+ Manage subscription
+
+
+
+ {subscription?.cancel_at_period_end ? (
+
+ This subscription is scheduled to end on {renewalLabel}. To keep
+ using a paid plan after that, subscribe again from{" "}
+
+ pricing
+
+ .
+
+ ) : (
+
+
+ {hasScheduledChange ? (
+
+ A plan change is already scheduled for the end of your
+ current billing cycle. It will apply automatically on{" "}
+ {renewalLabel}.
+
+ ) : (
+ <>
+
+ {planTier === "pro"
+ ? "Upgrade to Studio for more seats, workspaces, and analyses. Takes effect immediately."
+ : "Switch to Pro. The change applies at the end of your current billing cycle."}
+
+ {planTier === "pro" ? (
+
+ ) : (
+
+ )}
+ >
+ )}
+
+
+
+
+
+
+ )}
+
+ ) : (
+
+
+
+
+ No active subscription
+
+
+
+ You are on the free plan. Choose Pro or Studio to unlock more
+ analyses, seats, and workspaces.
+
+
+
+ View paid plans
+
+
+ )}
+
+
+
+
+
+ Payment history
+
+
+ {invoices.length > 0 ? (
+
+ {invoices.map((invoice) => (
+
+
+ {invoice.issuedAt ? formatDate(invoice.issuedAt) : "—"}
+
+
+ {formatAmount(invoice.amount, invoice.currency)}
+
+
+ {titleCase(invoice.status)}
+
+ {invoice.shortUrl ? (
+
+ Download
+
+ ) : (
+ {"—"}
+ )}
+
+ ))}
+
+ ) : (
+ No invoices yet.
+ )}
+
+
+ );
+}
+
+async function safeListInvoices(
+ subscriptionId: string,
+): Promise {
+ try {
+ return await listRazorpaySubscriptionInvoices(subscriptionId);
+ } catch {
+ return [];
+ }
+}
+
+async function safeHasScheduledChange(subscriptionId: string): Promise {
+ try {
+ const schedule = await getRazorpaySubscriptionSchedule(subscriptionId);
+ return schedule.hasScheduledChanges;
+ } catch {
+ return false;
+ }
+}
+
+function isPaidPlanTier(
+ value: unknown,
+): value is Extract {
+ return value === "pro" || value === "studio";
+}
+
+function Detail({ label, value }: { label: string; value: string }) {
+ return (
+
+
+ {label}
+
+
+ {value}
+
+
+ );
+}
+
+function titleCase(value: string) {
+ return value
+ .split("_")
+ .map((part) => part.slice(0, 1).toUpperCase() + part.slice(1))
+ .join(" ");
+}
+
+function formatDate(value: string) {
+ return new Intl.DateTimeFormat("en", { dateStyle: "medium" }).format(
+ new Date(value),
+ );
+}
+
+function formatAmount(amountInPaise: number, currency: string) {
+ try {
+ return new Intl.NumberFormat("en-IN", {
+ currency,
+ style: "currency",
+ }).format(amountInPaise / 100);
+ } catch {
+ return `${(amountInPaise / 100).toFixed(2)} ${currency}`;
+ }
+}
diff --git a/components/dashboard/PlanSync.tsx b/components/dashboard/PlanSync.tsx
new file mode 100644
index 0000000..d40be7a
--- /dev/null
+++ b/components/dashboard/PlanSync.tsx
@@ -0,0 +1,92 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { useEffect } from "react";
+
+import { createSupabaseBrowserClient } from "@/lib/supabase/browser";
+
+type PlanSyncProps = {
+ /** Current user's id; when absent (signed out) no subscription is opened. */
+ userId?: string | null;
+};
+
+/**
+ * Keeps the rendered plan tier in sync with the database without a manual
+ * reload. Two complementary triggers, both calling router.refresh() (which
+ * re-runs the server components that read the plan from Supabase):
+ *
+ * 1. Supabase Realtime — fires when this user's `profiles` row (admin plan
+ * override) or `subscriptions` row (Razorpay lifecycle) changes while the
+ * tab is open. Respects RLS via the user's session. If Realtime isn't
+ * enabled for those tables the channel simply receives nothing.
+ * 2. Focus / visibility — covers the common case of returning to the tab after
+ * completing checkout or an admin change elsewhere, and is the fallback when
+ * Realtime is unavailable.
+ *
+ * Renders nothing.
+ */
+export function PlanSync({ userId }: PlanSyncProps) {
+ const router = useRouter();
+
+ useEffect(() => {
+ const refreshIfVisible = () => {
+ if (document.visibilityState === "visible") {
+ router.refresh();
+ }
+ };
+
+ window.addEventListener("focus", refreshIfVisible);
+ document.addEventListener("visibilitychange", refreshIfVisible);
+
+ return () => {
+ window.removeEventListener("focus", refreshIfVisible);
+ document.removeEventListener("visibilitychange", refreshIfVisible);
+ };
+ }, [router]);
+
+ useEffect(() => {
+ if (!userId) return;
+
+ const supabase = (() => {
+ try {
+ return createSupabaseBrowserClient();
+ } catch {
+ // Missing public Supabase config; focus-refresh still covers updates.
+ return null;
+ }
+ })();
+ if (!supabase) return;
+
+ const onChange = () => router.refresh();
+
+ const channel = supabase
+ .channel(`plan-sync:${userId}`)
+ .on(
+ "postgres_changes",
+ {
+ event: "*",
+ schema: "public",
+ table: "profiles",
+ filter: `id=eq.${userId}`,
+ },
+ onChange,
+ )
+ .on(
+ "postgres_changes",
+ {
+ event: "*",
+ schema: "public",
+ table: "subscriptions",
+ filter: `user_id=eq.${userId}`,
+ },
+ onChange,
+ )
+ .subscribe();
+
+ return () => {
+ void supabase.removeChannel(channel);
+ };
+ }, [router, userId]);
+
+ return null;
+}
diff --git a/components/dashboard/app-shell.tsx b/components/dashboard/app-shell.tsx
index 7c9d258..4cbf063 100644
--- a/components/dashboard/app-shell.tsx
+++ b/components/dashboard/app-shell.tsx
@@ -1,16 +1,23 @@
+"use client";
+
import {
Boxes,
CreditCard,
FolderKanban,
Gauge,
+ Lock,
+ PanelLeftClose,
+ PanelLeftOpen,
Plus,
Sparkles,
UserCircle,
} from "lucide-react";
import Link from "next/link";
-import type { ReactNode } from "react";
+import { useSyncExternalStore, type ReactNode } from "react";
import { SignOutButton } from "@/components/auth/sign-out-button";
+import { PlanSync } from "@/components/dashboard/PlanSync";
+import type { PlanTier } from "@/lib/contracts/plans";
import { cn } from "@/lib/utils";
type ProductNavKey = "analyze" | "dashboard" | "projects" | "workspaces";
@@ -19,6 +26,58 @@ type AppShellProps = {
active?: ProductNavKey;
children: ReactNode;
userEmail?: string | null;
+ /** Current user's id; enables live plan sync via Supabase Realtime. */
+ userId?: string | null;
+ /**
+ * Plan tier of the current user. Drives the plan badge and the lock badge on
+ * paid-only nav items (Dashboard / Projects / Workspaces). Defaults to "free".
+ */
+ planTier?: PlanTier;
+ /**
+ * When true the main area fills the viewport with no max-width / padding so a
+ * full-bleed surface (the Analyze studio) can own the space.
+ */
+ bleed?: boolean;
+};
+
+// Nav keys that require a paid plan; locked for free users.
+const PAID_NAV_KEYS = new Set([
+ "dashboard",
+ "projects",
+ "workspaces",
+]);
+
+// Remembers the collapsed sidebar preference across visits on this device.
+// Backed by an external store so reads stay hydration-safe (the server and the
+// first client render both see `false`) and a toggle re-renders every consumer.
+const COLLAPSE_KEY = "motioncode_sidebar_collapsed";
+
+const collapseListeners = new Set<() => void>();
+
+const collapseStore = {
+ subscribe(listener: () => void) {
+ collapseListeners.add(listener);
+ return () => collapseListeners.delete(listener);
+ },
+ getSnapshot() {
+ try {
+ return localStorage.getItem(COLLAPSE_KEY) === "1";
+ } catch {
+ return false;
+ }
+ },
+ getServerSnapshot() {
+ return false;
+ },
+ toggle() {
+ const next = !collapseStore.getSnapshot();
+ try {
+ localStorage.setItem(COLLAPSE_KEY, next ? "1" : "0");
+ } catch {
+ // Best-effort persistence; the UI still toggles for this session.
+ }
+ for (const listener of collapseListeners) listener();
+ },
};
const navItems = [
@@ -33,93 +92,275 @@ const utilityItems = [
{ href: "/billing", icon: CreditCard, label: "Billing" },
] as const;
-export function AppShell({ active = "dashboard", children, userEmail }: AppShellProps) {
+export function AppShell({
+ active = "dashboard",
+ children,
+ userEmail,
+ userId,
+ planTier = "free",
+ bleed = false,
+}: AppShellProps) {
+ const isFree = planTier === "free";
+ const planLabel = planTier.charAt(0).toUpperCase() + planTier.slice(1);
+ // Collapse only affects the lg layout; server + first client render see false.
+ const collapsed = useSyncExternalStore(
+ collapseStore.subscribe,
+ collapseStore.getSnapshot,
+ collapseStore.getServerSnapshot,
+ );
+ const toggleCollapsed = () => collapseStore.toggle();
+
return (
-
+
+
+
-
-
-
+ {/* Solid (un-blurred) surface: animating a backdrop-blur'd sidebar's width
+ re-rasterizes the blur every frame, which is what made collapse lag. */}
+
+
+
- </> MotionCode
+
</> {" "}
+
MotionCode
-
- workspace lab
+
+ {planLabel}
+
+ {collapsed ? (
+
+ ) : (
+
+ )}
+
+
+ Navigate
+
{navItems.map((item) => {
const Icon = item.icon;
const isActive = active === item.key;
+ const locked = isFree && PAID_NAV_KEYS.has(item.key);
return (
-
- {item.label}
+
+
+ {item.label}
+
+ {locked ? (
+
+ ) : null}
);
})}
-
+
+
+ Account
+
{utilityItems.map((item) => {
const Icon = item.icon;
+ return (
+
+
+
+ {item.label}
+
+
+ );
+ })}
+
+ {isFree ? (
+
+
+
Upgrade
+
+ ) : (
+
+
+
New
+
+ )}
+ {userEmail ? (
+
+ ) : null}
+
+
+
+ {/* Mobile-only utility row */}
+
+ {utilityItems.map((item) => {
+ const Icon = item.icon;
return (
-
{item.label}
);
})}
-
-
-
+ {isFree ? (
+
+
+
+ ) : (
+
+
+
+ )}
{userEmail ? (
) : null}
-
+
-
+
{children}
diff --git a/components/dashboard/login-form.tsx b/components/dashboard/login-form.tsx
index 0cf1d43..ad9b521 100644
--- a/components/dashboard/login-form.tsx
+++ b/components/dashboard/login-form.tsx
@@ -1,9 +1,13 @@
"use client";
import { Eye, EyeOff, Lock, Mail, Send } from "lucide-react";
-import type { FormEvent } from "react";
+import type { FormEvent, MutableRefObject } from "react";
import { useEffect, useState } from "react";
+import {
+ bumpParticleTypingImpulse,
+ pulseParticleSubmitImpulse,
+} from "@/components/auth/particle-field";
import {
DEFAULT_AUTH_NEXT_PATH,
buildAuthCallbackUrl,
@@ -24,6 +28,8 @@ type GoogleProviderState = "checking" | "enabled" | "disabled" | "unavailable";
type LoginFormProps = {
nextPath?: string;
+ /** When provided, typing and submitting drive the auth particle figure. */
+ typingImpulseRef?: MutableRefObject;
};
const GOOGLE_PROVIDER_DISABLED_MESSAGE =
@@ -33,6 +39,7 @@ const GOOGLE_PROVIDER_UNAVAILABLE_MESSAGE =
export function LoginForm({
nextPath = DEFAULT_AUTH_NEXT_PATH,
+ typingImpulseRef,
}: LoginFormProps) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
@@ -105,6 +112,7 @@ export function LoginForm({
async function handlePasswordSignIn(event: FormEvent) {
event.preventDefault();
+ if (typingImpulseRef) pulseParticleSubmitImpulse(typingImpulseRef);
setState("authenticating");
setMessage(null);
@@ -128,6 +136,7 @@ export function LoginForm({
}
async function handleMagicLink() {
+ if (typingImpulseRef) pulseParticleSubmitImpulse(typingImpulseRef);
setState("sending");
setMessage(null);
@@ -150,8 +159,15 @@ export function LoginForm({
setMessage("Magic link sent.");
}
+ const monoFont = { fontFamily: "var(--font-bridge-mono)" } as const;
+
return (
-
+
{
+ if (typingImpulseRef) bumpParticleTypingImpulse(typingImpulseRef, event);
+ }}
+ >
G
@@ -180,29 +197,34 @@ export function LoginForm({
{googleProviderMessage ? (
{googleProviderMessage}
) : null}