feat(website): rebuild homepage product mockups

Replace static captures with live, responsive scenes that demonstrate building, reviewing, shipping, and extending work across desktop and mobile.
This commit is contained in:
Mohamed Boudra
2026-08-30 23:48:54 +02:00
parent adf80a9fe0
commit 375e9b3e53
23 changed files with 3254 additions and 94 deletions
+11
View File
@@ -0,0 +1,11 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_178_488)">
<path d="M4.50391 0.900391L12.5537 0.958008L12.751 0.969727C13.7276 1.07414 14.515 1.88867 14.5244 2.89648L14.6201 12.0547C14.6256 12.582 14.4168 13.0759 14.042 13.4414L14.043 13.4424C13.6798 13.7974 13.1987 13.9929 12.6914 13.998C12.6218 14.2376 12.4926 14.4566 12.3135 14.6367C12.0251 14.9267 11.6376 15.084 11.2324 15.084H11.2207L3.02637 15.0254H3.02539C2.19993 15.0189 1.50689 14.3557 1.49805 13.5195L1.40039 4.2041C1.3964 3.80142 1.55224 3.41827 1.83691 3.13281C2.03404 2.93517 2.27743 2.80079 2.54102 2.73438C2.56372 2.25029 2.76513 1.79842 3.11426 1.45703V1.45605C3.50319 1.07657 4.00506 0.923809 4.46973 0.900391L4.48633 0.899414L4.50391 0.900391ZM5.17285 11.4004L12.0674 11.4697L11.9844 3.49707L5.10645 3.42871L5.17285 11.4004Z" fill="#1D1D1B" stroke="white" stroke-width="1.2"/>
<rect x="5" y="3.5" width="7" height="8" fill="white"/>
</g>
<defs>
<clipPath id="clip0_178_488">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+90 -10
View File
@@ -1,15 +1,95 @@
import { motion } from "framer-motion";
import { useCallback, useState } from "react";
import {
DEFAULT_MOCKUP_STATE,
DESIGN_HEIGHT,
DESIGN_WIDTH,
MOCKUP_STATES,
type MockupStateId,
MockupWindow,
} from "~/components/mockup";
const ALT =
"Paseo desktop app with coding agents, a conversation, and a code diff open side by side";
const ASPECT_STYLE = { aspectRatio: `${DESIGN_WIDTH} / ${DESIGN_HEIGHT}` };
// `tan(atan2(a, b))` is the CSS way to divide one length by another, so the scale
// factor tracks the container with no JS, no layout thrash, and a correct
// server-rendered first paint.
const SCALE_STYLE = {
width: DESIGN_WIDTH,
height: DESIGN_HEIGHT,
transform: `scale(tan(atan2(100cqw, ${DESIGN_WIDTH}px)))`,
};
const PILL_TRANSITION = { duration: 0.34, ease: [0.22, 0.61, 0.36, 1] as const };
export function HeroMockup() {
const [state, setState] = useState<MockupStateId>(DEFAULT_MOCKUP_STATE);
return (
<div className="overflow-hidden rounded-xl bg-mock-surface0 p-2 ring-1 ring-white/10 sm:rounded-2xl sm:p-3">
<img
src="/homepage-hero.png"
alt="Paseo desktop app with coding agents, a conversation, and a code diff open side by side"
width={2400}
height={1442}
fetchPriority="high"
decoding="async"
className="block h-auto w-full rounded-lg sm:rounded-xl"
/>
<div>
<div className="mb-4 flex flex-wrap items-center justify-center gap-1 sm:gap-2">
{MOCKUP_STATES.map((option) => (
<StatePill
key={option.id}
id={option.id}
label={option.label}
selected={option.id === state}
onSelect={setState}
/>
))}
</div>
<div className="overflow-hidden rounded-xl ring-1 ring-white/10 sm:rounded-2xl">
{/* The window is authored at DESIGN_WIDTH and scaled to fit the hero column. */}
<div className="w-full [container-type:inline-size]">
<div
className="relative w-full overflow-hidden rounded-xl sm:rounded-2xl"
style={ASPECT_STYLE}
role="img"
aria-label={ALT}
>
<div className="absolute top-0 left-0 origin-top-left" style={SCALE_STYLE}>
<MockupWindow state={state} />
</div>
</div>
</div>
</div>
</div>
);
}
function StatePill({
id,
label,
selected,
onSelect,
}: {
id: MockupStateId;
label: string;
selected: boolean;
onSelect: (id: MockupStateId) => void;
}) {
const select = useCallback(() => onSelect(id), [onSelect, id]);
return (
<button
type="button"
onClick={select}
aria-pressed={selected}
className={`relative cursor-pointer rounded-full px-2.5 py-1.5 text-xs transition-colors sm:px-3.5 sm:text-sm ${
selected ? "text-foreground" : "text-muted-foreground hover:text-foreground"
}`}
>
{selected ? (
<motion.span
layoutId="hero-mockup-pill"
transition={PILL_TRANSITION}
className="absolute inset-0 rounded-full bg-white/8 ring-1 ring-white/12 ring-inset"
/>
) : null}
<span className="relative">{label}</span>
</button>
);
}
@@ -27,20 +27,23 @@ const DURATION_05: Transition = { duration: 0.5 };
const VIEWPORT_60 = { once: true, margin: "-60px" };
const SVG_OVERFLOW_VISIBLE_STYLE = { overflow: "visible" as const };
const PHONE_PERSPECTIVE_STYLE = { minHeight: 480, perspective: 1200 };
// A ~240px-wide phone rotated 15° only foreshortens a couple percent at
// perspective 1200 — it reads as a flat, skewed card. The side phones already
// sit on a correctly projecting plane (the frame and its scaled interior share
// one flattened texture), so the interior just needs the projection to be
// strong enough to see: a tighter perspective gives the trio a real book-fold.
const PHONE_PERSPECTIVE_STYLE = { minHeight: 480, perspective: 700 };
import { CursorFieldProvider } from "~/components/butterfly";
import { CommandDialog } from "~/components/command-dialog";
import { AGENT_PAGES } from "~/data/agent-pages";
import {
appStoreUrl,
playStoreUrl,
webAppUrl,
getDownloadOptions,
useDetectedPlatform,
AppleIcon,
PlayStoreIcon,
TerminalIcon,
GlobeIcon,
} from "~/downloads";
import { useRelease } from "~/routes/__root";
import { HeroMockup } from "~/components/hero-mockup";
@@ -52,7 +55,7 @@ import {
PiIcon,
} from "~/components/agent-icons";
import { DiscordIcon, GitHubIcon, SlackIcon } from "~/components/brand-icons";
import { ClaudeIcon } from "~/components/mockup";
import { ClaudeIcon, MobileChat, MobileDiff, MobileSidebar, PhoneFrame } from "~/components/mockup";
import { FAQItem } from "~/components/faq-item";
import { SiteFooter } from "~/components/site-footer";
import { SiteHeader } from "~/components/site-header";
@@ -68,7 +71,7 @@ export function LandingPage({ title, subtitle }: LandingPageProps) {
<CursorFieldProvider>
{/* Hero section with background image */}
<div className="relative bg-cover bg-center bg-no-repeat">
<div className="relative p-6 pb-10 md:px-32 md:pt-20 md:pb-12 max-w-7xl mx-auto">
<div className="relative px-6 pt-4 pb-10 md:px-32 md:pt-6 md:pb-12 max-w-7xl mx-auto">
<Nav />
<Hero title={title} subtitle={subtitle} />
<GetStarted />
@@ -79,7 +82,7 @@ export function LandingPage({ title, subtitle }: LandingPageProps) {
initial={FADE_IN_UP_40}
animate={FADE_IN}
transition={EASE_OUT_08_DELAY_05}
className="relative px-6 md:px-8 pb-8 md:pb-16"
className="relative px-6 md:px-8 pt-4 md:pt-8 pb-8 md:pb-16"
>
<div className="max-w-7xl mx-auto">
<HeroMockup />
@@ -112,7 +115,7 @@ export function LandingPage({ title, subtitle }: LandingPageProps) {
function Nav() {
return (
<nav className="mb-16">
<nav className="mb-20 md:mb-24">
<SiteHeader />
</nav>
);
@@ -120,9 +123,9 @@ function Nav() {
function Hero({ title, subtitle }: { title: React.ReactNode; subtitle: string }) {
return (
<div className="space-y-6">
<h1 className="text-3xl md:text-5xl font-medium tracking-tight">{title}</h1>
<p className="text-white/70 text-lg leading-relaxed max-w-lg">{subtitle}</p>
<div className="space-y-6 text-center">
<h1 className="text-4xl md:text-6xl font-medium tracking-tight leading-[0.95]">{title}</h1>
<p className="text-white/70 text-lg leading-relaxed max-w-lg mx-auto">{subtitle}</p>
</div>
);
}
@@ -861,22 +864,13 @@ function ShipPanel() {
function GetStarted() {
return (
<div className="pt-10">
<div className="flex flex-row flex-wrap gap-3">
<div className="flex flex-row flex-wrap justify-center gap-3">
<DownloadButton />
<a
href={webAppUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center gap-2 rounded-lg border border-white/20 px-4 py-2 text-sm font-medium text-white hover:bg-white/10 transition-colors"
>
<GlobeIcon className="h-4 w-4" />
Web App
</a>
<a
href={appStoreUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center rounded-lg border border-white/20 px-3 py-2 text-white hover:bg-white/10 transition-colors"
className="inline-flex items-center justify-center rounded-lg border border-white/12 px-3 py-2 text-white hover:bg-white/10 transition-colors"
aria-label="App Store"
>
<AppleIcon className="h-5 w-5" />
@@ -885,22 +879,14 @@ function GetStarted() {
href={playStoreUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center rounded-lg border border-white/20 px-3 py-2 text-white hover:bg-white/10 transition-colors"
className="inline-flex items-center justify-center rounded-lg border border-white/12 px-3 py-2 text-white hover:bg-white/10 transition-colors"
aria-label="Google Play"
>
<PlayStoreIcon className="h-5 w-5" />
</a>
<ServerInstallButton />
</div>
<div className="pt-3">
<a
href="/download"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
All download options
</a>
</div>
<div className="flex items-center gap-2 pt-6">
<div className="flex items-center justify-center gap-2 pt-6">
<span className="text-xs text-muted-foreground">Supports</span>
<div className="flex items-center gap-1">
<AgentBadge name="Claude Code" icon={CLAUDE_CODE_BADGE_ICON} />
@@ -940,7 +926,7 @@ function DownloadButton() {
}
const SERVER_INSTALL_TRIGGER = (
<span className="inline-flex items-center justify-center rounded-lg border border-white/20 px-3 py-2 text-white hover:bg-white/10 transition-colors">
<span className="inline-flex items-center justify-center rounded-lg border border-white/12 px-3 py-2 text-white hover:bg-white/10 transition-colors">
<TerminalIcon className="h-5 w-5" />
</span>
);
@@ -1245,54 +1231,42 @@ function PhoneShowcase() {
className="relative flex items-center justify-center overflow-x-clip w-full"
style={PHONE_PERSPECTIVE_STYLE}
>
{/* Left phone — rotated to face inward */}
<motion.div style={leftPhoneStyle} className="w-[160px] md:w-[240px] absolute">
<img
src="/phone-1-480.webp"
srcSet="/phone-1-320.webp 320w, /phone-1-480.webp 480w"
sizes="(min-width: 768px) 240px, 160px"
alt="Paseo sessions list"
width={480}
height={1044}
loading="lazy"
decoding="async"
className="w-full rounded-[40px] shadow-2xl border-[3px] border-black outline-[3px] outline-white/20"
/>
{/* Left phone — workspace drawer, rotated to face inward */}
<motion.div
style={leftPhoneStyle}
className="w-[160px] md:w-[240px] absolute"
role="img"
aria-label="Paseo workspace drawer"
>
<PhoneFrame time="18:54">
<MobileSidebar />
</PhoneFrame>
</motion.div>
{/* Center phone */}
{/* Center phone — agent chat */}
<motion.div
initial={FADE_IN_UP_XL}
animate={centerPhoneAnimate}
transition={EASE_OUT_06_DELAY_01}
className="w-[220px] md:w-[240px] relative z-10"
role="img"
aria-label="Paseo agent chat"
>
<img
src="/phone-2-480.webp"
srcSet="/phone-2-320.webp 320w, /phone-2-480.webp 480w"
sizes="(min-width: 768px) 240px, 220px"
alt="Paseo agent chat"
width={480}
height={1044}
loading="lazy"
decoding="async"
className="w-full rounded-[40px] shadow-2xl border-[3px] border-black outline-[3px] outline-white/20"
/>
<PhoneFrame time="18:53">
<MobileChat />
</PhoneFrame>
</motion.div>
{/* Right phone — rotated to face inward */}
<motion.div style={rightPhoneStyle} className="w-[160px] md:w-[240px] absolute">
<img
src="/phone-3-480.webp"
srcSet="/phone-3-320.webp 320w, /phone-3-480.webp 480w"
sizes="(min-width: 768px) 240px, 160px"
alt="Paseo diff view"
width={480}
height={1044}
loading="lazy"
decoding="async"
className="w-full rounded-[40px] shadow-2xl border-[3px] border-black outline-[3px] outline-white/20"
/>
{/* Right phone — diff view, rotated to face inward */}
<motion.div
style={rightPhoneStyle}
className="w-[160px] md:w-[240px] absolute"
role="img"
aria-label="Paseo diff view"
>
<PhoneFrame time="18:55">
<MobileDiff />
</PhoneFrame>
</motion.div>
</div>
</div>
@@ -0,0 +1,201 @@
// Shared bits of the Paseo UI, drawn small. Everything here is presentational —
// no state, no interactivity. Colors come from the `--color-mock-*` tokens in
// styles.css, which are copied from the app's default dark theme.
import type * as React from "react";
/**
* The `+12 −18` footnote. A diff stat is a *status* signal, not a diff, so it
* uses statusSuccess/statusDanger — see the comment above `lightDiffColors` in
* packages/app/src/styles/theme.ts.
*/
export function DiffStat({ add, remove }: { add: string; remove: string }) {
return (
<span className="flex shrink-0 items-center gap-[4px] text-[11px] tabular-nums">
<span className="text-mock-success">+{add}</span>
<span className="text-mock-danger">-{remove}</span>
</span>
);
}
export type DotTone = "success" | "danger" | "warning" | "running" | "idle";
const DOT_TONE: Record<DotTone, string> = {
success: "bg-mock-dot-success",
danger: "bg-mock-dot-danger",
warning: "bg-mock-dot-warning",
running: "bg-mock-dot-running",
idle: "bg-mock-surface3",
};
/** The 6pt disc on a sidebar row that carries the row's state. */
export function StatusDot({ tone }: { tone: DotTone }) {
return <span className={`size-[7px] shrink-0 rounded-full ${DOT_TONE[tone]}`} />;
}
/** Stands in for the dot on a running row — three stacked ticks, warning hue. */
export function WorkingGlyph() {
return (
<span className="flex h-[10px] w-[7px] shrink-0 flex-col items-center justify-between">
<i className="block size-[2.5px] rounded-full bg-mock-dot-warning" />
<i className="block size-[2.5px] rounded-full bg-mock-dot-warning" />
<i className="block size-[2.5px] rounded-full bg-mock-dot-warning" />
</span>
);
}
/** A pill-shaped count/label badge — PR number, failing checks, host. */
export function MetaBadge({
icon,
children,
className = "text-mock-fg-xmuted",
}: {
icon?: React.ReactNode;
children: React.ReactNode;
className?: string;
}) {
return (
<span className={`flex shrink-0 items-center gap-[3px] text-[10px] ${className}`}>
{icon}
{children}
</span>
);
}
/** Title-bar / toolbar button: transparent until hovered in the real app. */
export function ChromeButton({
children,
className = "",
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<span
className={`flex items-center gap-[5px] rounded-[6px] px-[7px] py-[4px] text-[11.5px] text-mock-fg-muted ${className}`}
>
{children}
</span>
);
}
export function VDivider() {
return <span className="h-[16px] w-px shrink-0 bg-mock-border" />;
}
// --- code rendering ---------------------------------------------------------
type SynRole =
| "keyword"
| "comment"
| "string"
| "number"
| "function"
| "class"
| "type"
| "tag"
| "property"
| "variable"
| "punctuation";
const SYN_CLASS: Record<SynRole, string> = {
keyword: "text-syn-keyword",
comment: "text-syn-comment",
string: "text-syn-string",
number: "text-syn-number",
function: "text-syn-function",
class: "text-mock-syn-class",
type: "text-syn-keyword",
tag: "text-syn-tag",
property: "text-syn-property",
variable: "text-syn-variable",
punctuation: "text-syn-punctuation",
};
const KEYWORDS = new Set([
"import",
"export",
"from",
"as",
"function",
"return",
"const",
"let",
"type",
"interface",
"await",
"async",
"new",
"default",
]);
const TYPE_KEYWORDS = new Set(["string", "number", "boolean", "unknown", "never", "void"]);
/** A compact TSX highlighter for the static hero diff. */
export function Code({ line }: { line: string }) {
return (
<>
{tokenize(line).map((token) => (
<span key={token.key} className={token.role ? SYN_CLASS[token.role] : undefined}>
{token.text}
</span>
))}
</>
);
}
interface CodeToken {
key: string;
text: string;
role: SynRole | null;
}
function tokenize(line: string): CodeToken[] {
const parts =
line.match(
/\/\/.*|"(?:\\.|[^"\\])*"?|'(?:\\.|[^'\\])*'?|`(?:\\.|[^`\\])*`?|\d+(?:\.\d+)?|[A-Za-z_$][\w$-]*|\s+|=>|===|!==|==|!=|<=|>=|&&|\|\||\?\.|[^\s]/g,
) ?? [];
return parts.map((text, index) => {
const role = classify(parts, index);
return { key: `${index}.${text}`, text, role };
});
}
function significantToken(parts: string[], index: number, direction: -1 | 1): string {
for (let cursor = index + direction; cursor >= 0 && cursor < parts.length; cursor += direction) {
const candidate = parts[cursor];
if (candidate && !/^\s+$/.test(candidate)) return candidate;
}
return "";
}
function isInsideJsxTag(parts: string[], index: number): boolean {
const prefix = parts.slice(0, index).join("");
return prefix.lastIndexOf("<") > prefix.lastIndexOf(">");
}
function classify(parts: string[], index: number): SynRole | null {
const part = parts[index] ?? "";
if (/^\s+$/.test(part)) return null;
if (/^["'`]/.test(part)) return "string";
if (part.startsWith("//")) return "comment";
if (KEYWORDS.has(part)) return "keyword";
if (TYPE_KEYWORDS.has(part)) return "type";
if (/^\d/.test(part)) return "number";
if (!/^[A-Za-z_$]/.test(part)) return "punctuation";
return classifyIdentifier(parts, index, part);
}
function classifyIdentifier(parts: string[], index: number, part: string): SynRole {
const previous = significantToken(parts, index, -1);
const next = significantToken(parts, index, 1);
const insideJsxTag = isInsideJsxTag(parts, index);
if (previous === "<" || (previous === "/" && insideJsxTag)) return "tag";
if (insideJsxTag && next === "=") return "property";
if (!insideJsxTag && next === ":") return "property";
if (previous === ":" || previous === "as") return "type";
if (next === "(") return "function";
if (/^[A-Z]/.test(part)) return "class";
if (part.includes("-")) return "property";
return "variable";
}
@@ -0,0 +1,260 @@
import {
AudioLines,
BookOpen,
Brain,
ChevronDown,
CircleDot,
Copy,
FilePenLine,
LoaderCircle,
ListTodo,
Mic,
Plus,
ShieldOff,
Split,
SquareTerminal,
Zap,
} from "lucide-react";
import type * as React from "react";
import { DiffStat } from "./atoms";
import { CodexIcon } from "./icons";
// Icons passed as props are hoisted so they are not rebuilt on every render.
const MODEL_ICON = <CodexIcon size={13} />;
const EFFORT_ICON = <Brain size={13} />;
const ACCESS_ICON = <ShieldOff size={13} />;
// --- transcript -------------------------------------------------------------
function Bullet({ children }: { children: React.ReactNode }) {
return (
<div className="flex gap-[10px] text-[13.5px] leading-[22px] text-mock-fg">
<span className="text-mock-fg-muted">·</span>
<span>{children}</span>
</div>
);
}
function Para({ children }: { children: React.ReactNode }) {
return <p className="text-[13.5px] leading-[20px] text-mock-fg">{children}</p>;
}
/** The user's message — a right-aligned bubble that opens the turn. */
function UserBubble({ children }: { children: React.ReactNode }) {
return (
<div className="flex justify-end pb-[12px]">
<span className="max-w-[80%] rounded-[11px] bg-mock-surface2 px-[13px] py-[8px] text-[13px] leading-[19px] text-mock-fg">
{children}
</span>
</div>
);
}
/** The message footer — copy, branch off, and how long the turn took. */
function TurnFooter({ duration }: { duration: string }) {
return (
<div className="flex items-center gap-[16px] pt-[14px] text-mock-fg-muted">
<Copy size={14} />
<Split size={14} />
<span className="text-[12.5px]">Worked for {duration}</span>
</div>
);
}
export function AgentTranscript() {
return (
<div className="space-y-[13px] pt-[2px]">
<Para>
The homepage is built. It leads with Paseo&rsquo;s actual value, then uses the product
itself to prove the story instead of falling back to generic dashboard art.
</Para>
<div>
<Bullet>Reworked the hero around &ldquo;The control plane for coding agents.&rdquo;</Bullet>
<Bullet>Built desktop and mobile scenes from real Paseo workflows and assets.</Bullet>
<Bullet>Added believable agent, review, plugin, shipping, and host states.</Bullet>
<Bullet>
Matched the app&rsquo;s tabs, explorer, diff tree, controls, and layout rails.
</Bullet>
</div>
<TurnFooter duration="31m 46s" />
<UserBubble>Create a PR</UserBubble>
<Para>PR opened: Rebuild the homepage around live Paseo workflows.</Para>
<span className="block text-[13.5px] text-mock-accent-bright">
https://github.com/getpaseo/paseo/pull/3981
</span>
<div>
<Bullet>Ships the new product-led hero and interactive desktop mockup.</Bullet>
<Bullet>Includes responsive mobile proof and the supporting homepage story.</Bullet>
<Bullet>Formatting, typecheck, and lint are clean.</Bullet>
</div>
<Para>Ready for review.</Para>
<TurnFooter duration="1m 18s" />
</div>
);
}
interface PluginToolCall {
kind: "read" | "shell" | "write";
label: string;
detail: string;
}
const PLUGIN_TOOL_CALLS: PluginToolCall[] = [
{ kind: "read", label: "Read", detail: "paseo-plugin/SKILL.md" },
{
kind: "shell",
label: "Shell",
detail: "paseo plugin init ~/dev/release-radar",
},
{
kind: "write",
label: "Write",
detail: "~/dev/release-radar/index.ts",
},
{
kind: "write",
label: "Write",
detail: "~/dev/release-radar/release-radar.client.tsx",
},
{
kind: "shell",
label: "Shell",
detail: "npm install && npm run typecheck",
},
{
kind: "shell",
label: "Shell",
detail: "paseo plugin install ~/dev/release-radar",
},
];
const PLUGIN_DISCOVERY_CALLS = PLUGIN_TOOL_CALLS.slice(0, 1);
const PLUGIN_BUILD_CALLS = PLUGIN_TOOL_CALLS.slice(1, 4);
const PLUGIN_INSTALL_CALLS = PLUGIN_TOOL_CALLS.slice(4);
function PluginToolIcon({ kind }: { kind: PluginToolCall["kind"] }) {
if (kind === "read") return <BookOpen size={14} />;
if (kind === "write") return <FilePenLine size={14} />;
return <SquareTerminal size={14} />;
}
function PluginToolRow({ call }: { call: PluginToolCall }) {
return (
<div className="flex items-center gap-[9px] py-[5px] text-[12.5px] leading-[18px]">
<span className="text-mock-fg-muted">
<PluginToolIcon kind={call.kind} />
</span>
<span className="shrink-0 text-mock-fg">{call.label}</span>
<span className="min-w-0 truncate font-mono text-[11.5px] text-mock-fg-muted">
{call.detail}
</span>
</div>
);
}
function PluginToolGroup({ calls }: { calls: PluginToolCall[] }) {
return (
<div>
{calls.map((call) => (
<PluginToolRow key={`${call.label}:${call.detail}`} call={call} />
))}
</div>
);
}
export function PluginBuildTranscript() {
return (
<div className="space-y-[13px] pt-[2px]">
<UserBubble>
Build me a release radar for Paseo. I want an explorer panel showing merged PRs, changes in
review, failing checks, and seven-day activity, accessible from the sidebar.
</UserBubble>
<Para>
I&rsquo;ll scaffold a local plugin, add the explorer surface, and wire it into the sidebar.
</Para>
<PluginToolGroup calls={PLUGIN_DISCOVERY_CALLS} />
<Para>
The plugin API exposes Paseo&rsquo;s live project and workspace state, so the radar
doesn&rsquo;t need a separate service.
</Para>
<PluginToolGroup calls={PLUGIN_BUILD_CALLS} />
<Para>The surface is wired. I&rsquo;m checking the project and installing it now.</Para>
<PluginToolGroup calls={PLUGIN_INSTALL_CALLS} />
<Para>Release Radar is live. It should now be visible in the right sidebar.</Para>
<TurnFooter duration="2m 14s" />
</div>
);
}
// --- composer ---------------------------------------------------------------
function ControlPill({ icon, label }: { icon: React.ReactNode; label: string }) {
return (
<span className="flex items-center gap-[6px] text-[12px] text-mock-fg-muted">
{icon}
{label}
<ChevronDown size={11} className="text-mock-fg-xmuted" />
</span>
);
}
/** A rounded pill that floats above the composer — task tracker, subagents, diff. */
function ComposerPill({ children }: { children: React.ReactNode }) {
return (
<span className="flex items-center gap-[6px] rounded-full border border-mock-border-accent px-[11px] py-[4px] text-[11.5px] text-mock-fg-muted">
{children}
</span>
);
}
/** The amber ring the app shows while a turn is streaming. */
function BusyRing() {
return <LoaderCircle size={14} className="text-mock-warning" strokeWidth={2.4} />;
}
export function Composer({
showProgress = true,
busy = true,
}: {
showProgress?: boolean;
busy?: boolean;
}) {
return (
<div className="shrink-0 px-[27px] pt-[16px] pb-[17px]">
<div className={showProgress ? "pb-[13px]" : ""}>
{showProgress ? (
<div className="flex items-center gap-[8px]">
<ComposerPill>6/6 tasks</ComposerPill>
<ComposerPill>
<CircleDot size={12} className="text-mock-dot-running" />3 subagents
</ComposerPill>
<ComposerPill>
<DiffStat add="1.9k" remove="684" />
</ComposerPill>
</div>
) : null}
</div>
<div className="rounded-[11px] border border-mock-border-accent bg-mock-surface1">
<div className="px-[14px] pt-[13px] pb-[12px]">
<div className="flex items-start gap-[10px]">
<span className="min-w-0 flex-1 text-[13px] text-mock-surface4">
Message the agent, tag @files, or use /commands and /skills
</span>
</div>
<div className="flex items-center gap-[17px] pt-[26px]">
<Plus size={15} className="text-mock-fg-muted" />
<ControlPill icon={MODEL_ICON} label="GPT-5.6-Sol" />
<ControlPill icon={EFFORT_ICON} label="Medium" />
<ControlPill icon={ACCESS_ICON} label="Full access" />
<Zap size={14} className="text-mock-fg-muted" />
<ListTodo size={14} className="text-mock-fg-muted" />
<span className="flex-1" />
{busy ? <BusyRing /> : null}
<Mic size={14} className="text-mock-fg-muted" />
<AudioLines size={14} className="text-mock-fg-muted" />
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,778 @@
import {
ChevronDown,
ChevronRight,
Columns2,
Ellipsis,
List,
Minus,
Plus,
RefreshCw,
Rows3,
} from "lucide-react";
import { Code, DiffStat } from "./atoms";
import { TABBAR_H } from "./geometry";
import { ReactFileIcon } from "./icons";
// Row height and the gutter width are the two numbers that make a diff read as a
// diff. Everything else follows them.
const ROW_H = 18.5;
const HUNK_H = 23;
const GUTTER_W = 42;
const ROW_STYLE = { height: ROW_H };
const HUNK_STYLE = { height: HUNK_H };
const GUTTER_STYLE = { width: GUTTER_W };
const SCROLLBAR_ROW_STYLE = { height: 9 };
const SUBHEADER_STYLE = { height: TABBAR_H };
export type DiffRow =
| { kind: "file"; name: string; path?: string; add: string; remove: string }
| { kind: "hunk"; label: string }
| { kind: "line"; n: string; text: string; mark?: DiffMark }
| { kind: "scrollbar" }
| { kind: "gap" };
type DiffMark = "add" | "remove";
// The row fill and the gutter number are the whole visual language of a diff:
// status hue at low alpha behind the row, full status hue on the number.
const ROW_FILL: Record<DiffMark, string> = {
add: "bg-mock-diff-add",
remove: "bg-mock-diff-remove",
};
const NUMBER_COLOR: Record<DiffMark, string> = {
add: "text-mock-success",
remove: "text-mock-danger",
};
function DiffLine({ n, text, mark }: { n: string; text: string; mark?: DiffMark }) {
const fill = mark ? ROW_FILL[mark] : "";
const number = mark ? NUMBER_COLOR[mark] : "text-mock-fg-muted";
return (
<div className={`flex items-center ${fill}`} style={ROW_STYLE}>
<span
className={`flex h-full shrink-0 items-center justify-end border-r border-mock-border pr-[10px] font-mono text-[11px] tabular-nums ${number}`}
style={GUTTER_STYLE}
>
{n}
</span>
<span className="min-w-0 flex-1 overflow-hidden whitespace-pre pl-[10px] font-mono text-[12px] leading-[18.5px]">
<Code line={text} />
</span>
</div>
);
}
function FileRow({
name,
path,
add,
remove,
}: {
name: string;
path?: string;
add: string;
remove: string;
}) {
return (
<div className="flex h-[36px] items-center gap-[8px] border-b border-mock-border bg-mock-surface0 pr-[14px] pl-[16px]">
<ChangesFileIcon name={name} />
<span className="min-w-0 flex-1 truncate text-[12px]">
<span className="text-mock-fg">{name}</span>
{path ? <span className="ml-[6px] text-mock-fg-muted">{path}</span> : null}
</span>
<DiffStat add={add} remove={remove} />
</div>
);
}
export function DiffRows({ rows, flush }: { rows: DiffRow[]; flush?: boolean }) {
return (
<div className={`relative overflow-hidden bg-mock-surface1 ${flush ? "" : "min-h-0 flex-1"}`}>
{rows.map((row, index) => {
const key = `${row.kind}-${index}`;
if (row.kind === "file") return <FileRow key={key} {...row} />;
if (row.kind === "scrollbar")
return (
<div key={key} className="flex items-center pl-[46px]" style={SCROLLBAR_ROW_STYLE}>
<span className="h-[5px] w-[110px] rounded-full bg-mock-surface4" />
</div>
);
if (row.kind === "gap") return <div key={key} style={ROW_STYLE} />;
if (row.kind === "hunk")
return (
<div key={key} className="flex items-center" style={HUNK_STYLE}>
<span className="h-full shrink-0 border-r border-mock-border" style={GUTTER_STYLE} />
<span className="pl-[10px] font-mono text-[11px] text-mock-fg-muted">
{row.label}
</span>
</div>
);
return <DiffLine key={key} {...row} />;
})}
{!flush ? (
<span className="pointer-events-none absolute top-[86px] right-[3px] bottom-[8px] w-[4px] rounded-full bg-mock-surface2">
<span className="mt-[112px] block h-[92px] rounded-full bg-mock-surface4" />
</span>
) : null}
</div>
);
}
/** The `Uncommitted ⌄` scope row with the view-mode controls on the right. */
export function ScopeRow({ label }: { label: string }) {
return (
<div className="flex h-[31px] shrink-0 items-center border-b border-mock-border px-[14px]">
<span className="flex items-center gap-[5px] text-[12px] text-mock-fg">
{label}
<ChevronDown size={12} className="text-mock-fg-muted" />
</span>
<span className="flex-1" />
<span className="flex items-center gap-[13px] text-mock-fg-muted">
<Columns2 size={13} />
<span className="rounded-[5px] bg-mock-surface2 p-[3px] text-mock-fg">
<List size={12} />
</span>
<Rows3 size={13} />
<ChevronDown size={12} />
</span>
</div>
);
}
// --- flat diff tree ---------------------------------------------------------
// The default diff view is a flat file list: name · path · +N -M · expand box.
export interface TreeFile {
name: string;
path?: string;
add: string;
remove: string;
}
/** The expand affordance on a tree row — green + for pure additions, else a dot. */
function ExpandBox({ pure }: { pure: boolean }) {
return (
<span
className={`flex size-[15px] shrink-0 items-center justify-center rounded-[4px] border ${
pure
? "border-mock-success/60 text-mock-success"
: "border-mock-warning/50 text-mock-warning"
}`}
>
{pure ? (
<Plus size={11} strokeWidth={2.4} />
) : (
<i className="block size-[3px] rounded-full bg-current" />
)}
</span>
);
}
/** One row of the flat diff tree. */
export function DiffTreeRow({ file }: { file: TreeFile }) {
const pure = file.remove === "0";
return (
<div className="flex items-center gap-[10px] border-b border-mock-border px-[14px] py-[7px]">
<span className="min-w-0 flex-1 truncate text-[12.5px]">
<span className="text-mock-fg">{file.name}</span>
{file.path ? <span className="text-mock-fg-muted"> {file.path}</span> : null}
</span>
<span className="flex shrink-0 items-center gap-[4px] text-[11px] tabular-nums">
<span className="text-mock-success">+{file.add}</span>
<span className={pure ? "text-mock-fg-xmuted" : "text-mock-danger"}>-{file.remove}</span>
</span>
<ExpandBox pure={pure} />
</div>
);
}
export function DiffTree({ files }: { files: TreeFile[] }) {
return (
<div className="min-h-0 flex-1 overflow-hidden">
{files.map((file) => (
<DiffTreeRow key={`${file.path ?? ""}/${file.name}`} file={file} />
))}
</div>
);
}
export const REVIEW_TREE: TreeFile[] = [
{ name: "providers.md", path: "docs", add: "3", remove: "1" },
{
name: "bridge-plugin.mjs",
path: "packages/server/src/server/agent/providers/opencode",
add: "146",
remove: "0",
},
{
name: "bridge.test.ts",
path: "packages/server/src/server/agent/providers/opencode",
add: "181",
remove: "0",
},
{
name: "bridge.ts",
path: "packages/server/src/server/agent/providers/opencode",
add: "284",
remove: "0",
},
{
name: "server-manager.ts",
path: "packages/server/src/server/agent/providers/opencode",
add: "13",
remove: "1",
},
{
name: "opencode-agent.test.ts",
path: "packages/server/src/server/agent/providers",
add: "40",
remove: "0",
},
{
name: "opencode-agent.ts",
path: "packages/server/src/server/agent/providers",
add: "58",
remove: "8",
},
{
name: "opencode-bridge-adapter.test.ts",
path: "packages/server/src/server/agent/providers",
add: "123",
remove: "0",
},
{
name: "opencode-bridge.local.e2e.test.ts",
path: "packages/server/src/server/agent/providers",
add: "158",
remove: "0",
},
{
name: "provider-registry.ts",
path: "packages/server/src/server/agent",
add: "10",
remove: "2",
},
{
name: "provider-snapshot-manager.ts",
path: "packages/server/src/server/agent",
add: "5",
remove: "0",
},
{ name: "bootstrap.ts", path: "packages/server/src/server", add: "12", remove: "0" },
{ name: "package.json", path: "packages/server", add: "2", remove: "1" },
{ name: "trace-daemon.mjs", path: "scripts", add: "2", remove: "0" },
{ name: "package-lock.json", add: "260", remove: "1" },
];
// --- explorer changes tree -------------------------------------------------
interface ChangesTreeFolder {
kind: "folder";
name: string;
depth: ChangesTreeDepth;
add: string;
remove: string;
}
interface ChangesTreeFile {
kind: "file";
name: string;
depth: ChangesTreeDepth;
add: string;
remove: string;
status: ChangeStatus;
}
type ChangesTreeEntry = ChangesTreeFolder | ChangesTreeFile;
type ChangesTreeDepth = 0 | 1 | 2 | 3 | 4;
type ChangeStatus = "added" | "modified" | "deleted";
const CHANGES_TREE: ChangesTreeEntry[] = [
{ kind: "folder", name: "packages/website/src", depth: 0, add: "1.9k", remove: "684" },
{ kind: "folder", name: "components", depth: 1, add: "1.5k", remove: "512" },
{ kind: "folder", name: "mockup", depth: 2, add: "1.3k", remove: "389" },
{ kind: "folder", name: "mobile", depth: 3, add: "480", remove: "231" },
{ kind: "file", name: "atoms.tsx", depth: 4, add: "56", remove: "18", status: "modified" },
{ kind: "file", name: "chat.tsx", depth: 4, add: "155", remove: "74", status: "modified" },
{ kind: "file", name: "frame.tsx", depth: 4, add: "91", remove: "0", status: "added" },
{ kind: "file", name: "legacy-tabs.tsx", depth: 4, add: "0", remove: "139", status: "deleted" },
{ kind: "folder", name: "desktop", depth: 3, add: "612", remove: "143" },
{ kind: "file", name: "window.tsx", depth: 4, add: "167", remove: "42", status: "modified" },
{ kind: "file", name: "titlebar.tsx", depth: 4, add: "117", remove: "61", status: "modified" },
{ kind: "file", name: "toolbar.tsx", depth: 4, add: "204", remove: "0", status: "added" },
{ kind: "file", name: "old-tabs.tsx", depth: 4, add: "0", remove: "40", status: "deleted" },
{ kind: "folder", name: "shared", depth: 3, add: "194", remove: "15" },
{ kind: "file", name: "icons.tsx", depth: 4, add: "62", remove: "15", status: "modified" },
{ kind: "file", name: "geometry.ts", depth: 4, add: "17", remove: "0", status: "added" },
{ kind: "file", name: "states.ts", depth: 4, add: "115", remove: "0", status: "added" },
{ kind: "file", name: "hero-mockup.tsx", depth: 2, add: "89", remove: "52", status: "modified" },
{ kind: "folder", name: "routes", depth: 1, add: "211", remove: "96" },
{ kind: "file", name: "index.tsx", depth: 2, add: "44", remove: "70", status: "modified" },
{ kind: "file", name: "download.tsx", depth: 2, add: "167", remove: "0", status: "added" },
{ kind: "folder", name: "styles", depth: 1, add: "68", remove: "76" },
{ kind: "file", name: "mockup.css", depth: 2, add: "68", remove: "47", status: "modified" },
{ kind: "file", name: "legacy.css", depth: 2, add: "0", remove: "29", status: "deleted" },
];
/** The open workspace diff matches the selected file in the Changes tree. */
export const REVIEW_DIFF_ROWS: DiffRow[] = [
{
kind: "file",
name: "chat.tsx",
path: "packages/website/src/components/mockup/mobile",
add: "155",
remove: "74",
},
{ kind: "hunk", label: "@@ -22,5 +23,11 @@" },
{ kind: "line", n: "22", text: "const TOOL_ROWS = [", mark: "remove" },
{
kind: "line",
n: "23",
text: 'type ToolKind = "read" | "shell";',
mark: "add",
},
{ kind: "line", n: "24", text: "" },
{
kind: "line",
n: "25",
text: "interface ToolCall {",
mark: "add",
},
{ kind: "line", n: "26", text: " kind: ToolKind;", mark: "add" },
{ kind: "line", n: "27", text: " label: string;", mark: "add" },
{ kind: "line", n: "28", text: " command: string;", mark: "add" },
{ kind: "line", n: "29", text: "}", mark: "add" },
{ kind: "line", n: "30", text: "" },
{
kind: "line",
n: "31",
text: "const TOOL_CALLS: ToolCall[] = [",
mark: "add",
},
{ kind: "line", n: "32", text: " {", mark: "add" },
{ kind: "line", n: "33", text: ' ...readToolCall("docs/design.md"),', mark: "add" },
{ kind: "line", n: "34", text: " },", mark: "add" },
{ kind: "line", n: "35", text: "];", mark: "add" },
{ kind: "line", n: "36", text: "" },
{
kind: "file",
name: "titlebar.tsx",
path: "packages/website/src/components/mockup/desktop",
add: "117",
remove: "61",
},
{ kind: "hunk", label: "@@ -88,6 +92,16 @@" },
{
kind: "line",
n: "88",
text: "const AGENT_TABS: Tab[] = [",
mark: "remove",
},
{
kind: "line",
n: "92",
text: "const WORKSPACE_TABS: Tab[] = [",
mark: "add",
},
{
kind: "line",
n: "95",
text: ' { id: "terminal", label: "npm run dev", icon: <SquareTerminal /> },',
mark: "add",
},
{
kind: "line",
n: "96",
text: ' { id: "browser", label: "https://localhost:3000", icon: <Globe /> },',
mark: "add",
},
{ kind: "line", n: "97", text: "];" },
{ kind: "line", n: "98", text: "" },
{
kind: "file",
name: "window.tsx",
path: "packages/website/src/components/mockup/desktop",
add: "167",
remove: "42",
},
{ kind: "hunk", label: "@@ -118,7 +124,7 @@" },
{ kind: "line", n: "118", text: " <TitleBar state={state} />" },
{ kind: "line", n: "119", text: " <TabStrip />", mark: "remove" },
{ kind: "line", n: "124", text: " <TabStrip state={state} />", mark: "add" },
{ kind: "line", n: "125", text: ' <div className="relative min-h-0 flex-1">' },
{ kind: "line", n: "126", text: " <AnimatePresence initial={false}>" },
{
kind: "file",
name: "mockup.css",
path: "packages/website/src/styles",
add: "68",
remove: "47",
},
{ kind: "hunk", label: "@@ -54,4 +61,4 @@" },
{
kind: "line",
n: "54",
text: "--color-mock-diff-add: rgba(108, 177, 123, 0.15);",
mark: "remove",
},
{
kind: "line",
n: "61",
text: "--color-mock-diff-add: rgba(108, 177, 123, 0.08);",
mark: "add",
},
{
kind: "line",
n: "55",
text: "--color-mock-diff-remove: rgba(216, 132, 123, 0.1);",
mark: "remove",
},
{
kind: "line",
n: "62",
text: "--color-mock-diff-remove: rgba(216, 132, 123, 0.07);",
mark: "add",
},
];
const TREE_DEPTH_PADDING = [
"pl-[14px]",
"pl-[27px]",
"pl-[40px]",
"pl-[53px]",
"pl-[66px]",
] as const;
function ChangesFileIcon({ name }: { name: string }) {
if (name.endsWith(".tsx")) return <ReactFileIcon size={13} />;
const label = name.endsWith(".css") ? "CSS" : "TS";
const color = name.endsWith(".css") ? "bg-purple-400/70" : "bg-sky-400/70";
return (
<span
className={`flex h-[13px] w-[15px] shrink-0 items-center justify-center text-[6px] font-bold text-mock-surface0 ${color}`}
>
{label}
</span>
);
}
function ChangeStatusBox({ status }: { status: ChangeStatus }) {
if (status === "added") return <ExpandBox pure />;
const deleted = status === "deleted";
return (
<span
className={`flex size-[15px] shrink-0 items-center justify-center rounded-[4px] border ${
deleted
? "border-mock-danger/50 text-mock-danger"
: "border-mock-warning/50 text-mock-warning"
}`}
>
{deleted ? (
<Minus size={11} strokeWidth={2.4} />
) : (
<i className="block size-[3px] rounded-full bg-current" />
)}
</span>
);
}
function ChangesTreeRow({ entry, selected }: { entry: ChangesTreeEntry; selected: boolean }) {
const hasAdditions = entry.add !== "0";
const hasRemovals = entry.remove !== "0";
return (
<div
className={`flex h-[29px] items-center gap-[8px] pr-[10px] ${TREE_DEPTH_PADDING[entry.depth]} ${
selected ? "bg-mock-surface2" : ""
}`}
>
{entry.kind === "folder" ? (
<ChevronDown size={13} className="shrink-0 text-mock-fg-muted" />
) : null}
{entry.kind === "file" ? <ChangesFileIcon name={entry.name} /> : null}
<span className="min-w-0 flex-1 truncate text-[12px] text-mock-fg">{entry.name}</span>
<span className="flex shrink-0 items-center gap-[4px] text-[11px] tabular-nums">
<span className={hasAdditions ? "text-mock-success" : "text-mock-fg-xmuted"}>
+{entry.add}
</span>
<span className={hasRemovals ? "text-mock-danger" : "text-mock-fg-xmuted"}>
-{entry.remove}
</span>
</span>
{entry.kind === "file" ? (
<ChangeStatusBox status={entry.status} />
) : (
<span className="size-[15px] shrink-0" />
)}
</div>
);
}
export function ExplorerChangesTree({ selectedFile }: { selectedFile?: string }) {
return (
<div className="min-h-0 flex-1 overflow-hidden">
{CHANGES_TREE.map((entry) => (
<ChangesTreeRow
key={`${entry.depth}:${entry.name}`}
entry={entry}
selected={entry.kind === "file" && entry.name === selectedFile}
/>
))}
</div>
);
}
export function ChangesBranchRow() {
return (
<div
className="flex shrink-0 items-center gap-[6px] border-b border-mock-border px-[14px] text-[12px] text-mock-fg-muted"
style={SUBHEADER_STYLE}
>
main
<ChevronDown size={12} />
</div>
);
}
export function ChangesScopeRow() {
return (
<div className="flex h-[36px] shrink-0 items-center border-b border-mock-border px-[14px]">
<span className="flex items-center gap-[5px] text-[12px] text-mock-fg-muted">
Uncommitted
<ChevronDown size={12} />
</span>
<span className="ml-[9px] flex items-center gap-[5px] text-[11.5px] tabular-nums">
<span className="text-mock-success">+1.9k</span>
<span className="text-mock-danger">-684</span>
</span>
<span className="flex-1" />
<span className="flex items-center gap-[12px] text-mock-fg-muted">
<RefreshCw size={13} />
<Ellipsis size={14} />
</span>
</div>
);
}
export function ChangesCommitsRow() {
return (
<div className="flex h-[36px] shrink-0 items-center gap-[8px] border-t border-mock-border px-[10px] text-[12px] text-mock-fg">
<ChevronRight size={13} className="text-mock-fg-muted" />
Commits
</div>
);
}
/** The hunk shown when the first review file is expanded. */
export const PROVIDERS_HUNK: DiffRow[] = [
{ kind: "hunk", label: "@@ -75,7 +75,9 @@" },
{ kind: "line", n: "75", text: "" },
{
kind: "line",
n: "76",
text: "Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged",
},
{ kind: "line", n: "77", text: "" },
{
kind: "line",
n: "78",
text: "OpenCode MCP injection is dynamic and session-scoped. Call OpenCode's `mcp.add` endpoint",
mark: "remove",
},
];
/** The hunk continues below the inline comment box. */
export const PROVIDERS_HUNK_TAIL: DiffRow[] = [
{
kind: "line",
n: "78",
text: "OpenCode 1 keeps MCP and process environment outside the session boundary. Paseo shares",
mark: "add",
},
{ kind: "line", n: "79", text: "", mark: "add" },
{
kind: "line",
n: "80",
text: "An agent with custom environment variables or user-configured MCP servers gets a server",
mark: "add",
},
{ kind: "line", n: "81", text: "" },
{
kind: "line",
n: "82",
text: "OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prompt APIs",
},
{ kind: "line", n: "83", text: "" },
];
// --- content ----------------------------------------------------------------
export const UNCOMMITTED_ROWS: DiffRow[] = [
{ kind: "file", name: "alert.tsx", add: "12", remove: "18" },
{ kind: "line", n: "49", text: " <div" },
{ kind: "line", n: "50", text: ' data-slot="alert-description"' },
{ kind: "line", n: "51", text: " className={cn(" },
{
kind: "line",
n: "52",
text: ' "col-start-2 grid justify-items-start gap-1 text-sm tex',
},
{ kind: "line", n: "59", text: " className", mark: "remove" },
{ kind: "line", n: "53", text: " className,", mark: "add" },
{ kind: "line", n: "54", text: " )}" },
{ kind: "line", n: "55", text: " {...props}" },
{ kind: "line", n: "56", text: " />" },
{ kind: "line", n: "63", text: " )", mark: "remove" },
{ kind: "line", n: "57", text: " );", mark: "add" },
{ kind: "line", n: "58", text: "}" },
{ kind: "line", n: "59", text: "" },
{
kind: "line",
n: "66",
text: "export { Alert, AlertTitle, AlertDescription }",
mark: "remove",
},
{ kind: "line", n: "60", text: "export { Alert, AlertTitle, AlertDescription };", mark: "add" },
{ kind: "scrollbar" },
{ kind: "file", name: "badge.tsx", add: "11", remove: "13" },
{ kind: "file", name: "button.tsx", add: "12", remove: "14" },
{ kind: "file", name: "card.tsx", add: "13", remove: "30" },
{ kind: "hunk", label: "@@ -1,6 +1,6 @@" },
{ kind: "line", n: "1", text: 'import * as React from "react"', mark: "remove" },
{ kind: "line", n: "1", text: 'import * as React from "react";', mark: "add" },
{ kind: "line", n: "2", text: "" },
{ kind: "line", n: "3", text: 'import { cn } from "@/lib/utils"', mark: "remove" },
{ kind: "line", n: "3", text: 'import { cn } from "../../lib/utils.js";', mark: "add" },
{ kind: "line", n: "4", text: "" },
{
kind: "line",
n: "5",
text: 'function Card({ className, ...props }: React.ComponentProps<"di',
},
{ kind: "line", n: "6", text: " return (" },
{ kind: "hunk", label: "@@ -8,11 +8,11 @@" },
{ kind: "line", n: "8", text: ' data-slot="card"' },
{ kind: "line", n: "9", text: " className={cn(" },
{
kind: "line",
n: "10",
text: ' "flex flex-col gap-6 rounded-xl border bg-card py-6 tex',
},
{ kind: "line", n: "11", text: " className", mark: "remove" },
{ kind: "line", n: "11", text: " className,", mark: "add" },
{ kind: "line", n: "12", text: " )}" },
{ kind: "line", n: "13", text: " {...props}" },
{ kind: "line", n: "14", text: " />" },
{ kind: "line", n: "15", text: " )", mark: "remove" },
{ kind: "line", n: "15", text: " );", mark: "add" },
{ kind: "line", n: "16", text: "}" },
{ kind: "line", n: "17", text: "" },
];
/** Same change set, room to breathe — used by the wide review pane. */
export const REVIEW_ROWS: DiffRow[] = [
{ kind: "hunk", label: "@@ -1,6 +1,6 @@" },
{ kind: "line", n: "1", text: 'import * as React from "react"', mark: "remove" },
{ kind: "line", n: "1", text: 'import * as React from "react";', mark: "add" },
{ kind: "line", n: "2", text: "" },
{ kind: "line", n: "3", text: 'import { cn } from "@/lib/utils"', mark: "remove" },
{ kind: "line", n: "3", text: 'import { cn } from "../../lib/utils.js";', mark: "add" },
{ kind: "line", n: "4", text: "" },
{
kind: "line",
n: "5",
text: 'function Card({ className, ...props }: React.ComponentProps<"div">) {',
},
{ kind: "line", n: "6", text: " return (" },
{ kind: "line", n: "7", text: " <div" },
{ kind: "hunk", label: "@@ -8,15 +8,15 @@ function Card({ className, ...props })" },
{ kind: "line", n: "8", text: ' data-slot="card"' },
{ kind: "line", n: "9", text: " className={cn(" },
{
kind: "line",
n: "10",
text: ' "flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground",',
},
{ kind: "line", n: "11", text: " className", mark: "remove" },
{ kind: "line", n: "11", text: " className,", mark: "add" },
{ kind: "line", n: "12", text: " )}" },
{ kind: "line", n: "13", text: " {...props}" },
{ kind: "line", n: "14", text: " />" },
{ kind: "line", n: "15", text: " )", mark: "remove" },
{ kind: "line", n: "15", text: " );", mark: "add" },
{ kind: "line", n: "16", text: "}" },
{ kind: "line", n: "17", text: "" },
{ kind: "hunk", label: "@@ -21,9 +21,9 @@ function CardHeader({ className, ...props })" },
{ kind: "line", n: "21", text: "function CardHeader({ className, ...props }) {" },
{ kind: "line", n: "22", text: " return (" },
{ kind: "line", n: "23", text: " <div" },
{ kind: "line", n: "24", text: ' data-slot="card-header"' },
{
kind: "line",
n: "25",
text: ' className={cn("grid auto-rows-min items-start", className)}',
mark: "remove",
},
{
kind: "line",
n: "25",
text: ' className={cn("grid auto-rows-min items-start gap-1.5", className)}',
mark: "add",
},
{ kind: "line", n: "26", text: " {...props}" },
{ kind: "line", n: "27", text: " />" },
{ kind: "line", n: "28", text: " );" },
{ kind: "line", n: "29", text: "}" },
{ kind: "line", n: "30", text: "" },
{ kind: "hunk", label: "@@ -36,12 +36,12 @@ function CardTitle({ className, ...props })" },
{ kind: "line", n: "36", text: "function CardTitle({ className, ...props }) {" },
{ kind: "line", n: "37", text: " return (" },
{ kind: "line", n: "38", text: " <div" },
{ kind: "line", n: "39", text: ' data-slot="card-title"' },
{
kind: "line",
n: "40",
text: ' className={cn("leading-none", className)}',
mark: "remove",
},
{
kind: "line",
n: "40",
text: ' className={cn("leading-none font-semibold", className)}',
mark: "add",
},
{ kind: "line", n: "41", text: " {...props}" },
{ kind: "line", n: "42", text: " />" },
{ kind: "line", n: "43", text: " )", mark: "remove" },
{ kind: "line", n: "43", text: " );", mark: "add" },
{ kind: "line", n: "44", text: "}" },
{ kind: "line", n: "45", text: "" },
{
kind: "line",
n: "46",
text: "export { Card, CardHeader, CardTitle, CardContent }",
mark: "remove",
},
{
kind: "line",
n: "46",
text: "export { Card, CardHeader, CardTitle, CardContent };",
mark: "add",
},
];
export const REVIEW_FILES: {
name: string;
path: string;
add: string;
remove: string;
active?: boolean;
}[] = [
{ name: "alert.tsx", path: "components/ui", add: "12", remove: "18" },
{ name: "badge.tsx", path: "components/ui", add: "11", remove: "13" },
{ name: "button.tsx", path: "components/ui", add: "12", remove: "14" },
{ name: "card.tsx", path: "components/ui", add: "13", remove: "30", active: true },
{ name: "sidebar.tsx", path: "components/ui", add: "48", remove: "9" },
{ name: "utils.ts", path: "lib", add: "3", remove: "1" },
];
@@ -0,0 +1,17 @@
/**
* The mockup is authored at one fixed size — "design pixels" — and scaled to
* whatever width its container gives it. Every measurement in the mockup is a
* plain px number you can reason about directly, and nothing has to change when
* the hero column gets wider or narrower.
*
* The numbers come from measuring public/homepage-hero.png (a 2x capture of a
* 2249x1289 window region) and dividing by 1.5617 so the window lands at 1440
* wide — the widest the hero column ever renders it.
*/
export const DESIGN_WIDTH = 1440;
export const DESIGN_HEIGHT = 826;
export const TITLEBAR_H = 39;
export const TABBAR_H = 31;
export const SIDEBAR_W = 262;
export const RIGHT_PANEL_W = 424;
@@ -1,5 +1,37 @@
// Exact SVG paths from packages/app/src/components/icons/
/** The Paseo brand swirl — public/logo.svg, drawn as a small sidebar avatar mark. */
export function PaseoMark({ size = 13, className }: { size?: number; className?: string }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 700 700"
fill="currentColor"
aria-hidden="true"
className={className}
>
<path d="M291.495 91.399C333.897 104.892 379.155 135.075 416.229 173.191C453.389 211.394 484.429 259.725 495.708 311.251C497.555 319.693 498.865 328.216 499.586 336.776C509.755 326.554 519.867 317.815 529.89 311.547C540.647 304.821 553.808 299.297 568.641 299.785C584.29 300.299 597.395 307.326 607.747 317.632C632.173 341.947 629.612 372.898 619.872 397.936C610.185 422.833 591.557 447.826 572.732 469.124C553.591 490.78 532.713 510.308 516.779 524.318C508.775 531.355 501.936 537.073 497.07 541.052C494.635 543.043 492.689 544.603 491.334 545.679C490.657 546.217 490.126 546.635 489.756 546.926C489.571 547.071 489.425 547.184 489.321 547.265C489.269 547.305 489.227 547.338 489.196 547.362C489.181 547.374 489.168 547.385 489.157 547.393C489.153 547.397 489.147 547.401 489.144 547.403C489.134 547.4 488.837 547.06 473.001 528.499L489.135 547.411C478.157 555.911 462.033 554.334 453.122 543.89C444.213 533.448 445.887 518.094 456.861 509.592C456.863 509.591 456.865 509.588 456.869 509.586C456.88 509.577 456.902 509.561 456.933 509.536C456.997 509.487 457.101 509.404 457.245 509.292C457.533 509.066 457.979 508.715 458.569 508.247C459.749 507.31 461.506 505.901 463.742 504.073C468.216 500.414 474.589 495.088 482.073 488.508C497.114 475.284 516.315 457.282 533.578 437.75C551.157 417.862 565.26 398.01 571.859 381.048C578.403 364.227 575.681 356.302 570.724 351.367C568.928 349.579 567.744 348.902 567.267 348.676C566.888 348.496 566.811 348.52 566.804 348.52C566.605 348.513 563.971 348.537 557.953 352.3C545.161 360.299 528.815 377.492 506.807 403.867C494.927 418.106 481.871 434.435 467.547 451.957C463.709 457.28 459.503 462.538 454.91 467.717L454.702 467.549C420.808 508.347 380.37 553.856 332.335 593.848C301.853 619.226 262.656 622.597 228.642 614.743C194.834 606.936 162.658 587.448 142.217 561.686C108.054 518.631 100.57 469.801 108.223 427.836C115.56 387.606 137.391 351.005 166.502 331.557C161.248 315.813 156.813 299.49 153.519 283.013C142.593 228.368 143.239 167.031 174.28 119.619C186.922 100.31 205.846 89.1535 227.387 85.2773C248.1 81.5504 270.278 84.648 291.495 91.399ZM378.642 206.356C345.773 172.563 307.463 147.917 275.208 137.654C259.096 132.527 246.171 131.514 236.828 133.195C228.314 134.727 222.227 138.497 217.721 145.38C196.712 177.468 193.858 224.004 203.82 273.827C206.532 287.394 210.127 300.834 214.345 313.817C236.45 310.276 260.156 311.463 281.22 317.11C319.621 327.403 357.501 355.419 357.501 405.654C357.501 435.255 339.111 465.136 307.278 473.815C273.211 483.103 238.854 464.822 213.105 427.541C203.716 413.947 194.443 397.766 185.947 379.89C174.028 392.223 163.08 411.953 158.673 436.118C153.128 466.518 158.514 501.286 183.085 532.253C195.993 548.522 217.742 562.031 240.771 567.349C263.594 572.619 284.147 569.24 298.664 557.154C349.383 514.927 390.709 466.547 426.366 422.952C448.879 390.86 453.195 356.06 445.578 321.265C436.703 280.718 411.425 240.06 378.642 206.356ZM306.296 405.722C306.296 384.769 292.223 370.736 267.284 364.051C256.012 361.03 244.156 360.087 233.095 360.771C240.361 375.935 248.168 389.513 255.897 400.704C275.647 429.298 289.989 427.822 293.247 426.934C298.737 425.437 306.296 418.161 306.296 405.722Z" />
</svg>
);
}
/** GitHub mark for the toolbar and PR panel. */
export function GithubGlyph({ size = 14, className }: { size?: number; className?: string }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
className={className}
>
<path d="M12 2C6.48 2 2 6.58 2 12.25c0 4.53 2.87 8.37 6.84 9.73.5.1.68-.22.68-.49 0-.24-.01-.87-.01-1.71-2.78.62-3.37-1.37-3.37-1.37-.46-1.19-1.11-1.5-1.11-1.5-.91-.64.07-.62.07-.62 1 .07 1.53 1.06 1.53 1.06.89 1.57 2.34 1.12 2.91.85.09-.66.35-1.12.63-1.38-2.22-.26-4.56-1.14-4.56-5.06 0-1.12.39-2.03 1.03-2.75-.1-.26-.45-1.3.1-2.7 0 0 .84-.28 2.75 1.05a9.32 9.32 0 0 1 5 0c1.91-1.33 2.75-1.05 2.75-1.05.55 1.4.2 2.44.1 2.7.64.72 1.03 1.63 1.03 2.75 0 3.93-2.34 4.79-4.57 5.05.36.32.68.94.68 1.9 0 1.37-.01 2.48-.01 2.82 0 .27.18.6.69.49A10.02 10.02 0 0 0 22 12.25C22 6.58 17.52 2 12 2Z" />
</svg>
);
}
export function ClaudeIcon({ size = 13, className }: { size?: number; className?: string }) {
return (
<svg
@@ -30,6 +62,36 @@ export function CodexIcon({ size = 13, className }: { size?: number; className?:
);
}
/** Material file-icon theme's React mark — what the app shows next to a .tsx file. */
export function ReactFileIcon({ size = 11, className }: { size?: number; className?: string }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
<circle cx={12} cy={12} r={2.2} fill="#61dafb" />
<g stroke="#61dafb" strokeWidth={1.2} fill="none">
<ellipse cx={12} cy={12} rx={9.6} ry={4} />
<ellipse cx={12} cy={12} rx={9.6} ry={4} transform="rotate(60 12 12)" />
<ellipse cx={12} cy={12} rx={9.6} ry={4} transform="rotate(120 12 12)" />
</g>
</svg>
);
}
/** The blue "open in editor" chip in the title bar. */
export function EditorChipIcon({ size = 13, className }: { size?: number; className?: string }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
<rect x={1} y={1} width={22} height={22} rx={5} fill="#2f86ff" />
<path
d="M16.4 5.6 10 12l6.4 6.4V5.6ZM8.3 9.9 5.6 12l2.7 2.1"
stroke="#fff"
strokeWidth={1.7}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function SourceControlIcon({ size = 14, className }: { size?: number; className?: string }) {
return (
<svg
@@ -1 +1,8 @@
export { ClaudeIcon, CodexIcon, SourceControlIcon } from "./icons";
export { DESIGN_HEIGHT, DESIGN_WIDTH } from "./geometry";
export { PhoneFrame } from "./mobile/frame";
export { MobileChat } from "./mobile/chat";
export { MobileDiff } from "./mobile/diff";
export { MobileSidebar } from "./mobile/sidebar";
export { DEFAULT_MOCKUP_STATE, MOCKUP_STATES, type MockupStateId } from "./states";
export { MockupWindow } from "./window";
@@ -0,0 +1,56 @@
// Small shared bits for the mobile mockups. The Paseo butterfly mark is the
// app icon (packages/website/public/favicon.svg) with the black plate dropped,
// so it can be tinted and placed on any tile. Tile fills are the app's identity
// palette — packages/app/src/styles/identity-colors.ts — surfaced as
// `--color-mock-tile-*` tokens in styles.css.
const BUTTERFLY_D =
"M291.495 91.399C333.897 104.892 379.155 135.075 416.229 173.191C453.389 211.394 484.429 259.725 495.708 311.251C497.555 319.693 498.865 328.216 499.586 336.776C509.755 326.554 519.867 317.815 529.89 311.547C540.647 304.821 553.808 299.297 568.641 299.785C584.29 300.299 597.395 307.326 607.747 317.632C632.173 341.947 629.612 372.898 619.872 397.936C610.185 422.833 591.557 447.826 572.732 469.124C553.591 490.78 532.713 510.308 516.779 524.318C508.775 531.355 501.936 537.073 497.07 541.052C494.635 543.043 492.689 544.603 491.334 545.679C490.657 546.217 490.126 546.635 489.756 546.926C489.571 547.071 489.425 547.184 489.321 547.265C489.269 547.305 489.227 547.338 489.196 547.362C489.181 547.374 489.168 547.385 489.157 547.393C489.153 547.397 489.147 547.401 489.144 547.403C489.134 547.4 488.837 547.06 473.001 528.499L489.135 547.411C478.157 555.911 462.033 554.334 453.122 543.89C444.213 533.448 445.887 518.094 456.861 509.592C456.863 509.591 456.865 509.588 456.869 509.586C456.88 509.577 456.902 509.561 456.933 509.536C456.997 509.487 457.101 509.404 457.245 509.292C457.533 509.066 457.979 508.715 458.569 508.247C459.749 507.31 461.506 505.901 463.742 504.073C468.216 500.414 474.589 495.088 482.073 488.508C497.114 475.284 516.315 457.282 533.578 437.75C551.157 417.862 565.26 398.01 571.859 381.048C578.403 364.227 575.681 356.302 570.724 351.367C568.928 349.579 567.744 348.902 567.267 348.676C566.888 348.496 566.811 348.52 566.804 348.52C566.605 348.513 563.971 348.537 557.953 352.3C545.161 360.299 528.815 377.492 506.807 403.867C494.927 418.106 481.871 434.435 467.547 451.957C463.709 457.28 459.503 462.538 454.91 467.717L454.702 467.549C420.808 508.347 380.37 553.856 332.335 593.848C301.853 619.226 262.656 622.597 228.642 614.743C194.834 606.936 162.658 587.448 142.217 561.686C108.054 518.631 100.57 469.801 108.223 427.836C115.56 387.606 137.391 351.005 166.502 331.557C161.248 315.813 156.813 299.49 153.519 283.013C142.593 228.368 143.239 167.031 174.28 119.619C186.922 100.31 205.846 89.1535 227.387 85.2773C248.1 81.5504 270.278 84.648 291.495 91.399ZM378.642 206.356C345.773 172.563 307.463 147.917 275.208 137.654C259.096 132.527 246.171 131.514 236.828 133.195C228.314 134.727 222.227 138.497 217.721 145.38C196.712 177.468 193.858 224.004 203.82 273.827C206.532 287.394 210.127 300.834 214.345 313.817C236.45 310.276 260.156 311.463 281.22 317.11C319.621 327.403 357.501 355.419 357.501 405.654C357.501 435.255 339.111 465.136 307.278 473.815C273.211 483.103 238.854 464.822 213.105 427.541C203.716 413.947 194.443 397.766 185.947 379.89C174.028 392.223 163.08 411.953 158.673 436.118C153.128 466.518 158.514 501.286 183.085 532.253C195.993 548.522 217.742 562.031 240.771 567.349C263.594 572.619 284.147 569.24 298.664 557.154C349.383 514.927 390.709 466.547 426.366 422.952C448.879 390.86 453.195 356.06 445.578 321.265C436.703 280.718 411.425 240.06 378.642 206.356ZM306.296 405.722C306.296 384.769 292.223 370.736 267.284 364.051C256.012 361.03 244.156 360.087 233.095 360.771C240.361 375.935 248.168 389.513 255.897 400.704C275.647 429.298 289.989 427.822 293.247 426.934C298.737 425.437 306.296 418.161 306.296 405.722Z";
export function PaseoMark({ size = 20, className }: { size?: number; className?: string }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 700 700"
fill="currentColor"
className={className}
aria-hidden="true"
>
<path transform="translate(350,350) scale(1.05) translate(-350,-350)" d={BUTTERFLY_D} />
</svg>
);
}
/** A hashed identity tile — one letter on a muted identity fill. */
export function LetterTile({
letter,
tone,
}: {
letter: string;
tone: "red" | "violet" | "amber" | "teal";
}) {
return (
<span
className={`flex size-[18px] shrink-0 items-center justify-center rounded-[5px] text-[10px] font-semibold text-white ${TILE_TONE[tone]}`}
>
{letter}
</span>
);
}
const TILE_TONE = {
red: "bg-mock-tile-red",
violet: "bg-mock-tile-violet",
amber: "bg-mock-tile-amber",
teal: "bg-mock-tile-teal",
} as const;
/** The Paseo project tile — white butterfly on the black app plate. */
export function PaseoTile() {
return (
<span className="flex size-[18px] shrink-0 items-center justify-center rounded-[5px] bg-black text-white">
<PaseoMark size={14} />
</span>
);
}
@@ -0,0 +1,155 @@
// Mobile agent chat — the counterpart of the desktop chat pane, drawn at phone
// width. Maps to the "agent chat" reference screenshot: header, collapsed slash
// command, muted tool-call rows, an assistant turn, the diff-stat pill, and the
// composer. Presentational only; colors come from the `--color-mock-*` tokens.
import {
AudioLines,
ChevronDown,
Copy,
LoaderCircle,
Menu,
Mic,
MoreHorizontal,
PanelRight,
Play,
Plus,
Server,
Split,
SquareTerminal,
} from "lucide-react";
import type * as React from "react";
import { CodexIcon } from "../icons";
const TOOL_ROWS = [
"sed -n '321,680p' docs/release.md",
'"for p in highlight relay protocol client plu…',
"npx eas workflow:view 01a0493d-01cd-77…",
"gh release view v0.7.0-beta.2 --json name…",
"gh run list --commit 4e60c2880b4c9ebd…",
"docker buildx imagetools inspect ghcr.io/g…",
];
function Header() {
return (
<div className="flex shrink-0 items-center gap-[14px] px-[18px] pt-[6px] pb-[12px]">
<Menu size={22} className="shrink-0 text-mock-fg" strokeWidth={1.9} />
<span className="min-w-0 flex-1">
<span className="block text-[18px] font-semibold leading-[21px] text-mock-fg">Paseo</span>
<span className="mt-[2px] flex items-center gap-[5px] text-[13px] leading-[16px] text-mock-fg-muted">
<span className="shrink-0">paseo ·</span>
<Server size={12} className="shrink-0" />
<span className="truncate">Mohameds-MacBook-Pro.l…</span>
</span>
</span>
<span className="flex shrink-0 items-center gap-[17px] text-mock-fg-muted">
<MoreHorizontal size={20} />
<Play size={19} strokeWidth={1.8} />
<PanelRight size={19} strokeWidth={1.8} />
</span>
</div>
);
}
function SlashRow() {
return (
<div className="flex shrink-0 items-center gap-[11px] border-y border-mock-border px-[18px] py-[13px]">
<CodexIcon size={19} className="shrink-0 text-mock-fg" />
<span className="flex-1 text-[17px] text-mock-fg">/release-beta</span>
<ChevronDown size={19} className="shrink-0 text-mock-fg-muted" />
</div>
);
}
function ToolRow({ command }: { command: string }) {
return (
<div className="flex items-center gap-[13px] py-[7px] text-mock-fg-muted">
<SquareTerminal size={19} className="shrink-0" strokeWidth={1.7} />
<span className="shrink-0 text-[16px]">Shell</span>
<span className="min-w-0 flex-1 truncate text-[16px]">{command}</span>
</div>
);
}
function InlineCode({ children }: { children: React.ReactNode }) {
return (
<code className="rounded-[4px] bg-mock-surface2 px-[5px] py-[1px] font-mono text-[13.5px] text-mock-fg">
{children}
</code>
);
}
function Bullet({ children }: { children: React.ReactNode }) {
return (
<div className="flex gap-[12px] text-[16.5px] leading-[24px] text-mock-fg">
<span className="text-mock-fg-muted">•</span>
<span className="min-w-0 flex-1">{children}</span>
</div>
);
}
function Transcript() {
return (
<div className="px-[18px]">
{TOOL_ROWS.map((command) => (
<ToolRow key={command} command={command} />
))}
<div className="my-[14px] h-px bg-mock-border" />
<p className="text-[16.5px] leading-[24px] text-mock-fg">Still in progress:</p>
<div className="mt-[14px] space-y-[7px]">
<Bullet>npm tags and release notes are correct</Bullet>
<Bullet>
Docker beta image is published; <InlineCode>latest</InlineCode> remains unchanged
</Bullet>
<Bullet>iOS build succeeded; TestFlight distribution/Beta App Review is running</Bullet>
<Bullet>Desktop and Android assets are still building</Bullet>
</div>
<p className="mt-[14px] text-[16.5px] leading-[24px] text-mock-fg">
The heartbeat remains active.
</p>
<div className="flex items-center gap-[20px] pt-[16px] text-mock-fg-muted">
<Copy size={18} strokeWidth={1.8} />
<Split size={18} strokeWidth={1.8} />
</div>
</div>
);
}
function Composer() {
return (
<div className="shrink-0 px-[18px] pt-[10px] pb-[30px]">
<div className="mb-[16px]">
<span className="inline-flex items-center gap-[6px] rounded-full border border-mock-border-accent px-[13px] py-[6px] text-[15px] tabular-nums">
<span className="text-mock-success">+2,173</span>
<span className="text-mock-danger">-54</span>
</span>
</div>
<div className="rounded-[22px] border border-mock-border-accent bg-mock-surface1 px-[17px] pt-[15px] pb-[13px]">
<span className="block text-[16px] text-mock-fg-xmuted">Message, @files, /commands</span>
<div className="flex items-center gap-[13px] pt-[26px]">
<Plus size={20} className="shrink-0 text-mock-fg-muted" strokeWidth={1.9} />
<CodexIcon size={19} className="shrink-0 text-mock-fg" />
<span className="shrink-0 whitespace-nowrap text-[16px] text-mock-fg">GPT-5.6-Sol</span>
<span className="shrink-0 whitespace-nowrap text-[16px] text-mock-fg-muted">Medium</span>
<span className="flex-1" />
<LoaderCircle size={19} className="shrink-0 text-mock-fg-muted" strokeWidth={1.8} />
<Mic size={19} className="shrink-0 text-mock-fg-muted" strokeWidth={1.8} />
<AudioLines size={19} className="shrink-0 text-mock-fg-muted" strokeWidth={1.8} />
</div>
</div>
</div>
);
}
export function MobileChat() {
return (
<>
<Header />
<SlashRow />
<div className="min-h-0 flex-1 overflow-hidden pt-[6px]">
<Transcript />
</div>
<Composer />
</>
);
}
@@ -0,0 +1,166 @@
// Mobile diff view — counterpart of the desktop diff pane. Maps to the "diff"
// reference screenshot: Changes/Files tabs, the branch + uncommitted scope rows,
// a new-file header, a hunk header, and the added lines of atoms.tsx (every row
// an addition, so the whole gutter runs green). Reuses the shared `Code`
// highlighter and the `--color-mock-diff-*` tokens.
import { ChevronDown, GitBranch, MoreHorizontal, RefreshCw, SquarePlus, X } from "lucide-react";
import { Code } from "../atoms";
import { ReactFileIcon } from "../icons";
// Every visible line of the new atoms.tsx — all additions. Long lines are
// clipped by the phone width exactly as they are in the app.
const ADDED_LINES: { n: number; text: string }[] = [
{ n: 1, text: "// Shared bits of the Paseo UI, drawn small. Every" },
{ n: 2, text: "// no state, no interactivity. Colors come from th" },
{ n: 3, text: "// styles.css, which are copied from the app's def" },
{ n: 4, text: "" },
{ n: 5, text: 'import type * as React from "react";' },
{ n: 6, text: "" },
{ n: 7, text: "/**" },
{ n: 8, text: " * The `+12 −18` footnote. A diff stat is a *statu" },
{ n: 9, text: " * uses statusSuccess/statusDanger — see the comme" },
{ n: 10, text: " * packages/app/src/styles/theme.ts." },
{ n: 11, text: " */" },
{ n: 12, text: "export function DiffStat({ add, remove }: { add: st" },
{ n: 13, text: " return (" },
{ n: 14, text: ' <span className="flex shrink-0 items-center ga' },
{ n: 15, text: ' <span className="text-mock-success">+{add}</s' },
{ n: 16, text: ' <span className="text-mock-danger">-{remove}<' },
{ n: 17, text: " </span>" },
{ n: 18, text: " );" },
{ n: 19, text: "}" },
{ n: 20, text: "" },
{ n: 21, text: 'export type DotTone = "success" | "danger" | "warni' },
{ n: 22, text: "" },
{ n: 23, text: "const DOT_TONE: Record<DotTone, string> = {" },
{ n: 24, text: ' success: "bg-mock-dot-success",' },
{ n: 25, text: ' danger: "bg-mock-dot-danger",' },
{ n: 26, text: ' warning: "bg-mock-dot-warning",' },
{ n: 27, text: ' running: "bg-mock-dot-running",' },
{ n: 28, text: ' idle: "bg-mock-surface3",' },
{ n: 29, text: "};" },
{ n: 30, text: "" },
{ n: 31, text: "/** The 6pt disc on a sidebar row that carries the" },
{ n: 32, text: "export function StatusDot({ tone }: { tone: DotTone" },
{ n: 33, text: " return <span className={`size-[7px] shrink-0 rou" },
];
function Tabs() {
return (
<div className="flex shrink-0 items-center gap-[16px] px-[18px] pt-[4px] pb-[14px]">
<div className="flex items-center gap-[6px] rounded-[11px] bg-mock-surface2 p-[3px]">
<span className="rounded-[8px] bg-mock-fg px-[15px] py-[6px] text-[16px] font-semibold text-mock-surface0">
Changes
</span>
<span className="px-[13px] py-[6px] text-[16px] text-mock-fg-muted">Files</span>
</div>
<span className="flex-1" />
<X size={22} className="text-mock-fg-muted" strokeWidth={1.9} />
</div>
);
}
function BranchRow() {
return (
<div className="flex shrink-0 items-center border-t border-mock-border px-[18px] py-[11px]">
<span className="flex items-center gap-[6px] text-[16px] text-mock-fg">
main
<ChevronDown size={16} className="text-mock-fg-muted" />
</span>
<span className="flex-1" />
<span className="flex items-center gap-[6px] rounded-[8px] border border-mock-border-accent px-[10px] py-[5px] text-mock-fg-muted">
<GitBranch size={15} />
<ChevronDown size={14} />
</span>
</div>
);
}
function ScopeRow() {
return (
<div className="flex shrink-0 items-center gap-[12px] border-y border-mock-border px-[18px] py-[11px]">
<span className="flex items-center gap-[5px] text-[16px] text-mock-fg">
Uncommitted
<ChevronDown size={16} className="text-mock-fg-muted" />
</span>
<span className="flex items-center gap-[6px] text-[15px] tabular-nums">
<span className="text-mock-success">+2,173</span>
<span className="text-mock-danger">-54</span>
</span>
<span className="flex-1" />
<span className="flex items-center gap-[15px] text-mock-fg-muted">
<RefreshCw size={17} strokeWidth={1.8} />
<MoreHorizontal size={19} />
</span>
</div>
);
}
function FileHeader() {
return (
<div className="flex shrink-0 items-center gap-[9px] px-[18px] py-[11px]">
<ReactFileIcon size={15} />
<span className="shrink-0 text-[16px] text-mock-fg">atoms.tsx</span>
<span className="min-w-0 flex-1 truncate text-[14px] text-mock-fg-muted">
packages/website/src/componen…
</span>
<span className="flex shrink-0 items-center gap-[3px] text-[14px] tabular-nums">
<span className="text-mock-success">+175</span>
<span className="text-mock-fg-xmuted">-0</span>
</span>
<SquarePlus size={17} className="shrink-0 text-mock-fg-muted" strokeWidth={1.7} />
</div>
);
}
function DiffLine({ n, text }: { n: number; text: string }) {
return (
<div className="flex items-center bg-mock-diff-add" style={ROW_STYLE}>
<span
className="shrink-0 pr-[10px] text-right font-mono text-[13px] tabular-nums text-mock-success"
style={GUTTER_STYLE}
>
{n}
</span>
<span className="min-w-0 flex-1 overflow-hidden whitespace-pre pl-[12px] font-mono text-[14px] leading-[26px]">
<Code line={text} />
</span>
</div>
);
}
const ROW_H = 26;
const GUTTER_W = 44;
const ROW_STYLE = { height: ROW_H };
const GUTTER_STYLE = { width: GUTTER_W };
const GUTTER_RULE_STYLE = { left: GUTTER_W };
export function MobileDiff() {
return (
<>
<Tabs />
<BranchRow />
<ScopeRow />
<FileHeader />
<div className="relative min-h-0 flex-1 overflow-hidden bg-mock-surface1">
<div
className="pointer-events-none absolute top-0 bottom-0 w-px bg-mock-border"
style={GUTTER_RULE_STYLE}
/>
<div className="flex items-center bg-mock-surface-diff-empty" style={ROW_STYLE}>
<span className="shrink-0" style={GUTTER_STYLE} />
<span className="pl-[12px] font-mono text-[13px] text-mock-fg-muted">
@@ -0,0 +1,175 @@
</span>
</div>
{ADDED_LINES.map((line) => (
<DiffLine key={line.n} n={line.n} text={line.text} />
))}
</div>
<div className="flex h-[52px] shrink-0 items-center border-t border-mock-border px-[18px] pb-[16px] text-[16px] text-mock-fg">
Commits
</div>
</>
);
}
@@ -0,0 +1,91 @@
// The iPhone frame every mobile mockup sits inside. Authored at a fixed
// "design" size — iPhone 17 Pro points, 402 x 874 — and scaled to whatever
// width its column gives it. The scale is pure CSS (`tan(atan2(100cqw, …))`,
// the same trick the desktop hero uses), so the frame stays crisp at any width
// with no JS and a correct server-rendered first paint.
import type * as React from "react";
export const PHONE_W = 402;
export const PHONE_H = 874;
const FRAME_ASPECT = { aspectRatio: `${PHONE_W} / ${PHONE_H}` };
const SCREEN_SCALE = {
width: PHONE_W,
height: PHONE_H,
transform: `scale(tan(atan2(100cqw, ${PHONE_W}px)))`,
};
/** iOS status bar: time, dynamic island, and the signal/wifi/battery cluster. */
function StatusBar({ time }: { time: string }) {
return (
<div className="relative flex h-[54px] shrink-0 items-end justify-between px-[34px] pb-[13px]">
<span className="text-[17px] font-semibold tracking-tight text-mock-fg tabular-nums">
{time}
</span>
{/* Dynamic island. */}
<div className="absolute top-[11px] left-1/2 h-[37px] w-[126px] -translate-x-1/2 rounded-full bg-black" />
<span className="flex items-center gap-[7px] text-mock-fg">
<CellularIcon />
<WifiIcon />
<BatteryIcon />
</span>
</div>
);
}
function CellularIcon() {
return (
<svg width={18} height={12} viewBox="0 0 18 12" fill="currentColor" aria-hidden="true">
<rect x={0} y={8} width={3} height={4} rx={1} />
<rect x={5} y={5.5} width={3} height={6.5} rx={1} />
<rect x={10} y={3} width={3} height={9} rx={1} />
<rect x={15} y={0.5} width={3} height={11.5} rx={1} />
</svg>
);
}
function WifiIcon() {
return (
<svg width={17} height={12} viewBox="0 0 17 12" fill="currentColor" aria-hidden="true">
<path d="M8.5 2.1c2.7 0 5.2 1.05 7.05 2.77.3.29.31.77.02 1.07l-.72.73a.74.74 0 0 1-1.04.02A7.5 7.5 0 0 0 8.5 4.6a7.5 7.5 0 0 0-5.31 2.11.74.74 0 0 1-1.04-.02l-.72-.73a.76.76 0 0 1 .02-1.07A10.2 10.2 0 0 1 8.5 2.1Z" />
<path d="M8.5 6.2c1.5 0 2.87.57 3.9 1.5.31.29.32.78.02 1.08l-.86.86a.73.73 0 0 1-1 .04 3.1 3.1 0 0 0-4.12 0 .73.73 0 0 1-1-.04l-.86-.86a.75.75 0 0 1 .02-1.08A5.83 5.83 0 0 1 8.5 6.2Z" />
<path d="M8.5 9.9c.62 0 1.17.26 1.56.68.28.3.27.77-.02 1.06l-1 1a.76.76 0 0 1-1.08 0l-1-1a.75.75 0 0 1-.02-1.06c.39-.42.94-.68 1.56-.68Z" />
</svg>
);
}
function BatteryIcon() {
return (
<svg width={27} height={13} viewBox="0 0 27 13" fill="none" aria-hidden="true">
<rect x={0.5} y={0.5} width={23} height={12} rx={3.6} stroke="currentColor" opacity={0.4} />
<rect x={2} y={2} width={20} height={9} rx={2.2} fill="currentColor" />
<rect x={25} y={4} width={1.6} height={5} rx={0.8} fill="currentColor" opacity={0.4} />
</svg>
);
}
function HomeIndicator() {
return (
<div className="pointer-events-none absolute bottom-[9px] left-1/2 h-[5px] w-[140px] -translate-x-1/2 rounded-full bg-mock-fg/30" />
);
}
export function PhoneFrame({ time, children }: { time: string; children: React.ReactNode }) {
return (
<div
className="relative w-full overflow-hidden rounded-[13.5%/6.2%] border-[3px] border-black bg-black shadow-2xl outline outline-[3px] outline-white/20"
style={FRAME_ASPECT}
>
<div className="absolute -inset-[3px] [container-type:inline-size]">
<div className="absolute top-0 left-0 origin-top-left" style={SCREEN_SCALE}>
<div className="relative flex h-[874px] w-[402px] flex-col overflow-hidden bg-mock-surface0 text-mock-fg antialiased">
<StatusBar time={time} />
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">{children}</div>
<HomeIndicator />
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,314 @@
// Mobile workspace drawer — counterpart of the desktop sidebar. Maps to the
// "sidebar" reference screenshot: the New workspace / History / Schedules nav,
// the Pinned project list with identity tiles, and the Workspaces list split
// into Working and Done groups. Colors come from the `--color-mock-*` tokens.
import {
CalendarClock,
CircleCheck,
CircleDot,
CircleHelp,
Columns2,
GitPullRequest,
History,
House,
Plus,
Search,
Server,
Settings,
Settings2,
SquarePlus,
X,
} from "lucide-react";
import type * as React from "react";
import { LetterTile, PaseoTile } from "./atoms";
// Icons passed as props are hoisted so they are not rebuilt on every render.
const NEW_WORKSPACE_ICON = <Plus size={19} strokeWidth={2} />;
const NEW_WORKSPACE_CLOSE = <X size={20} className="text-mock-fg-muted" strokeWidth={1.9} />;
const HISTORY_ICON = <History size={19} strokeWidth={1.8} />;
const SCHEDULES_ICON = <CalendarClock size={19} strokeWidth={1.8} />;
const WORKING_ICON = <CircleDot size={19} className="text-mock-dot-running" strokeWidth={1.9} />;
const READY_ICON = <CircleCheck size={19} className="text-mock-success" strokeWidth={1.9} />;
const DONE_ICON = <CircleCheck size={19} className="text-mock-fg-muted" strokeWidth={1.9} />;
function NavRow({
icon,
label,
trailing,
}: {
icon: React.ReactNode;
label: string;
trailing?: React.ReactNode;
}) {
return (
<div className="flex items-center gap-[14px] px-[20px] py-[11px]">
<span className="text-mock-fg-muted">{icon}</span>
<span className="flex-1 text-[17px] text-mock-fg">{label}</span>
{trailing}
</div>
);
}
interface RowData {
key: string;
icon: React.ReactNode;
title: string;
time: string;
host?: string;
pr?: string;
passed?: boolean;
selected?: boolean;
}
/** The workspace project icon — the same filled tile as the pinned rows, with
* an optional running dot. Uses PaseoTile so pinned and workspace icons match. */
function WorkspaceGlyph({ running, white }: { running?: boolean; white?: boolean }) {
return (
<span className="relative flex size-[18px] items-center justify-center">
{white ? <span className="size-[18px] rounded-[5px] bg-mock-fg" /> : <PaseoTile />}
{running ? (
<span className="absolute -right-[1px] -bottom-[1px] size-[7px] rounded-full border-[1.5px] border-mock-surface0 bg-mock-dot-running" />
) : null}
</span>
);
}
function BlankPageGlyph({ running }: { running?: boolean }) {
return (
<span className="relative flex size-[18px] items-center justify-center">
<img src="/blank-page.svg" alt="" className="size-[18px]" />
{running ? (
<span className="absolute -right-[1px] -bottom-[1px] size-[7px] rounded-full border-[1.5px] border-mock-surface0 bg-mock-dot-running" />
) : null}
</span>
);
}
function WorkspaceMeta({ host, pr, passed }: Pick<RowData, "host" | "pr" | "passed">) {
if (!host && !pr) return null;
return (
<span className="mt-[3px] flex items-center gap-[6px] text-[12.5px] text-mock-fg-muted">
{host ? (
<span className="flex items-center gap-[4px] text-mock-host-teal">
<Server size={12} />
{host}
</span>
) : null}
{host && pr ? <span className="text-mock-fg-xmuted">·</span> : null}
{pr ? (
<span className="flex items-center gap-[4px] tabular-nums">
<GitPullRequest size={12} />
{pr}
</span>
) : null}
{passed ? (
<>
<span className="text-mock-fg-xmuted">·</span>
<span className="flex items-center gap-[4px] text-mock-success">
<CircleCheck size={13} />
passed
</span>
</>
) : null}
</span>
);
}
const PINNED: RowData[] = [
{
key: "skills-dotfiles",
icon: <LetterTile letter="S" tone="red" />,
title: "skills / dotfiles",
time: "1h",
},
];
const WORKING: RowData[] = [
{
key: "safe-area",
icon: <WorkspaceGlyph running />,
title: "Fix commit safe area in emulators",
time: "4m",
},
{
key: "blank-page-typing",
icon: <BlankPageGlyph running />,
title: "Improve editor typing latency",
time: "18m",
},
];
const READY: RowData[] = [
{
key: "paseo-homepage",
icon: <WorkspaceGlyph />,
title: "Paseo homepage",
time: "now",
pr: "3981",
passed: true,
selected: true,
},
{
key: "blank-page-titles",
icon: <BlankPageGlyph />,
title: "Add automatic page titles",
time: "12m",
pr: "761",
passed: true,
},
];
const DONE: RowData[] = [
{
key: "beta",
icon: <WorkspaceGlyph />,
title: "Release v0.7.0-beta.2",
time: "2h",
},
{
key: "blank-page-focus",
icon: <BlankPageGlyph />,
title: "Preserve focus between pages",
time: "3h",
pr: "750",
passed: true,
},
{
key: "permissions",
icon: <WorkspaceGlyph />,
title: "Design semantic permission sy…",
time: "7h",
host: "my-server",
pr: "3981",
passed: true,
},
{
key: "modules",
icon: <WorkspaceGlyph />,
title: "Plan modules and abstractions",
time: "1d",
pr: "3884",
passed: true,
},
{ key: "resume", icon: <WorkspaceGlyph />, title: "Resume in-progress work", time: "3d" },
{ key: "undefined", icon: <WorkspaceGlyph />, title: "Undefined task", time: "3d" },
{
key: "diagnose",
icon: <WorkspaceGlyph />,
title: "Diagnose cloud/Codex issue re…",
time: "4d",
},
{
key: "create-workspace",
icon: <WorkspaceGlyph />,
title: "Diagnose create_workspace 37…",
time: "4d",
},
{ key: "taps", icon: <WorkspaceGlyph />, title: "Diagnose tap interactions", time: "4d" },
{ key: "clarify", icon: <WorkspaceGlyph />, title: "Clarify task requirements", time: "5d" },
{ key: "abc", icon: <WorkspaceGlyph />, title: "Task for abcdefghi", time: "5d" },
];
/** One row — every pinned project and every workspace renders through this. */
function Row({
icon,
title,
time,
host,
pr,
passed,
selected,
indented = false,
}: RowData & { indented?: boolean }) {
return (
<div
className={`flex items-center gap-[12px] rounded-[10px] py-[9px] pr-[12px] ${
indented ? "pl-[24px]" : "pl-[12px]"
} ${selected ? "bg-mock-surface1 ring-1 ring-mock-border-accent" : ""}`}
>
<span className="flex size-[18px] shrink-0 items-center justify-center">{icon}</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[16px] leading-[20px] text-mock-fg">{title}</span>
<WorkspaceMeta host={host} pr={pr} passed={passed} />
</span>
<span className="shrink-0 self-start pt-[2px] text-[13px] text-mock-fg-xmuted">{time}</span>
</div>
);
}
function GroupHeader({ icon, label }: { icon: React.ReactNode; label: string }) {
return (
<div className="flex items-center gap-[11px] px-[20px] pt-[16px] pb-[8px]">
{icon}
<span className="text-[15px] text-mock-fg-muted">{label}</span>
</div>
);
}
export function MobileSidebar() {
return (
<div className="flex min-h-0 flex-1 flex-col bg-mock-sidebar">
<div className="pt-[4px]">
<NavRow icon={NEW_WORKSPACE_ICON} label="New workspace" trailing={NEW_WORKSPACE_CLOSE} />
<NavRow icon={HISTORY_ICON} label="History" />
<NavRow icon={SCHEDULES_ICON} label="Schedules" />
</div>
<div className="mt-[4px] h-px bg-mock-border" />
<div className="min-h-0 flex-1 overflow-hidden">
<div className="px-[20px] pt-[16px] pb-[6px] text-[15px] text-mock-fg-muted">Pinned</div>
<div className="px-[8px]">
{PINNED.map(({ key, ...row }) => (
<Row key={key} {...row} />
))}
</div>
<div className="flex items-center px-[20px] pt-[26px] pb-[2px]">
<span className="flex-1 text-[15px] text-mock-fg-muted">Workspaces</span>
<span className="flex items-center gap-[18px] text-mock-fg-muted">
<Search size={15} strokeWidth={1.8} />
<Settings2 size={15} strokeWidth={1.8} />
</span>
</div>
<GroupHeader icon={READY_ICON} label="Ready to review" />
<div className="px-[8px]">
{READY.map(({ key, ...row }) => (
<Row key={key} {...row} indented />
))}
</div>
<GroupHeader icon={WORKING_ICON} label="Working" />
<div className="px-[8px]">
{WORKING.map(({ key, ...row }) => (
<Row key={key} {...row} indented />
))}
</div>
<GroupHeader icon={DONE_ICON} label="Done" />
<div className="px-[8px]">
{DONE.map(({ key, ...row }) => (
<Row key={key} {...row} indented />
))}
</div>
</div>
<div className="h-px shrink-0 bg-mock-border" />
<div className="flex h-[64px] shrink-0 items-center px-[20px] pb-[16px] text-mock-fg-muted">
<span className="flex flex-1 items-center gap-[10px] text-[16px] text-mock-fg">
<SquarePlus size={19} strokeWidth={1.8} />
Add project
</span>
<span className="flex items-center gap-[22px]">
<Columns2 size={20} strokeWidth={1.8} />
<House size={20} strokeWidth={1.8} />
<CircleHelp size={21} strokeWidth={1.8} />
<Settings size={20} strokeWidth={1.8} />
</span>
</div>
</div>
);
}
@@ -0,0 +1,15 @@
/**
* Motion presets, hoisted to module scope because a fresh object literal in a
* JSX prop re-renders the subtree on every parent render (and the lint rule that
* enforces it).
*
* The mockup only ever cross-fades and resizes. No springs — a hero that bounces
* reads as a toy.
*/
export const FADE_IN = { opacity: 1 };
export const FADE_OUT = { opacity: 0 };
const EASE_OUT: [number, number, number, number] = [0.22, 0.61, 0.36, 1];
export const MOCKUP_TRANSITION = { duration: 0.34, ease: EASE_OUT };
export const INSTANT_TRANSITION = { duration: 0 };
@@ -0,0 +1,13 @@
import { DiffRows, REVIEW_DIFF_ROWS, ScopeRow } from "./diff";
// --- review -----------------------------------------------------------------
/** The selected Changes-tree file, open in its own workspace tab. */
export function DiffReviewPane() {
return (
<div className="flex min-h-0 flex-1 flex-col bg-mock-surface0">
<ScopeRow label="Uncommitted" />
<DiffRows rows={REVIEW_DIFF_ROWS} />
</div>
);
}
@@ -0,0 +1,298 @@
import {
CircleCheck,
ChevronDown,
ExternalLink,
FileDiff,
Files,
GitPullRequest,
PanelRight,
Puzzle,
RefreshCw,
Rows3,
} from "lucide-react";
import type * as React from "react";
import { ChangesBranchRow, ChangesCommitsRow, ChangesScopeRow, ExplorerChangesTree } from "./diff";
import { RIGHT_PANEL_W, TABBAR_H, TITLEBAR_H } from "./geometry";
import type { MockupStateId } from "./states";
const TABBAR_STYLE = { height: TITLEBAR_H };
const SUBHEADER_STYLE = { height: TABBAR_H };
const PANEL_STYLE = { width: RIGHT_PANEL_W };
interface PanelTab {
label: string;
icon: React.ReactNode;
}
function PanelTabs({ tabs, active }: { tabs: PanelTab[]; active: number }) {
return (
<div
className="flex shrink-0 items-center gap-[4px] border-b border-mock-border px-[8px]"
style={TABBAR_STYLE}
>
{tabs.map((tab, index) => (
<span
key={tab.label}
className={`flex items-center gap-[6px] rounded-[6px] px-[9px] py-[3px] text-[12px] ${
index === active ? "bg-mock-surface2 text-mock-fg" : "text-mock-fg-muted"
}`}
>
{tab.icon}
{tab.label}
</span>
))}
<span className="flex-1" />
<span className="flex size-[24px] items-center justify-center text-mock-fg-muted">
<PanelRight size={14} />
</span>
</div>
);
}
function PanelShell({
tabs,
active,
children,
}: {
tabs: PanelTab[];
active: number;
children: React.ReactNode;
}) {
return (
<div
className="flex h-full shrink-0 flex-col overflow-hidden bg-mock-sidebar"
style={PANEL_STYLE}
>
<PanelTabs tabs={tabs} active={active} />
{children}
</div>
);
}
const PR_TAB_GLYPH = <GitPullRequest size={12} />;
const CHANGES_TAB_GLYPH = <FileDiff size={12} />;
const FILES_TAB_GLYPH = <Files size={12} />;
// --- Changes ---------------------------------------------------------------
const CHANGES_TABS: PanelTab[] = [
{ label: "Files", icon: FILES_TAB_GLYPH },
{ label: "Changes", icon: CHANGES_TAB_GLYPH },
{ label: "3981", icon: PR_TAB_GLYPH },
];
function ChangesPanel({ selectedFile }: { selectedFile?: string }) {
return (
<PanelShell tabs={CHANGES_TABS} active={1}>
<ChangesBranchRow />
<ChangesScopeRow />
<ExplorerChangesTree selectedFile={selectedFile} />
<ChangesCommitsRow />
</PanelShell>
);
}
// --- Ship (GitHub PR checks) ------------------------------------------------
const SHIP_TABS: PanelTab[] = CHANGES_TABS;
const CHECKS: { name: string; context: string; time: string }[] = [
{ name: "changes", context: "CI", time: "11s" },
{ name: "setup", context: "Docker", time: "5s" },
{ name: "build", context: "Docker", time: "16m 18s" },
{ name: "format", context: "CI", time: "1m 23s" },
{ name: "build-desktop-darwin", context: "Nix", time: "6m 40s" },
{ name: "lint", context: "CI", time: "1m 43s" },
{ name: "typecheck", context: "CI", time: "2m 19s" },
{ name: "server-tests (ubuntu-latest)", context: "CI", time: "7m 15s" },
{ name: "app-tests (macos)", context: "CI", time: "5m 02s" },
];
function CheckGlyph() {
return <CircleCheck size={14} className="shrink-0 text-mock-success" />;
}
function ShipPanel() {
return (
<PanelShell tabs={SHIP_TABS} active={2}>
<div
className="flex shrink-0 items-center border-b border-mock-border px-[16px]"
style={SUBHEADER_STYLE}
>
<span className="flex items-center gap-[6px] text-[12px] text-mock-fg-muted">
<ExternalLink size={13} />
View
</span>
<span className="flex-1" />
<RefreshCw size={13} className="text-mock-fg-muted" />
</div>
<div className="px-[16px] pt-[13px] pb-[11px]">
<div className="text-[13.5px] leading-[18px] text-mock-fg">
Lay the foundation for granular daemon permissions{" "}
<span className="text-mock-fg-muted">#3981</span>
</div>
<div className="flex items-center gap-[7px] pt-[6px] text-[11.5px]">
<span className="flex items-center gap-[5px] text-mock-success">
<GitPullRequest size={12} />
Open
</span>
<span className="text-mock-fg-muted">getpaseo/paseo</span>
</div>
</div>
<div className="flex items-center gap-[11px] bg-mock-surface1 px-[16px] py-[11px]">
<CheckGlyph />
<span className="min-w-0 flex-1">
<span className="block text-[13px] font-medium text-mock-fg">All checks have passed</span>
<span className="block text-[11px] text-mock-fg-muted">
9 successful, 13 skipped checks
</span>
</span>
<ChevronDown size={14} className="text-mock-fg-muted" />
</div>
<div className="min-h-0 flex-1 overflow-hidden">
<div className="flex items-center gap-[6px] px-[16px] pt-[11px] pb-[4px] text-[11.5px] text-mock-fg-muted">
9 successful checks
<ChevronDown size={12} />
</div>
{CHECKS.map((check) => (
<div key={check.name} className="flex items-center gap-[9px] px-[16px] py-[5px]">
<CheckGlyph />
<span className="shrink-0 truncate text-[12.5px] text-mock-fg">{check.name}</span>
<span className="text-[11px] text-mock-fg-muted">{check.context}</span>
<span className="flex-1" />
<span className="shrink-0 text-[11px] tabular-nums text-mock-fg-muted">
{check.time}
</span>
</div>
))}
<div className="flex items-center gap-[6px] px-[16px] pt-[13px] pb-[4px] text-[11.5px] text-mock-fg-muted">
<ChevronDown size={12} />
Activity
</div>
<div className="px-[16px] pt-[2px] text-[12px] text-mock-fg-muted">No activity yet</div>
</div>
</PanelShell>
);
}
// --- Plugins (invented custom dashboard) ------------------------------------
// No real reference exists; this reads as a user-built native surface plugin.
const PLUGIN_TABS: PanelTab[] = [
...CHANGES_TABS,
{ label: "Release Radar", icon: <Puzzle size={12} /> },
];
const TILES: { label: string; value: string; tile: string }[] = [
{ label: "Merged · 7d", value: "12", tile: "text-mock-tile-teal" },
{ label: "In review", value: "4", tile: "text-mock-tile-amber" },
{ label: "Failing", value: "1", tile: "text-mock-tile-red" },
];
// Weekly merge counts — a small bar chart the plugin author drew. Heights and
// their style objects are precomputed so the render loop allocates nothing.
const BARS: { id: string; style: { height: string } }[] = [
{ id: "mon", style: { height: "35px" } },
{ id: "tue", style: { height: "17px" } },
{ id: "wed", style: { height: "44px" } },
{ id: "thu", style: { height: "26px" } },
{ id: "fri", style: { height: "53px" } },
{ id: "sat", style: { height: "35px" } },
{ id: "sun", style: { height: "62px" } },
];
const RECENT: { title: string; meta: string; tone: string }[] = [
{
title: "Connect to remote daemons over SSH",
meta: "#3989 · 20m ago",
tone: "bg-mock-dot-success",
},
{
title: "Keep restored workspaces out of loops",
meta: "#3987 · 1h ago",
tone: "bg-mock-dot-success",
},
{
title: "Honor configured commit signing",
meta: "#3976 · reviewing",
tone: "bg-mock-dot-warning",
},
];
function PluginDashboard() {
return (
<PanelShell tabs={PLUGIN_TABS} active={3}>
<div
className="flex shrink-0 items-center gap-[8px] border-b border-mock-border px-[16px]"
style={SUBHEADER_STYLE}
>
<Puzzle size={14} className="text-mock-accent-bright" />
<span className="text-[13px] font-medium text-mock-fg">Release Radar</span>
<span className="text-[11px] text-mock-fg-muted">plugin</span>
<span className="flex-1" />
<RefreshCw size={13} className="text-mock-fg-muted" />
</div>
<div className="min-h-0 flex-1 overflow-hidden px-[16px] pt-[14px]">
<div className="flex gap-[9px]">
{TILES.map((tile) => (
<div
key={tile.label}
className="flex-1 rounded-[10px] border border-mock-border-accent bg-mock-surface1 px-[12px] py-[11px]"
>
<div className={`text-[22px] leading-[24px] font-semibold tabular-nums ${tile.tile}`}>
{tile.value}
</div>
<div className="pt-[3px] text-[10.5px] text-mock-fg-muted">{tile.label}</div>
</div>
))}
</div>
<div className="mt-[16px] rounded-[10px] border border-mock-border-accent bg-mock-surface1 px-[14px] pt-[12px] pb-[13px]">
<div className="flex items-center justify-between text-[11px] text-mock-fg-muted">
<span>Merges · last 7 days</span>
<span className="flex items-center gap-[5px] text-mock-tile-teal">
<Rows3 size={11} />
+18%
</span>
</div>
<div className="flex h-[62px] items-end gap-[8px] pt-[12px]">
{BARS.map((bar) => (
<span
key={bar.id}
className="flex-1 rounded-[3px] bg-mock-accent-bright/70"
style={bar.style}
/>
))}
</div>
</div>
<div className="mt-[16px] text-[11px] text-mock-fg-muted">Recently merged</div>
<div className="pt-[6px]">
{RECENT.map((item) => (
<div key={item.title} className="flex items-center gap-[10px] py-[7px]">
<span className={`size-[7px] shrink-0 rounded-full ${item.tone}`} />
<span className="min-w-0 flex-1">
<span className="block truncate text-[12px] leading-[16px] text-mock-fg">
{item.title}
</span>
<span className="block text-[10.5px] leading-[13px] text-mock-fg-muted">
{item.meta}
</span>
</span>
</div>
))}
</div>
</div>
</PanelShell>
);
}
export function RightPanel({ state }: { state: MockupStateId }) {
if (state === "ship") return <ShipPanel />;
if (state === "extend") return <PluginDashboard />;
return <ChangesPanel />;
}
@@ -0,0 +1,338 @@
import { AnimatePresence, motion } from "framer-motion";
import {
CalendarClock,
CircleCheck,
CircleDot,
CircleHelp,
GitPullRequest,
History,
House,
PanelBottom,
PanelLeft,
Plus,
Puzzle,
Search,
Server,
Settings,
Settings2,
SquarePlus,
} from "lucide-react";
import type * as React from "react";
import { SIDEBAR_W, TITLEBAR_H } from "./geometry";
import { PaseoMark } from "./icons";
import { FADE_IN, FADE_OUT } from "./motion";
import type { MockupStateId } from "./states";
const SIDEBAR_STYLE = { width: SIDEBAR_W };
const TITLEBAR_STYLE = { height: TITLEBAR_H };
// --- data -------------------------------------------------------------------
type Avatar =
| { kind: "paseo" }
| { kind: "blank-page" }
| { kind: "tile"; letter: string; tile: string };
interface WorkspaceRow {
id: string;
title: string;
avatar: Avatar;
time: string;
host?: string;
pr?: string;
passed?: boolean;
running?: boolean;
selected?: boolean;
}
const PASEO: Avatar = { kind: "paseo" };
const BLANK_PAGE: Avatar = { kind: "blank-page" };
const PINNED_ROWS: WorkspaceRow[] = [
{
id: "pin-skills-dotfiles",
title: "skills / dotfiles",
avatar: { kind: "tile", letter: "S", tile: "bg-mock-tile-red" },
time: "1h",
},
];
const WORKING_ROWS: WorkspaceRow[] = [
{
id: "safe-area",
title: "Fix commit safe area in emulators",
avatar: PASEO,
time: "4m",
running: true,
},
{
id: "blank-page-typing",
title: "Improve editor typing latency",
avatar: BLANK_PAGE,
time: "18m",
running: true,
},
];
const READY_TO_REVIEW_ROWS: WorkspaceRow[] = [
{
id: "lineage",
title: "Paseo homepage",
avatar: PASEO,
pr: "3981",
passed: true,
time: "now",
selected: true,
},
{
id: "blank-page-titles",
title: "Add automatic page titles",
avatar: BLANK_PAGE,
pr: "761",
passed: true,
time: "12m",
},
];
const DONE_ROWS: WorkspaceRow[] = [
{ id: "beta", title: "Release v0.7.0-beta.2", avatar: PASEO, time: "2h" },
{
id: "blank-page-focus",
title: "Preserve focus between pages",
avatar: BLANK_PAGE,
pr: "750",
passed: true,
time: "3h",
},
{
id: "perms",
title: "Design semantic permission sy…",
avatar: PASEO,
host: "my-server",
pr: "3981",
passed: true,
time: "7h",
},
{
id: "modules",
title: "Plan modules and abstractions",
avatar: PASEO,
pr: "3884",
passed: true,
time: "1d",
},
{ id: "resume", title: "Resume in-progress work", avatar: PASEO, time: "3d" },
{ id: "undef", title: "Undefined task", avatar: PASEO, time: "3d" },
{ id: "codex-issue", title: "Diagnose cloud/Codex issue re…", avatar: PASEO, time: "4d" },
{ id: "cws", title: "Diagnose create_workspace 37…", avatar: PASEO, time: "4d" },
{ id: "taps", title: "Diagnose tap interactions", avatar: PASEO, time: "4d" },
{ id: "clarify", title: "Clarify task requirements", avatar: PASEO, time: "5d" },
{ id: "abc", title: "Task for abcdefghi", avatar: PASEO, time: "5d" },
];
// --- pieces -----------------------------------------------------------------
// Icons passed as props are hoisted so they are not rebuilt on every render.
const NEW_WORKSPACE_ICON = <Plus size={14} />;
const HISTORY_ICON = <History size={14} />;
const SCHEDULES_ICON = <CalendarClock size={14} />;
const PLUGIN_ICON = <Puzzle size={14} />;
const WORKING_ICON = <CircleDot size={16} className="text-mock-dot-running" />;
const READY_TO_REVIEW_ICON = <CircleCheck size={16} className="text-mock-success" />;
const DONE_ICON = <CircleCheck size={16} className="text-mock-fg-xmuted" />;
function NavItem({
icon,
label,
active,
}: {
icon: React.ReactNode;
label: string;
active?: boolean;
}) {
return (
<div
className={`flex h-[30px] items-center gap-[10px] rounded-[7px] px-[10px] text-[12.5px] text-mock-fg ${
active ? "bg-mock-surface1" : ""
}`}
>
<span className="text-mock-fg-muted">{icon}</span>
{label}
</div>
);
}
/** The rounded-square agent avatar, with a blue running dot on the corner. */
function AvatarIcon({ avatar }: { avatar: Avatar }) {
if (avatar.kind === "paseo") {
return (
<span className="flex size-[16px] items-center justify-center rounded-[5px] bg-black text-white">
<PaseoMark size={14} />
</span>
);
}
if (avatar.kind === "blank-page") {
return <img src="/blank-page.svg" alt="" className="size-[16px]" />;
}
return (
<span
className={`flex size-[16px] items-center justify-center rounded-[5px] text-[9px] font-semibold text-white ${avatar.tile}`}
>
{avatar.letter}
</span>
);
}
function RowAvatar({ avatar, running }: { avatar: Avatar; running?: boolean }) {
return (
<span className="relative block size-[16px] shrink-0">
<AvatarIcon avatar={avatar} />
{running ? (
<span className="absolute -right-[1px] -bottom-[1px] size-[6px] rounded-full bg-mock-dot-running ring-[1.5px] ring-mock-sidebar" />
) : null}
</span>
);
}
function RowMeta({ row }: { row: WorkspaceRow }) {
if (row.host || row.pr) {
return (
<span className="mt-[3px] flex items-center gap-[6px] text-[10.5px] leading-[13px] text-mock-fg-muted">
{row.host ? (
<span className="flex items-center gap-[4px] text-mock-host-teal">
<Server size={12} className="shrink-0" />
{row.host}
</span>
) : null}
{row.host && row.pr ? <span className="text-mock-fg-xmuted">·</span> : null}
{row.pr ? (
<span className="flex items-center gap-[4px]">
<GitPullRequest size={10} className="shrink-0" />
{row.pr}
</span>
) : null}
{row.passed ? (
<>
<span className="text-mock-fg-xmuted">·</span>
<span className="flex items-center gap-[4px] text-mock-success">
<CircleCheck size={11} className="shrink-0" />
passed
</span>
</>
) : null}
</span>
);
}
return null;
}
function Row({ row, indented = false }: { row: WorkspaceRow; indented?: boolean }) {
return (
<motion.div
layout="position"
className={`flex gap-[8px] rounded-[8px] py-[7px] pr-[9px] ${
indented ? "pl-[21px]" : "pl-[9px]"
} ${row.selected ? "bg-mock-surface1" : ""}`}
>
<span className="pt-[1px]">
<RowAvatar avatar={row.avatar} running={row.running} />
</span>
<span className="min-w-0 flex-1">
<span className="flex items-baseline gap-[8px]">
<span className="min-w-0 flex-1 truncate text-[13px] leading-[17px] text-mock-fg">
{row.title}
</span>
<span className="shrink-0 text-[11px] text-mock-fg-xmuted">{row.time}</span>
</span>
<RowMeta row={row} />
</span>
</motion.div>
);
}
function GroupHeader({ icon, label }: { icon: React.ReactNode; label: string }) {
return (
<div className="mt-[10px] mb-[3px] flex items-center gap-[8px] px-[9px] py-[7px]">
<span className="flex size-[16px] shrink-0 items-center justify-center">{icon}</span>
<span className="text-[13px] leading-[17px] text-mock-fg-muted">{label}</span>
</div>
);
}
// --- sidebar ----------------------------------------------------------------
export function Sidebar({ state }: { state: MockupStateId }) {
const extending = state === "extend";
return (
<div className="flex shrink-0 flex-col overflow-hidden bg-mock-sidebar" style={SIDEBAR_STYLE}>
{/* The sidebar is one full-height column with no header border of its own. */}
<div className="flex shrink-0 items-center gap-[10px] px-[13px]" style={TITLEBAR_STYLE}>
<div className="flex items-center gap-[7px]">
<i className="block size-[10px] rounded-full bg-[#ff5f57]" />
<i className="block size-[10px] rounded-full bg-[#febc2e]" />
<i className="block size-[10px] rounded-full bg-[#28c840]" />
</div>
<PanelLeftGlyph />
</div>
<div className="px-[8px] pt-[7px]">
<NavItem icon={NEW_WORKSPACE_ICON} label="New workspace" />
<NavItem icon={HISTORY_ICON} label="History" />
<NavItem icon={SCHEDULES_ICON} label="Schedules" />
{extending ? <NavItem icon={PLUGIN_ICON} label="Release Radar" active /> : null}
</div>
<div className="mt-[9px] h-px bg-mock-border" />
<div className="min-h-0 flex-1 overflow-hidden px-[8px]">
<div className="px-[9px] pt-[8px] text-[11px] text-mock-fg-muted">Pinned</div>
{PINNED_ROWS.map((row) => (
<Row key={row.id} row={row} />
))}
<div className="mt-[16px] flex items-center justify-between px-[9px] pb-[2px]">
<span className="text-[11px] text-mock-fg-muted">Workspaces</span>
<span className="flex items-center gap-[13px] text-mock-fg-muted">
<Search size={11} className="text-mock-fg-muted" />
<Settings2 size={11} className="text-mock-fg-muted" />
</span>
</div>
<GroupHeader icon={READY_TO_REVIEW_ICON} label="Ready to review" />
{READY_TO_REVIEW_ROWS.map((row) => (
<Row key={row.id} row={row} indented />
))}
<GroupHeader icon={WORKING_ICON} label="Working" />
<AnimatePresence initial={false} mode="popLayout">
{WORKING_ROWS.map((row) => (
<motion.div key={row.id} layout initial={FADE_OUT} animate={FADE_IN} exit={FADE_OUT}>
<Row row={row} indented />
</motion.div>
))}
</AnimatePresence>
<GroupHeader icon={DONE_ICON} label="Done" />
{DONE_ROWS.map((row) => (
<Row key={row.id} row={row} indented />
))}
</div>
<div className="h-px shrink-0 bg-mock-border" />
<div className="flex h-[44px] shrink-0 items-center gap-[8px] px-[14px] text-[12.5px] text-mock-fg-muted">
<SquarePlus size={14} />
<span className="text-mock-fg">Add project</span>
<span className="flex-1" />
<PanelBottom size={14} />
<House size={14} />
<CircleHelp size={14} />
<Settings size={14} />
</div>
</div>
);
}
function PanelLeftGlyph() {
return <PanelLeft size={15} strokeWidth={1.8} className="text-mock-fg-muted" />;
}
@@ -0,0 +1,19 @@
/**
* The states the hero mockup can be in, in pill order. Labels are copy — edit
* them here and nothing else has to change.
*/
export const MOCKUP_STATES = [
{ id: "build", label: "Build" },
{ id: "review", label: "Review" },
{ id: "ship", label: "Ship" },
{ id: "extend", label: "Extend" },
] as const;
export type MockupStateId = (typeof MOCKUP_STATES)[number]["id"];
export const DEFAULT_MOCKUP_STATE: MockupStateId = "build";
/** Which states keep a panel docked to the right of the workspace. */
export function hasRightPanel(_state: MockupStateId): boolean {
return true;
}
@@ -0,0 +1,140 @@
import {
ChevronDown,
Columns2,
Ellipsis,
FileDiff,
GitCommitHorizontal,
Globe,
PanelRight,
Play,
Plus,
SquareTerminal,
} from "lucide-react";
import type * as React from "react";
import finderIconUrl from "../../../../app/assets/images/editor-apps/finder.png";
import { TABBAR_H, TITLEBAR_H } from "./geometry";
import { ClaudeIcon, CodexIcon, GithubGlyph } from "./icons";
import { hasRightPanel, type MockupStateId } from "./states";
const TITLEBAR_STYLE = { height: TITLEBAR_H };
const TABBAR_STYLE = { height: TABBAR_H };
/** A bordered toolbar button — the real header buttons carry an outline. */
function HeaderButton({ children }: { children: React.ReactNode }) {
return (
<span className="flex h-[28px] items-center gap-[6px] rounded-[7px] border border-mock-border-accent bg-mock-surface1 px-[8px] text-[12px] text-mock-fg">
{children}
</span>
);
}
function FinderSplitButton() {
return (
<span className="flex h-[28px] items-center overflow-hidden rounded-[7px] border border-mock-border-accent bg-mock-surface1 text-mock-fg-muted">
<span className="flex h-full items-center px-[8px]">
<img src={finderIconUrl} alt="" className="size-[16px]" />
</span>
<span className="h-full w-px bg-mock-border-accent" />
<span className="flex h-full items-center px-[7px]">
<ChevronDown size={12} />
</span>
</span>
);
}
export function TitleBar({ state }: { state: MockupStateId }) {
const shipping = state === "ship";
return (
<div
className="flex shrink-0 items-center gap-[10px] border-b border-mock-border px-[16px]"
style={TITLEBAR_STYLE}
>
<span className="truncate text-[13.5px] font-medium text-mock-fg">Paseo homepage</span>
<span className="truncate text-[12.5px] text-mock-fg-muted">paseo</span>
<Ellipsis size={14} className="shrink-0 text-mock-fg-muted" />
<span className="flex-1" />
<HeaderButton>
<Play size={12} className="text-mock-fg-muted" />
<ChevronDown size={12} className="text-mock-fg-muted" />
</HeaderButton>
<FinderSplitButton />
<HeaderButton>
{shipping ? (
<>
<GithubGlyph size={13} />
Merge PR (squash)
</>
) : (
<>
<GitCommitHorizontal size={14} className="text-mock-fg-muted" />
Commit
</>
)}
<ChevronDown size={12} className="text-mock-fg-muted" />
</HeaderButton>
{!hasRightPanel(state) ? (
<span className="flex size-[26px] items-center justify-center text-mock-fg-muted">
<PanelRight size={14} />
</span>
) : null}
</div>
);
}
interface Tab {
id: string;
label: string;
icon: React.ReactNode;
}
const WORKSPACE_TABS: Tab[] = [
{ id: "codex", label: "Codex", icon: <CodexIcon size={11} /> },
{ id: "claude", label: "Claude Code", icon: <ClaudeIcon size={11} /> },
{
id: "terminal",
label: "npm run dev",
icon: <SquareTerminal size={11} />,
},
{
id: "browser",
label: "https://localhost:3000",
icon: <Globe size={11} />,
},
];
const DIFF_TAB: Tab = { id: "diff", label: "Diff", icon: <FileDiff size={11} /> };
export function TabStrip({ state }: { state: MockupStateId }) {
const review = state === "review";
const tabs = review ? [...WORKSPACE_TABS, DIFF_TAB] : WORKSPACE_TABS;
return (
<div
className="flex shrink-0 items-center gap-[5px] border-b border-mock-border pr-[16px] pl-[7px]"
style={TABBAR_STYLE}
>
{tabs.map((tab) => (
<div
key={tab.id}
className={`flex items-center gap-[7px] rounded-[7px] px-[10px] py-[5px] ${
(review ? tab.id === "diff" : tab.id === "codex")
? "bg-mock-surface2 text-mock-fg"
: "text-mock-fg-muted"
}`}
>
<span>{tab.icon}</span>
<span className="whitespace-nowrap text-[11.5px]">{tab.label}</span>
</div>
))}
<span className="flex size-[22px] items-center justify-center text-mock-fg-muted">
<Plus size={13} />
</span>
<span className="flex-1" />
<span className="flex items-center gap-[14px] text-mock-fg-muted">
<Columns2 size={13} />
<Ellipsis size={14} />
</span>
</div>
);
}
@@ -0,0 +1,118 @@
import { AnimatePresence, MotionConfig, motion, useReducedMotion } from "framer-motion";
import { AgentTranscript, Composer, PluginBuildTranscript } from "./chat";
import { DESIGN_HEIGHT, DESIGN_WIDTH, RIGHT_PANEL_W } from "./geometry";
import { FADE_IN, FADE_OUT, INSTANT_TRANSITION, MOCKUP_TRANSITION } from "./motion";
import { DiffReviewPane } from "./panes";
import { RightPanel } from "./right-panel";
import { Sidebar } from "./sidebar";
import { hasRightPanel, type MockupStateId } from "./states";
import { TabStrip, TitleBar } from "./titlebar";
const WINDOW_STYLE = { width: DESIGN_WIDTH, height: DESIGN_HEIGHT };
const PANEL_STYLE = { width: RIGHT_PANEL_W };
const PANEL_COLLAPSED = { width: 0, opacity: 0 };
const PANEL_OPEN = { width: RIGHT_PANEL_W + 1, opacity: 1 };
/** Which center pane a state shows. States sharing a key keep it mounted. */
function centerKey(state: MockupStateId): string {
if (state === "review") return "review";
return "chat";
}
function panelKey(state: MockupStateId): string {
if (state === "ship") return "ship";
if (state === "extend") return "extend";
return "changes";
}
function CenterPane({ state }: { state: MockupStateId }) {
if (state === "review") return <DiffReviewPane />;
return (
<div className="flex min-h-0 flex-1 flex-col bg-mock-surface0">
{/* Bottom-anchored: the newest turn sits on the composer, older text clips. */}
<div className="flex min-h-0 flex-1 flex-col justify-end overflow-hidden px-[27px] pb-[6px]">
{state === "extend" ? <PluginBuildTranscript /> : <AgentTranscript />}
</div>
<Composer showProgress={state !== "extend"} busy={state !== "extend"} />
</div>
);
}
/** The docked panel keeps its width while its contents swap. */
function PanelCrossfade({ state }: { state: MockupStateId }) {
return (
<div className="relative" style={PANEL_STYLE}>
<AnimatePresence initial={false}>
<motion.div
key={panelKey(state)}
initial={FADE_OUT}
animate={FADE_IN}
exit={FADE_OUT}
className="absolute inset-0"
>
<RightPanel state={state} />
</motion.div>
</AnimatePresence>
</div>
);
}
/** The middle content column: its own header, then the tab strip, then the pane. */
function CenterColumn({ state }: { state: MockupStateId }) {
return (
<div className="flex min-w-0 flex-1 flex-col">
<TitleBar state={state} />
<TabStrip state={state} />
<div className="relative min-h-0 flex-1">
<AnimatePresence initial={false}>
<motion.div
key={centerKey(state)}
initial={FADE_OUT}
animate={FADE_IN}
exit={FADE_OUT}
className="absolute inset-0 flex flex-col"
>
<CenterPane state={state} />
</motion.div>
</AnimatePresence>
</div>
</div>
);
}
/** The docked right panel, animating its width open and closed. */
function DockedPanel({ state }: { state: MockupStateId }) {
return (
<motion.div
initial={PANEL_COLLAPSED}
animate={PANEL_OPEN}
exit={PANEL_COLLAPSED}
className="relative flex shrink-0 overflow-hidden"
>
<div className="w-px shrink-0 bg-mock-border" />
<PanelCrossfade state={state} />
</motion.div>
);
}
export function MockupWindow({ state }: { state: MockupStateId }) {
const reduceMotion = useReducedMotion();
return (
<MotionConfig transition={reduceMotion ? INSTANT_TRANSITION : MOCKUP_TRANSITION}>
<div
className="relative flex overflow-hidden bg-mock-surface0 text-mock-fg antialiased"
style={WINDOW_STYLE}
>
{/* Three full-height columns, each owning its own header. The middle
header stops at the divider — it never spans over the right panel. */}
<Sidebar state={state} />
<div className="w-px shrink-0 bg-mock-border" />
<CenterColumn state={state} />
<AnimatePresence initial={false}>
{hasRightPanel(state) ? <DockedPanel key="right-panel" state={state} /> : null}
</AnimatePresence>
</div>
</MotionConfig>
);
}
+2 -2
View File
@@ -17,9 +17,9 @@ function Home() {
<LandingPage
title={
<>
Orchestrate coding agents
The control plane
<br />
from your desk and your phone
for coding agents
</>
}
subtitle="Run any coding agent from your phone, desktop, or terminal. Self-hosted, multi-provider, open source."
+59 -12
View File
@@ -672,26 +672,73 @@
--color-secondary: #252b2a;
--color-secondary-foreground: #fafafa;
/* Mockup palette (from app unistyles theme) */
/* Mockup palette. Every value below is copied from the app's default dark
theme — `paseoDarkColors` in packages/app/src/styles/theme.ts. Do not
eyeball these from a screenshot; re-read theme.ts and copy. */
/* Surfaces */
--color-mock-surface0: #181b1a;
--color-mock-surface1: #1e2120;
--color-mock-surface2: #272a29;
--color-mock-surface3: #434645;
--color-mock-surface4: #595b5b;
--color-mock-surface-diff-empty: #252827;
--color-mock-sidebar: #141716;
/* Foregrounds */
--color-mock-fg: #fafafa;
--color-mock-fg-muted: #a1a5a4;
--color-mock-border: #272a29;
--color-mock-border-accent: #313433;
--color-mock-fg-xmuted: #717574;
/* Borders and accent */
--color-mock-border: #252b2a;
--color-mock-border-accent: #2f3534;
--color-mock-accent: #20744a;
--color-mock-green: #22c55e;
--color-mock-green-400: #4ade80;
--color-mock-red: #f87171;
--color-mock-amber: #f59e0b;
--color-mock-blue: #3b82f6;
--color-mock-zinc500: #a1a5a4;
--color-mock-zinc600: #52525b;
--color-mock-diff-add: rgba(46, 160, 67, 0.15);
--color-mock-diff-remove: rgba(248, 81, 73, 0.1);
--color-mock-accent-bright: #7ccba0;
--color-mock-destructive: #c64f43;
/* Status band (dark: L=0.70). One token per signal — diff stats, PR state,
CI checks, badges. theme.ts:154-160. */
--color-mock-success: #6cb17b;
--color-mock-danger: #d8847b;
--color-mock-warning: #c09664;
--color-mock-merged: #a890d5;
/* Project identity tiles — the letter-avatar fills the mobile sidebar hashes
a project name into. Verbatim from the app's identity palette,
packages/app/src/styles/identity-colors.ts (white letter drawn on top). */
--color-mock-tile-red: #b06260;
--color-mock-tile-violet: #7a6aa8;
--color-mock-tile-amber: #8f7838;
--color-mock-tile-teal: #368080;
--color-mock-host-teal: #6cacab;
/* Status *dot* band (dark: L=0.72, chroma 90%). Sidebar row state dots only.
theme.ts:198-204. */
--color-mock-dot-success: #35c264;
--color-mock-dot-danger: #f7796d;
--color-mock-dot-warning: #db932e;
--color-mock-dot-running: #5caaf6;
/* Diff rows. The row fills are the status colors at the alphas the real diff
renderer uses — git/diff-document/palette.ts:14-15. The *-fg pair is the
diffAddition/diffDeletion tier from theme.ts:121-124. */
--color-mock-diff-add: rgba(108, 177, 123, 0.08);
--color-mock-diff-remove: rgba(216, 132, 123, 0.07);
--color-mock-diff-add-fg: #4ade80;
--color-mock-diff-remove-fg: #ef4444;
/* The one darkHighlightColors role the --color-syn-* block below is missing.
@getpaseo/highlight src/colors.ts, `class`. */
--color-mock-syn-class: #ffa657;
/* Terminal ANSI — darkTerminalAnsi, theme.ts:358-373 */
--color-mock-term-red: #e07070;
--color-mock-term-green: #5dba80;
--color-mock-term-yellow: #d4a44a;
--color-mock-term-blue: #6a9de0;
--color-mock-term-magenta: #b07ad0;
--color-mock-term-cyan: #4aabb8;
/* Syntax highlighting */
--color-syn-keyword: #ff7b72;