Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,20 @@ The default agent for this repo is agents/DEFAULT.AGENT.md
^ Read this file on session start

Testing strategy: agents/testing-strategy.md

## Player scenario concurrency rule

When driving **Studio Player** (Electron app) via UI automation, WebSocket messages, or CLI:

- Never start multiple scenarios concurrently in a single player instance.
- Always serialize scenario commands (`load`, `runAll`, `runStep`, `reset`, `cancel`).
- Do not issue a second `runAll`/`runStep` while a run is in progress; wait for completion or send `cancel` and wait for cancellation acknowledgement before starting another run.
- If you implement new automation, ensure it cannot trigger overlapping executions (race conditions between `load` and `runAll`, double-clicks, reconnect retries, etc.).

## Scenario debugging workflow rule

When validating “all scenarios run without errors” and you find a failure:

- First reproduce by playing **only the failing scenario** (do not rerun the full suite yet).
- Fix the scenario (or the underlying library/app bug), then rerun **only that scenario** until it passes reliably.
- Only after the single-scenario run is stable, rerun the **all-scenarios** suite again.
7 changes: 4 additions & 3 deletions apps/demo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,20 @@
"@automerge/automerge-repo-network-websocket": "^2.5.1",
"@automerge/automerge-repo-sync-server": "^0.2.8",
"@automerge/react": "^2.5.1",
"@xterm/addon-fit": "^0.10.0",
"@xyflow/react": "^12.10.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"embla-carousel-react": "^8.6.0",
"framer-motion": "^11.18.0",
"lucide-react": "^0.469.0",
"motion": "^12.34.3",
"radix-ui": "^1.4.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.0",
"tailwind-merge": "^2.6.0",
"xterm": "^5.3.0",
"@xterm/addon-fit": "^0.10.0"
"xterm": "^5.3.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
Expand All @@ -38,4 +40,3 @@
"vite-plugin-wasm": "^3.5.0"
}
}

58 changes: 38 additions & 20 deletions apps/demo/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Suspense, useMemo } from "react";
import { Routes, Route, Navigate, useLocation, useNavigate } from "react-router-dom";
import { Routes, Route, Navigate, useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { AnimatePresence, motion } from "framer-motion";
import { LayoutDashboard, ListTodo, TerminalSquare, Columns3, MessageCircle, CalendarDays } from "lucide-react";
import { Button } from "@/components/ui/button";
Expand All @@ -10,6 +10,11 @@ import TerminalsPage from "@/pages/terminals";
import KanbanPage from "@/pages/kanban";
import ChatPage from "@/pages/chat";
import CalendarPage from "@/pages/calendar";
import MoviePage from "@/pages/movie";
import WikiPage from "@/pages/wiki";
import SlidesPage from "@/pages/slides";
import IPhoneChrome from "@/components/iphone-chrome";
import PixelChrome from "@/components/pixel-chrome";
import { RepoContext } from "@/lib/use-automerge";
import { createRepo } from "@/lib/use-automerge";

Expand Down Expand Up @@ -51,23 +56,44 @@ function NavMenu({ onNavigate }: { onNavigate: (path: string) => void }) {
);
}

function AppLayout({ children }: { children: React.ReactNode }) {
function PlainLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen flex-col">
<main className="flex-1 overflow-y-auto">
{children}
</main>
<main className="flex-1 overflow-y-auto">{children}</main>
</div>
);
}

function DeviceLayout({ role, children }: { role: string | null; children: React.ReactNode }) {
if (role === "veronica") return <IPhoneChrome>{children}</IPhoneChrome>;
if (role === "bob") return <PixelChrome>{children}</PixelChrome>;
return <PlainLayout>{children}</PlainLayout>;
}

function PageRoutes() {
const location = useLocation();
return (
<Routes location={location}>
<Route path="/" element={<AppPage />} />
<Route path="/notes" element={<NotesPage />} />
<Route path="/chat" element={<ChatPage />} />
<Route path="/calendar" element={<CalendarPage />} />
<Route path="/terminals" element={<TerminalsPage />} />
<Route path="/kanban" element={<KanbanPage />} />
<Route path="/movie" element={<MoviePage />} />
<Route path="/wiki" element={<WikiPage />} />
<Route path="/slides" element={<SlidesPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}

export default function App() {
const location = useLocation();
const wsUrl = useMemo(() => {
const params = new URLSearchParams(location.search);
return params.get("ws") ?? undefined;
}, [location.search]);
const [params] = useSearchParams();
const role = params.get("role");

const wsUrl = useMemo(() => params.get("ws") ?? undefined, [params]);
const repo = useMemo(() => createRepo({ wsUrl }), [wsUrl]);

return (
Expand All @@ -81,17 +107,9 @@ export default function App() {
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<AppLayout>
<Routes location={location}>
<Route path="/" element={<AppPage />} />
<Route path="/notes" element={<NotesPage />} />
<Route path="/chat" element={<ChatPage />} />
<Route path="/calendar" element={<CalendarPage />} />
<Route path="/terminals" element={<TerminalsPage />} />
<Route path="/kanban" element={<KanbanPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</AppLayout>
<DeviceLayout role={role}>
<PageRoutes />
</DeviceLayout>
</motion.div>
</AnimatePresence>
</Suspense>
Expand Down
118 changes: 118 additions & 0 deletions apps/demo/src/components/animate-ui/stars-background.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
'use client';

import * as React from 'react';
import {
type HTMLMotionProps,
motion,
useMotionValue,
useSpring,
type SpringOptions,
type Transition,
} from 'motion/react';

import { cn } from '@/lib/utils';

type StarLayerProps = HTMLMotionProps<'div'> & {
count: number;
size: number;
transition: Transition;
starColor: string;
};

function generateStars(count: number, starColor: string) {
const shadows: string[] = [];
for (let i = 0; i < count; i++) {
const x = Math.floor(Math.random() * 4000) - 2000;
const y = Math.floor(Math.random() * 4000) - 2000;
shadows.push(`${x}px ${y}px ${starColor}`);
}
return shadows.join(', ');
}

function StarLayer({
count = 1000,
size = 1,
transition = { repeat: Infinity, duration: 50, ease: 'linear' },
starColor = '#fff',
className,
...props
}: StarLayerProps) {
const [boxShadow, setBoxShadow] = React.useState('');

React.useEffect(() => {
setBoxShadow(generateStars(count, starColor));
}, [count, starColor]);

return (
<motion.div
className={cn('absolute rounded-full bg-transparent', className)}
style={{
width: size,
height: size,
boxShadow,
}}
animate={{ y: [0, -2000] }}
transition={transition}
{...props}
/>
);
}

type StarsBackgroundProps = React.ComponentProps<'div'> & {
factor?: number;
speed?: number;
transition?: SpringOptions;
starColor?: string;
pointerEvents?: boolean;
};

function StarsBackground({
children,
className,
factor = 0.05,
speed = 50,
transition = { stiffness: 50, damping: 20 },
starColor = '#fff',
pointerEvents = true,
...props
}: StarsBackgroundProps) {
const offsetX = useMotionValue(1);
const offsetY = useMotionValue(1);

const springX = useSpring(offsetX, transition);
const springY = useSpring(offsetY, transition);

const handleMouseMove = React.useCallback(
(e: React.MouseEvent) => {
const centerX = window.innerWidth / 2;
const centerY = window.innerHeight / 2;
const newOffsetX = -(e.clientX - centerX) * factor;
const newOffsetY = -(e.clientY - centerY) * factor;
offsetX.set(newOffsetX);
offsetY.set(newOffsetY);
},
[offsetX, offsetY, factor],
);

return (
<div
className={cn('relative overflow-hidden bg-black', className)}
onMouseMove={pointerEvents ? handleMouseMove : undefined}
{...props}
>
<motion.div className="absolute inset-0" style={{ x: springX, y: springY }}>
<StarLayer count={700} size={1} transition={{ repeat: Infinity, duration: speed, ease: 'linear' }} starColor={starColor} />
<StarLayer count={200} size={2} transition={{ repeat: Infinity, duration: speed * 2, ease: 'linear' }} starColor={starColor} />
<StarLayer count={100} size={3} transition={{ repeat: Infinity, duration: speed * 3, ease: 'linear' }} starColor={starColor} />
</motion.div>
{children}
</div>
);
}

export {
StarLayer,
StarsBackground,
type StarLayerProps,
type StarsBackgroundProps,
};
122 changes: 122 additions & 0 deletions apps/demo/src/components/iphone-chrome.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/**
* @description iPhone / iOS-style device chrome.
* Wraps page content with a status bar (Dynamic Island), dock, and home indicator.
*/
import { useCallback, type ReactNode } from "react";
import { useNavigate, useLocation, useSearchParams } from "react-router-dom";
import {
Phone,
Globe,
MessageCircle,
Camera,
Music,
Map,
Settings,
Wifi,
BatteryMedium,
Signal,
} from "lucide-react";

const DOCK_APPS = [
{ id: "phone", icon: Phone, label: "Phone", color: "from-green-400 to-green-600" },
{ id: "safari", icon: Globe, label: "Safari", color: "from-sky-400 to-blue-600" },
{ id: "messages", icon: MessageCircle, label: "Messages", color: "from-green-400 to-emerald-600", testId: "dock-messages" },
{ id: "camera", icon: Camera, label: "Camera", color: "from-zinc-500 to-zinc-700" },
] as const;

interface Props {
children: ReactNode;
onMessengerClick?: () => void;
}

function StatusBar() {
const now = new Date();
const time = now.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });

return (
<div className="flex items-center justify-between px-5 pt-1 pb-0.5 bg-zinc-950 shrink-0">
<span className="text-[13px] font-semibold text-white w-14">{time}</span>

{/* Dynamic Island */}
<div className="w-24 h-[22px] rounded-full bg-black border border-zinc-800" />

<div className="flex items-center gap-1 w-14 justify-end">
<Signal className="h-3 w-3 text-white" />
<Wifi className="h-3 w-3 text-white" />
<BatteryMedium className="h-3.5 w-3.5 text-white" />
</div>
</div>
);
}

export default function IPhoneChrome({ children, onMessengerClick }: Props) {
const navigate = useNavigate();
const location = useLocation();
const [params] = useSearchParams();
const role = params.get("role") ?? "veronica";
const ws = params.get("ws") ?? "";

const handleDockClick = useCallback(
(appId: string) => {
if (appId === "messages") {
if (onMessengerClick) {
onMessengerClick();
} else {
navigate(`/chat?role=${encodeURIComponent(role)}&ws=${encodeURIComponent(ws)}`);
}
}
},
[navigate, role, ws, onMessengerClick],
);

const isMessengerActive = location.pathname === "/chat";

return (
<div className="flex flex-col h-screen bg-zinc-950 overflow-hidden">
<div className="select-none">
<StatusBar />
</div>

{/* Content */}
<div className="flex-1 overflow-hidden">{children}</div>

{/* Dock */}
<div className="shrink-0 flex justify-center pb-0.5 pt-1 bg-zinc-950/80 backdrop-blur-xl select-none">
<div className="flex items-end gap-4 px-5 py-1.5">
{DOCK_APPS.map((app) => {
const Icon = app.icon;
const isActive = app.id === "messages" && isMessengerActive;
return (
<button
key={app.id}
onClick={() => handleDockClick(app.id)}
data-testid={app.testId}
title={app.label}
className="group relative flex flex-col items-center"
>
<div
className={`
w-12 h-12 rounded-[13px] bg-gradient-to-br ${app.color}
flex items-center justify-center
transition-transform duration-150 group-hover:scale-105
shadow-lg
`}
>
<Icon className="h-6 w-6 text-white drop-shadow" />
</div>
{isActive && (
<div className="w-1 h-1 rounded-full bg-white/70 mt-1" />
)}
</button>
);
})}
</div>
</div>

{/* Home indicator */}
<div className="flex justify-center pb-1.5 pt-0.5 bg-zinc-950 select-none">
<div className="w-32 h-1 rounded-full bg-zinc-600" />
</div>
</div>
);
}
Loading
Loading