diff --git a/package-lock.json b/package-lock.json index 93514ba71..f2b1aa353 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25126,6 +25126,15 @@ "node": ">=10" } }, + "node_modules/lucide-react": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.7.0.tgz", + "integrity": "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/lucide-react-native": { "version": "0.546.0", "resolved": "https://registry.npmjs.org/lucide-react-native/-/lucide-react-native-0.546.0.tgz", @@ -35703,6 +35712,7 @@ "@tanstack/react-router": "^1.120.3", "@tanstack/react-start": "^1.120.3", "framer-motion": "^12.35.2", + "lucide-react": "^1.7.0", "react": "^19.1.4", "react-dom": "^19.1.4", "react-markdown": "^10.1.0", diff --git a/packages/website/package.json b/packages/website/package.json index f53f30022..6f78ac0c8 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -16,6 +16,7 @@ "@tanstack/react-router": "^1.120.3", "@tanstack/react-start": "^1.120.3", "framer-motion": "^12.35.2", + "lucide-react": "^1.7.0", "react": "^19.1.4", "react-dom": "^19.1.4", "react-markdown": "^10.1.0", diff --git a/packages/website/public/iphone-mockup-left.png b/packages/website/public/iphone-mockup-left.png new file mode 100644 index 000000000..8b18865b0 Binary files /dev/null and b/packages/website/public/iphone-mockup-left.png differ diff --git a/packages/website/public/phone-1.png b/packages/website/public/phone-1.png new file mode 100644 index 000000000..0aaaf74f2 Binary files /dev/null and b/packages/website/public/phone-1.png differ diff --git a/packages/website/public/phone-2.png b/packages/website/public/phone-2.png new file mode 100644 index 000000000..5bfdde1de Binary files /dev/null and b/packages/website/public/phone-2.png differ diff --git a/packages/website/public/phone-3.png b/packages/website/public/phone-3.png new file mode 100644 index 000000000..11ec117b6 Binary files /dev/null and b/packages/website/public/phone-3.png differ diff --git a/packages/website/src/components/hero-mockup.tsx b/packages/website/src/components/hero-mockup.tsx new file mode 100644 index 000000000..613f7f856 --- /dev/null +++ b/packages/website/src/components/hero-mockup.tsx @@ -0,0 +1,737 @@ +import * as React from "react"; +import { motion } from "framer-motion"; +import { + FolderGit2, + Paperclip, + ArrowUp, + X, + MessagesSquare, + Plus, + Settings, + Monitor, + GitCommitHorizontal, + ChevronDown, + ChevronRight, + ListChevronsUpDown, + Bot, + Check, + Loader, + SquareTerminal, + PanelLeft, + Ellipsis, + Mic, + GitPullRequest, + SquarePen, + Columns2, + Rows2, +} from "lucide-react"; +import { ClaudeIcon, CodexIcon, SourceControlIcon } from "~/components/mockup"; + +// ── Stagger timing ────────────────────────────────────── + +const D = { + shell: 0, + sidebar: 0.1, + titleBar: 0.15, + tabs: 0.25, + chat: 0.35, + diffPanel: 0.55, +}; + +function fade(delay: number) { + return { + initial: { opacity: 0, y: 8 } as const, + animate: { opacity: 1, y: 0 } as const, + transition: { duration: 0.5, delay, ease: "easeOut" as const }, + }; +} + +// ── Data ──────────────────────────────────────────────── + +type ChatItem = + | { type: "user"; text: string } + | { type: "text"; text: string; bold?: boolean } + | { type: "tool"; label: string; summary?: string; status: "running" | "done" }; + +const CHAT: ChatItem[] = [ + { type: "user", text: "Build me an internal dashboard for tracking customer returns" }, + { type: "text", text: "I'll break this down into planning and implementation.", bold: true }, + { type: "tool", label: "Run plan-technical", summary: "codex · plan-technical", status: "done" }, + { type: "tool", label: "Run plan-design", summary: "claude · plan-design", status: "done" }, + { type: "tool", label: "Wait for agents", summary: "plan-technical plan-design", status: "done" }, + { type: "text", text: "Got the plans. Spinning up Codex for implementation.", bold: true }, + { type: "tool", label: "Run implement", summary: "codex · 12 files changed", status: "done" }, + { type: "text", text: "Implementation done. Requesting review from Claude." }, + { type: "tool", label: "Run review", summary: "claude · no issues found", status: "running" }, + { type: "text", text: "All tasks complete. Dashboard is ready.", bold: true }, +]; + +// ── Sidebar data ──────────────────────────────────────── + +type SidebarWorkspace = { + name: string; + kind: "checkout" | "worktree"; + status: "running" | "done" | "idle" | "syncing"; + selected?: boolean; + diffStat?: { additions: number; deletions: number }; + pr?: { number: number; state: "open" | "merged" | "closed" }; +}; + +type SidebarProject = { + initial: string; + name: string; + workspaces: SidebarWorkspace[]; +}; + +const SIDEBAR_PROJECTS: SidebarProject[] = [ + { + initial: "A", + name: "acme/returns-app", + workspaces: [ + { name: "main", kind: "checkout", status: "syncing", selected: true, diffStat: { additions: 247, deletions: 15 } }, + { name: "feat/dashboard", kind: "worktree", status: "done", pr: { number: 142, state: "open" } }, + ], + }, + { + initial: "P", + name: "acme/payments", + workspaces: [ + { name: "main", kind: "checkout", status: "idle" }, + { name: "fix/stripe-webhook", kind: "worktree", status: "done", diffStat: { additions: 38, deletions: 4 }, pr: { number: 89, state: "merged" } }, + ], + }, + { + initial: "I", + name: "acme/infra", + workspaces: [ + { name: "main", kind: "checkout", status: "idle" }, + { name: "feat/k8s-autoscale", kind: "worktree", status: "syncing", diffStat: { additions: 91, deletions: 3 } }, + ], + }, + { + initial: "D", + name: "acme/design-system", + workspaces: [ + { name: "main", kind: "checkout", status: "idle" }, + ], + }, +]; + +// ── Tab data ───────────────────────────────────────────── + +type TabDef = { + name: string; + provider: "claude" | "codex" | "terminal"; + done: boolean; + active?: boolean; +}; + +const TABS: TabDef[] = [ + { name: "Orchestrator", provider: "claude", done: false, active: true }, + { name: "Implement", provider: "codex", done: true }, + { name: "Review", provider: "claude", done: true }, +]; + +// ── Diff data ────────────────────────────────────────── + +type DiffLineType = "add" | "remove" | "context" | "header"; +type DiffLine = { + type: DiffLineType; + ln: string | null; + content?: string; + tokens?: Array<{ text: string; cls: string }>; +}; + +const DIFF_LINES: DiffLine[] = [ + { type: "add", ln: "1", tokens: [{ text: "import", cls: "text-syn-keyword" }, { text: " { ", cls: "text-syn-punctuation" }, { text: "useState", cls: "text-syn-variable" }, { text: " } ", cls: "text-syn-punctuation" }, { text: "from", cls: "text-syn-keyword" }, { text: ' "react"', cls: "text-syn-string" }] }, + { type: "add", ln: "2", tokens: [{ text: "import", cls: "text-syn-keyword" }, { text: " { ", cls: "text-syn-punctuation" }, { text: "ReturnTable", cls: "text-syn-variable" }, { text: " } ", cls: "text-syn-punctuation" }, { text: "from", cls: "text-syn-keyword" }, { text: ' "./components"', cls: "text-syn-string" }] }, + { type: "add", ln: "3", tokens: [] }, + { type: "add", ln: "4", tokens: [{ text: "export", cls: "text-syn-keyword" }, { text: " function", cls: "text-syn-keyword" }, { text: " Dashboard", cls: "text-syn-function" }, { text: "() {", cls: "text-syn-punctuation" }] }, + { type: "add", ln: "5", tokens: [{ text: " const", cls: "text-syn-keyword" }, { text: " [returns, setReturns]", cls: "text-syn-variable" }, { text: " = ", cls: "text-syn-operator" }, { text: "useState", cls: "text-syn-function" }, { text: "([])", cls: "text-syn-punctuation" }] }, + { type: "add", ln: "6", tokens: [{ text: " const", cls: "text-syn-keyword" }, { text: " [filter, setFilter]", cls: "text-syn-variable" }, { text: " = ", cls: "text-syn-operator" }, { text: "useState", cls: "text-syn-function" }, { text: '("all")', cls: "text-syn-string" }] }, + { type: "add", ln: "7", tokens: [] }, + { type: "add", ln: "8", tokens: [{ text: " ", cls: "text-syn-punctuation" }, { text: "return", cls: "text-syn-keyword" }, { text: " (", cls: "text-syn-punctuation" }] }, + { type: "add", ln: "9", tokens: [{ text: " <", cls: "text-syn-punctuation" }, { text: "main", cls: "text-syn-tag" }, { text: " className", cls: "text-syn-property" }, { text: '="', cls: "text-syn-punctuation" }, { text: "min-h-screen p-8", cls: "text-syn-string" }, { text: '">', cls: "text-syn-punctuation" }] }, + { type: "add", ln: "10", tokens: [{ text: " <", cls: "text-syn-punctuation" }, { text: "h1", cls: "text-syn-tag" }, { text: ">Customer Returns", cls: "text-syn-punctuation" }] }, + { type: "add", ln: "11", tokens: [{ text: " <", cls: "text-syn-punctuation" }, { text: "FilterBar", cls: "text-syn-tag" }, { text: " value", cls: "text-syn-property" }, { text: "={", cls: "text-syn-punctuation" }, { text: "filter", cls: "text-syn-variable" }, { text: "}", cls: "text-syn-punctuation" }, { text: " onChange", cls: "text-syn-property" }, { text: "={", cls: "text-syn-punctuation" }, { text: "setFilter", cls: "text-syn-variable" }, { text: "} />", cls: "text-syn-punctuation" }] }, + { type: "add", ln: "12", tokens: [{ text: " <", cls: "text-syn-punctuation" }, { text: "ReturnTable", cls: "text-syn-tag" }, { text: " data", cls: "text-syn-property" }, { text: "={", cls: "text-syn-punctuation" }, { text: "returns", cls: "text-syn-variable" }, { text: "} />", cls: "text-syn-punctuation" }] }, + { type: "add", ln: "13", tokens: [{ text: " <", cls: "text-syn-punctuation" }, { text: "StatusChart", cls: "text-syn-tag" }, { text: " data", cls: "text-syn-property" }, { text: "={", cls: "text-syn-punctuation" }, { text: "returns", cls: "text-syn-variable" }, { text: "} />", cls: "text-syn-punctuation" }] }, + { type: "add", ln: "14", tokens: [{ text: " ", cls: "text-syn-punctuation" }] }, + { type: "add", ln: "15", tokens: [{ text: " )", cls: "text-syn-punctuation" }] }, + { type: "add", ln: "16", tokens: [{ text: "}", cls: "text-syn-punctuation" }] }, +]; + +// ── Implementation agent chat data ────────────────────── + +const IMPLEMENT_CHAT: ChatItem[] = [ + { type: "text", text: "Starting implementation of the returns dashboard.", bold: true }, + { type: "tool", label: "Edit file", summary: "src/pages/dashboard.tsx", status: "done" }, + { type: "tool", label: "Edit file", summary: "src/components/return-table.tsx", status: "done" }, + { type: "tool", label: "Edit file", summary: "src/components/filter-bar.tsx", status: "done" }, + { type: "tool", label: "Edit file", summary: "src/components/status-chart.tsx", status: "done" }, + { type: "tool", label: "Run command", summary: "npm run typecheck", status: "done" }, + { type: "text", text: "Typecheck passes. Adding API route and data fetching." }, + { type: "tool", label: "Edit file", summary: "src/api/returns.ts", status: "done" }, + { type: "tool", label: "Edit file", summary: "src/hooks/use-returns.ts", status: "done" }, + { type: "tool", label: "Run command", summary: "npm run typecheck", status: "running" }, +]; + +// ── Terminal log lines ───────────────────────────────── + +const TERMINAL_LINES = [ + { text: "$ npm run dev", cls: "text-mock-fg" }, + { text: "", cls: "" }, + { text: "> acme-returns@0.1.0 dev", cls: "text-mock-fg-muted" }, + { text: "> next dev --turbopack", cls: "text-mock-fg-muted" }, + { text: "", cls: "" }, + { text: " ▲ Next.js 15.3.1 (Turbopack)", cls: "text-mock-fg" }, + { text: " - Local: http://localhost:3000", cls: "text-mock-fg-muted" }, + { text: "", cls: "" }, + { text: " ✓ Starting...", cls: "text-mock-green" }, + { text: " ✓ Ready in 1.2s", cls: "text-mock-green" }, + { text: " ○ Compiling /dashboard ...", cls: "text-mock-fg-muted" }, + { text: " ✓ Compiled /dashboard in 340ms", cls: "text-mock-green" }, + { text: " ○ Compiling /api/returns ...", cls: "text-mock-fg-muted" }, + { text: " ✓ Compiled /api/returns in 120ms", cls: "text-mock-green" }, +]; + +// ── Sub-components ────────────────────────────────────── + +function TrafficLights() { + return ( +
+
+
+
+
+ ); +} + +function ProviderIcon({ provider, muted = false }: { provider: "claude" | "codex" | "terminal"; muted?: boolean }) { + const cls = muted ? "text-mock-fg-muted" : "text-mock-fg"; + if (provider === "terminal") return ; + return provider === "claude" + ? + : ; +} + +function TabBarAction({ icon: Icon }: { icon: React.ComponentType<{ size?: number; className?: string; strokeWidth?: number }> }) { + return ( +
+ +
+ ); +} + +function PaneTabBar({ tabs, focused = false }: { tabs: TabDef[]; focused?: boolean }) { + return ( +
+ {tabs.map((tab) => ( +
+ {tab.active && ( +
+ )} + +
+ +
+ + + {tab.name} + + + +
+ ))} +
+ + + + +
+
+ ); +} + +function Composer({ provider, model, focused = false }: { provider: "claude" | "codex"; model: string; focused?: boolean }) { + const Icon = provider === "claude" ? ClaudeIcon : CodexIcon; + return ( +
+
+
+ {focused && ( + + )} + + Message the agent, tag @files, or use /commands and /skills + +
+
+
+
+ +
+
+ + {model} + +
+
+
+
+ +
+
+ +
+
+
+
+
+ ); +} + +function ChatMessages({ items }: { items: ChatItem[] }) { + return ( +
+ {items.map((item, i) => { + if (item.type === "user") { + return ( +
+
+ {item.text} +
+
+ ); + } + if (item.type === "tool") { + const isDone = item.status === "done"; + return ( +
+
+ +
+ + {item.label} + + {item.summary && ( + + {item.summary} + + )} +
+ {isDone ? ( + + ) : ( + + )} +
+
+ ); + } + return ( +

+ {item.text} +

+ ); + })} +
+ ); +} + +function ChatArea() { + return ( +
+ + +
+ ); +} + +function ImplementPane() { + return ( +
+ + +
+ ); +} + +function TerminalPane() { + return ( +
+
+ {TERMINAL_LINES.map((line, i) => ( +
+ {line.text} +
+ ))} +
+
+ ); +} + +function ExplorerSidebar() { + return ( +
+ {/* Explorer header — h-12, Changes/Files tabs */} +
+
+
+ Changes +
+
+ Files +
+
+
+ + {/* Secondary header — h-9, Uncommitted dropdown + filter icons */} +
+
+ Uncommitted + +
+
+
+ +
+
+
+ + {/* File header — expanded */} +
+
+ + dashboard.tsx + src/pages + New +
+
+ +16 + -0 +
+
+ + {/* Diff lines */} +
+ {DIFF_LINES.map((line, i) => { + const isAdd = line.type === "add"; + const isRemove = line.type === "remove"; + const lineBg = isAdd ? "bg-mock-diff-add" : isRemove ? "bg-mock-diff-remove" : "bg-mock-surface1"; + const lineNumCls = isAdd ? "text-mock-green-400" : isRemove ? "text-mock-red" : "text-mock-fg-muted"; + + return ( +
+
+ {line.ln ?? ""} +
+ + {line.tokens?.map((tok, j) => ( + {tok.text} + ))} + +
+ ); + })} +
+ + {/* Collapsed file rows */} + {[ + { name: "return-table.tsx", dir: "src/components", added: 42, removed: 8 }, + { name: "filter-bar.tsx", dir: "src/components", added: 28, removed: 3 }, + { name: "status-chart.tsx", dir: "src/components", added: 19, removed: 0, isNew: true }, + { name: "returns.ts", dir: "src/api", added: 12, removed: 5 }, + { name: "index.tsx", dir: "src/pages", added: 6, removed: 2 }, + ].map((file) => ( +
+
+ + {file.name} + {file.dir} + {file.isNew && ( + New + )} +
+
+ +{file.added} + -{file.removed} +
+
+ ))} + +
+
+ ); +} + +// Snake traversal order matching the real app: [0, 1, 3, 5, 4, 2] (2-col × 3-row grid) +// DOT_SEQUENCE[sequenceIndex] = dotIndex, so sequenceIndex = indexOf(dotIndex) +const DOT_SEQUENCE = [0, 1, 3, 5, 4, 2] as const; +const SYNCED_LOADER_DURATION_MS = 950; +const DOT_COUNT = 6; +const STEP_MS = SYNCED_LOADER_DURATION_MS / DOT_COUNT; // 158.333…ms per step + +function SyncedLoader({ size = 11 }: { size?: number }) { + const gap = Math.max(1, Math.round(size * 0.12)); + const dotSize = Math.max(2, Math.floor((size - gap * 2) / 3)); + const gridW = dotSize * 2 + gap; + const gridH = dotSize * 3 + gap * 2; + + return ( +
+
+ {Array.from({ length: DOT_COUNT }).map((_, dotIndex) => { + const col = dotIndex % 2; + const row = Math.floor(dotIndex / 2); + // The snake sequence index for this dot: when is it the "head"? + const sequenceIndex = DOT_SEQUENCE.indexOf(dotIndex as (typeof DOT_SEQUENCE)[number]); + // Negative delay syncs the animation so this dot is the head at t=0 when sequenceIndex=0. + // At t=0, headIndex should be 0, meaning sequenceIndex=0 dot is the head. + // Each dot is shifted by sequenceIndex steps back in time. + const delayMs = -(sequenceIndex * STEP_MS); + return ( +
+ ); + })} +
+
+ ); +} + +function WorkspaceStatusDot({ status }: { status: SidebarWorkspace["status"] }) { + if (status === "running") return
; + if (status === "done") return
; + return null; +} + +function Sidebar() { + // Shared left edge: pl-3 (12px) for traffic lights, icons, footer dot + // Workspace indent: pl-7 (28px) + // PR badge aligns with workspace text: pl-[42px] + return ( +
+ {/* Traffic lights */} +
+ +
+ + {/* Sessions header */} +
+ + Sessions +
+ + {/* Project list */} +
+ {SIDEBAR_PROJECTS.map((project) => ( +
+ {/* Project row — icon aligns with traffic lights / sessions */} +
+
+ {project.initial} +
+ + {project.name} + +
+ + {/* Workspace rows — indented one level */} + {project.workspaces.map((workspace) => ( +
+
+
+ {workspace.status === "syncing" ? ( + + ) : ( + <> + {workspace.kind === "worktree" + ? + : + } + {workspace.status !== "idle" && ( +
+ +
+ )} + + )} +
+ + {workspace.name} + + {workspace.diffStat && ( +
+ +{workspace.diffStat.additions} + -{workspace.diffStat.deletions} +
+ )} +
+ {workspace.pr && ( +
+ + + #{workspace.pr.number} · {workspace.pr.state === "open" ? "Open" : workspace.pr.state === "merged" ? "Merged" : "Closed"} + +
+ )} +
+ ))} +
+ ))} +
+ + {/* Footer — dot aligns with traffic lights / icons */} +
+
+
+ MacBook Pro +
+
+
+ +
+
+ +
+
+
+
+ ); +} + +// ── Desktop Mockup ────────────────────────────────────── + +function DesktopMockup() { + const containerRef = React.useRef(null); + const [scale, setScale] = React.useState(1); + + React.useEffect(() => { + function updateScale() { + if (!containerRef.current) return; + const parentWidth = containerRef.current.parentElement?.clientWidth ?? 1200; + const designWidth = 1200; + setScale(Math.min(1, parentWidth / designWidth)); + } + updateScale(); + window.addEventListener("resize", updateScale); + return () => window.removeEventListener("resize", updateScale); + }, []); + + return ( +
+
+ {/* Top-level: left sidebar | center column | explorer sidebar — all full height */} +
+ {/* Left sidebar — full height */} + + + + + {/* Center column: title bar + split panes */} +
+ {/* Title bar — belongs to center column only */} + +
+
+ +
+
+ main + acme/returns-app +
+ +
+
+
+ +
+
+
+ + Commit +
+
+ +
+
+
+ + +247 + -15 +
+
+
+ + {/* Split panes */} +
+ {/* Left pane: all agent tabs + chat */} +
+ + + + + + +
+ + {/* Resize handle */} +
+ + {/* Right pane: terminal only */} + + + + +
+
+ + {/* Explorer sidebar — full height, pushes center column */} + + + +
+
+
+ ); +} + +// ── Export ─────────────────────────────────────────────── + +export function HeroMockup() { + return ; +} diff --git a/packages/website/src/components/landing-page.tsx b/packages/website/src/components/landing-page.tsx index f61da8164..9de68bc37 100644 --- a/packages/website/src/components/landing-page.tsx +++ b/packages/website/src/components/landing-page.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { motion, AnimatePresence } from "framer-motion"; +import { motion, AnimatePresence, useInView, useScroll, useTransform, useMotionValueEvent } from "framer-motion"; import { CursorFieldProvider } from "~/components/butterfly"; import { CommandDialog } from "~/components/command-dialog"; import { @@ -13,6 +13,9 @@ import { TerminalIcon, GlobeIcon, } from "~/downloads"; +import { Mic } from "lucide-react"; +import { HeroMockup } from "~/components/hero-mockup"; +import { ClaudeIcon } from "~/components/mockup"; import "~/styles.css"; interface LandingPageProps { @@ -25,10 +28,8 @@ export function LandingPage({ title, subtitle }: LandingPageProps) { {/* Hero section with background image */}
-
-
-
+
-
+
diff --git a/packages/website/src/routes/index.tsx b/packages/website/src/routes/index.tsx index afbb061dc..73572272f 100644 --- a/packages/website/src/routes/index.tsx +++ b/packages/website/src/routes/index.tsx @@ -15,12 +15,7 @@ export const Route = createFileRoute("/")({ function Home() { return ( - All your coding agents, -
from anywhere - - } + title={<>The development environment
built for coding agents} subtitle="Run any coding agent from your phone, desktop, or terminal. Self-hosted, multi-provider, open source." /> ); diff --git a/packages/website/src/styles.css b/packages/website/src/styles.css index 2e6a1a831..b518feaa2 100644 --- a/packages/website/src/styles.css +++ b/packages/website/src/styles.css @@ -1,12 +1,17 @@ @import "tailwindcss"; +@keyframes voice-bar { + 0% { transform: scaleY(1); } + 100% { transform: scaleY(0.3); } +} + /* Title font - centralized for easy changes */ .font-title { font-family: inherit; } -/* Inline code styling - applies to code elements not inside pre blocks */ -:not(pre) > code { +/* Inline code styling - scoped to prose content */ +.prose :not(pre) > code { background-color: var(--color-muted); padding: 0.125rem 0.375rem; border-radius: 0.25rem; @@ -87,10 +92,57 @@ --color-secondary: #27272a; --color-secondary-foreground: #fafafa; + /* Mockup palette (from app unistyles theme) */ + --color-mock-surface0: #18181c; + --color-mock-surface1: #1f1f23; + --color-mock-surface2: #27272a; + --color-mock-surface3: #3f3f46; + --color-mock-sidebar: #121216; + --color-mock-fg: #fafafa; + --color-mock-fg-muted: #a1a1aa; + --color-mock-border: #27272a; + --color-mock-border-accent: #34343a; + --color-mock-accent: #20744A; + --color-mock-green: #22c55e; + --color-mock-green-400: #4ade80; + --color-mock-red: #ef4444; + --color-mock-amber: #f59e0b; + --color-mock-blue: #3b82f6; + --color-mock-zinc500: #71717a; + --color-mock-zinc600: #52525b; + --color-mock-diff-add: rgba(46, 160, 67, 0.15); + --color-mock-diff-remove: rgba(248, 81, 73, 0.1); + + /* Syntax highlighting */ + --color-syn-keyword: #ff7b72; + --color-syn-comment: #8b949e; + --color-syn-string: #a5d6ff; + --color-syn-number: #79c0ff; + --color-syn-function: #d2a8ff; + --color-syn-tag: #7ee787; + --color-syn-property: #79c0ff; + --color-syn-variable: #c9d1d9; + --color-syn-punctuation: #c9d1d9; + --color-syn-operator: #79c0ff; + --animate-flutter-left: flutter-left 0.6s ease-in-out infinite; --animate-flutter-right: flutter-right 0.6s ease-in-out infinite; --animate-float: float 8s ease-in-out infinite; --animate-flutter-body: flutter-body 0.5s ease-in-out infinite; + --animate-synced-snake-dot: synced-snake-dot 950ms linear infinite; +} + +/* Synced loader — 6-dot snake animation matching the real app. + Each dot runs this keyframe with a staggered delay so that at any moment + up to 4 dots are visible at decreasing opacity (head=1, trail=0.72/0.46/0.22). + steps(1,end) keeps each opacity level solid for its full 1/6-cycle slot. */ +@keyframes synced-snake-dot { + 0% { opacity: 1; animation-timing-function: steps(1, end); } + 16.667% { opacity: 0.72; animation-timing-function: steps(1, end); } + 33.333% { opacity: 0.46; animation-timing-function: steps(1, end); } + 50% { opacity: 0.22; animation-timing-function: steps(1, end); } + 66.667% { opacity: 0; animation-timing-function: steps(1, end); } + 83.333% { opacity: 0; animation-timing-function: steps(1, end); } } @keyframes flutter-left {