Skip to content
1,390 linesCodeBlameRaw
1/**
2 * The landing page's product tour: g1t browsing itself. A faithful app
3 * frame (rail, sidebars, the real colours) plays one story on a loop, with
4 * a soft cursor that moves, clicks and types: a request in #web, an agent's
5 * desk, the pull request going green, the ship note, the docs page.
6 *
7 * The frame is a pure function of time (lib/tour.ts). One rAF loop drives
8 * the clock and re-renders only when the frame changes; the step pills'
9 * progress bars are written straight to the DOM. The server renders t = 0
10 * and the clock starts after mount, so there is no hydration mismatch.
11 * Motion is transforms and opacity only. Reduced motion gets a still frame
12 * per step, chosen with the pills.
13 *
14 * Parts of the story are not built yet (handoffs between agents, desks,
15 * Docs); the frame says so in its top bar, and the caption under it.
16 */
17import {
18 ArrowRight,
19 AtSign,
20 Bell,
21 BookOpen,
22 Bot,
23 Check,
24 ChevronDown,
25 Code2,
26 FileText,
27 GitMerge,
28 GitPullRequest,
29 Hash,
30 Home,
31 Inbox,
32 MessagesSquare,
33 Paperclip,
34 Pin,
35 Plus,
36 Search,
37 SendHorizontal,
38} from "lucide-react";
39import { type CSSProperties, type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
40
41import { cn } from "../lib/cn";
42import { ASK, CHECKS, type CursorTarget, type Frame, LOOP_MS, PILLS, STEPS, STILLS, type Scene, frameAt, pillAt, pillProgress, sameFrame } from "../lib/tour";
43import { AgentAvatar } from "./agent-avatar";
44import { Mark } from "./logo";
45
46/** The frame's design size; it scales to fit, keeping its aspect. */
47const W = 992;
48const H = 600;
49
50const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
51
52/* ------------------------------------------------------------------ */
53/* The clock */
54/* ------------------------------------------------------------------ */
55
56type Clock = {
57 frame: Frame;
58 reduced: boolean;
59 /** The pill shown as current. */
60 pill: number;
61 jump: (index: number) => void;
62 hover: (on: boolean) => void;
63 /** Observed for being on screen. */
64 rootRef: React.RefObject<HTMLDivElement | null>;
65 /** The pills' progress bars, written each tick. */
66 barRefs: React.RefObject<(HTMLSpanElement | null)[]>;
67};
68
69function useTourClock(): Clock {
70 const [frame, setFrame] = useState<Frame>(() => frameAt(0));
71 const [reduced, setReduced] = useState(false);
72 const [still, setStill] = useState(0);
73 const rootRef = useRef<HTMLDivElement | null>(null);
74 const barRefs = useRef<(HTMLSpanElement | null)[]>([]);
75 const time = useRef(0);
76 const shown = useRef(frame);
77 const paused = useRef({ hover: false, offscreen: true, hidden: false });
78
79 const paint = useCallback((t: number) => {
80 const next = frameAt(t);
81 if (!sameFrame(next, shown.current)) {
82 shown.current = next;
83 setFrame(next);
84 }
85 const current = pillAt(t);
86 const progress = pillProgress(t);
87 barRefs.current.forEach((bar, index) => {
88 if (!bar) return;
89 const amount = index === current ? progress : 0;
90 bar.style.transform = `scaleX(${amount})`;
91 });
92 }, []);
93
94 useEffect(() => {
95 const motion = window.matchMedia("(prefers-reduced-motion: reduce)");
96 if (motion.matches) {
97 setReduced(true);
98 const next = frameAt(STILLS.chat);
99 shown.current = next;
100 setFrame(next);
101 return;
102 }
103 const root = rootRef.current;
104 const observer =
105 typeof IntersectionObserver === "undefined"
106 ? null
107 : new IntersectionObserver(([entry]) => {
108 paused.current.offscreen = !entry?.isIntersecting;
109 });
110 if (root && observer) observer.observe(root);
111 else paused.current.offscreen = false;
112 const visibility = () => {
113 paused.current.hidden = document.hidden;
114 };
115 visibility();
116 document.addEventListener("visibilitychange", visibility);
117
118 let last: number | null = null;
119 let raf = 0;
120 const tick = (now: number) => {
121 const { hover, offscreen, hidden } = paused.current;
122 // A long gap (a background tab, a debugger) moves time on by one frame, not by the gap.
123 if (last !== null && !hover && !offscreen && !hidden) time.current = (time.current + Math.min(now - last, 64)) % LOOP_MS;
124 last = now;
125 paint(time.current);
126 raf = requestAnimationFrame(tick);
127 };
128 raf = requestAnimationFrame(tick);
129 return () => {
130 cancelAnimationFrame(raf);
131 observer?.disconnect();
132 document.removeEventListener("visibilitychange", visibility);
133 };
134 }, [paint]);
135
136 const jump = useCallback(
137 (index: number) => {
138 const pill = PILLS[index];
139 if (reduced) {
140 setStill(index);
141 const next = frameAt(STILLS[pill.scene]);
142 shown.current = next;
143 setFrame(next);
144 return;
145 }
146 time.current = pill.start + 1;
147 paint(time.current);
148 },
149 [paint, reduced],
150 );
151
152 const hover = useCallback((on: boolean) => {
153 paused.current.hover = on;
154 }, []);
155
156 return { frame, reduced, pill: reduced ? still : pillOf(frame), jump, hover, rootRef, barRefs };
157}
158
159/** The pill a frame belongs to, from what it shows (the clock's time is not React state). */
160function pillOf(frame: Frame): number {
161 if (frame.scene === "docs") return 3;
162 if (frame.scene === "code" || frame.merged) return 2;
163 if (frame.scene === "agents") return 1;
164 return 0;
165}
166
167/** What the frame's top bar says about the step: shipped today, or a preview. */
168function honesty(frame: Frame): { today: boolean; label: string } {
169 if (frame.scene === "docs") return { today: false, label: "Docs: coming soon" };
170 if (frame.scene === "agents") return { today: false, label: "Desks: coming soon" };
171 if (frame.scene === "code") return { today: true, label: "Works today" };
172 if (frame.handoff) return { today: false, label: "Handoffs: coming soon" };
173 return { today: true, label: "Works today" };
174}
175
176/* ------------------------------------------------------------------ */
177/* Small parts */
178/* ------------------------------------------------------------------ */
179
180/** The people in the story; everyone else is an agent. */
181const PEOPLE: Record<string, string> = { Priya: "Priya Shah", Dana: "Dana Ruiz", Sam: "Sam Lee" };
182
183/** The agents in the story: a human name, a title, and a pixel creature of their own. */
184const AGENTS: Record<string, { title: string; team: string }> = {
185 g1t: { title: "Orchestrator", team: "" },
186 Otto: { title: "Software Engineer", team: "Engineering" },
187 Margo: { title: "QA Engineer", team: "QA" },
188 Inky: { title: "Technical Writer", team: "Docs" },
189 Izzy: { title: "Support Specialist", team: "Customer Support" },
190 Dot: { title: "Product Manager", team: "Product" },
191 David: { title: "Sales Operations", team: "Sales" },
192 Bruno: { title: "Operations Engineer", team: "Operations" },
193};
194
195/** A face: an agent's own creature (g1t keeps its pixel 1), or a person's round letter. */
196function Face({ who, size = 28 }: { who: string; size?: number }) {
197 if (!PEOPLE[who]) return <AgentAvatar agent={{ handle: who.toLowerCase(), name: who }} size={size} />;
198 return (
199 <span
200 style={{ width: size, height: size, fontSize: size * 0.4 }}
201 className="flex shrink-0 items-center justify-center rounded-full bg-raised font-semibold text-fg-soft ring-1 ring-line-strong"
202 >
203 {who.charAt(0)}
204 </span>
205 );
206}
207
208function AgentTag() {
209 return <span className="rounded bg-accent/15 px-1 py-px font-mono text-[9px] tracking-wide text-accent uppercase">Agent</span>;
210}
211
212/** Text with @mentions, #channels and #numbers picked out, as chat shows them. */
213function Rich({ text }: { text: string }) {
214 const parts = text.split(/(@[a-z0-9-]+|#[a-z0-9]+)/gi);
215 return (
216 <>
217 {parts.map((part, index) =>
218 /^[@#]/.test(part) ? (
219 <span key={index} className={part.startsWith("@") ? "rounded bg-accent/15 px-0.5 text-accent" : "text-accent"}>
220 {part}
221 </span>
222 ) : (
223 part
224 ),
225 )}
226 </>
227 );
228}
229
230function Message({ who, role, time, children, enter }: { who: string; role?: string; time: string; children: ReactNode; enter?: boolean }) {
231 const agent = !PEOPLE[who];
232 return (
233 <div className={cn("flex gap-3 px-5 py-2", enter && "tour-in")}>
234 <Face who={who} />
235 <div className="min-w-0 flex-1">
236 <p className="flex flex-wrap items-baseline gap-x-1.5 text-[13px]">
237 <span className="font-semibold text-fg">{PEOPLE[who] ?? who}</span>
238 {agent && <AgentTag />}
239 {(role ?? AGENTS[who]?.title) && <span className="text-[11px] text-faint">{role ?? AGENTS[who]?.title}</span>}
240 <span className="text-[11px] text-faint">{time}</span>
241 </p>
242 <div className="mt-0.5 space-y-2 text-[13px] leading-[1.55] text-fg-soft">{children}</div>
243 </div>
244 </div>
245 );
246}
247
248function Dots() {
249 return (
250 <span className="inline-flex items-center gap-1" aria-hidden="true">
251 {[0, 1, 2].map((i) => (
252 <span key={i} className="tour-dot size-1.5 rounded-full bg-faint" style={{ animationDelay: `${i * 160}ms` }} />
253 ))}
254 </span>
255 );
256}
257
258/** The task card g1t posts into #web; it follows the task wherever the story is. */
259function TaskCard({ frame }: { frame: Frame }) {
260 const state = frame.shipped
261 ? { text: "Deployed", tone: "bg-success/15 text-success" }
262 : frame.cardMerged
263 ? { text: "Merged · deploying", tone: "bg-merged/15 text-merged" }
264 : { text: "Working", tone: "bg-warn/15 text-warn" };
265 return (
266 <div className="max-w-[26rem] rounded-xl bg-bg ring-1 ring-line">
267 <div className="flex items-start gap-3 px-3.5 py-3">
268 {frame.cardMerged ? (
269 <GitMerge size={15} className="mt-0.5 shrink-0 text-merged" />
270 ) : (
271 <Bot size={15} className="mt-0.5 shrink-0 text-accent" />
272 )}
273 <div className="min-w-0 flex-1">
274 <p className="text-[13px] font-medium text-fg">CSV export times out for big accounts</p>
275 <p className="mt-0.5 text-[11px] text-faint">
276 <span className="text-fg-soft">@otto</span> is on it · <span className="text-fg-soft">@margo</span> reviews
277 </p>
278 </div>
279 <span key={state.text} className={cn("tour-pop shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium", state.tone)}>
280 {state.text}
281 </span>
282 </div>
283 <div className="flex items-center gap-3 border-t border-line px-3.5 py-2 text-[11px] text-muted">
284 <span className="flex gap-0.5" aria-hidden="true">
285 {STEPS.map((step, index) => (
286 <span
287 key={step.text}
288 className={cn("h-1 w-5 rounded-full transition-colors duration-500", index < frame.steps ? "bg-accent" : "bg-raised")}
289 />
290 ))}
291 </span>
292 <span>
293 {frame.steps} of {STEPS.length} steps
294 </span>
295 <span className="ml-auto font-mono tabular-nums">${(STEPS[frame.steps - 1]?.cost ?? 0).toFixed(2)}</span>
296 </div>
297 </div>
298 );
299}
300
301/* ------------------------------------------------------------------ */
302/* The app frame */
303/* ------------------------------------------------------------------ */
304
305const RAIL: { key: string; icon: ReactNode; label: string; target?: CursorTarget; scene?: Scene }[] = [
306 { key: "home", icon: <Home size={17} />, label: "Home" },
307 { key: "code", icon: <Code2 size={17} />, label: "Code", target: "rail-code", scene: "code" },
308 { key: "chat", icon: <MessagesSquare size={17} />, label: "Chat", target: "rail-chat", scene: "chat" },
309 { key: "docs", icon: <BookOpen size={17} />, label: "Docs", target: "rail-docs", scene: "docs" },
310 { key: "agents", icon: <Bot size={17} />, label: "Agents", target: "rail-agents", scene: "agents" },
311 { key: "inbox", icon: <Inbox size={17} />, label: "Inbox" },
312];
313
314function TopBar({ frame }: { frame: Frame }) {
315 const note = honesty(frame);
316 return (
317 <div className="flex h-11 items-center gap-3 border-b border-line bg-bg px-3">
318 <span className="flex size-8 items-center justify-center text-fg">
319 <Mark className="size-6" />
320 </span>
321 <span className="flex items-center gap-1 text-[13px] font-medium text-fg">
322 Acme <ChevronDown size={13} className="text-faint" />
323 </span>
324 <span className="mx-auto flex h-7 w-72 items-center gap-2 rounded-md bg-surface px-2.5 text-[12px] text-faint ring-1 ring-line">
325 <Search size={13} />
326 Search Acme
327 <span className="ml-auto font-mono text-[10px]">Ctrl K</span>
328 </span>
329 <span
330 key={note.label}
331 className={cn(
332 "tour-fade flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1",
333 note.today ? "bg-success/10 text-success ring-success/25" : "bg-raised text-muted ring-line-strong",
334 )}
335 >
336 <span className={cn("size-1.5 rounded-full", note.today ? "bg-success" : "bg-faint")} />
337 {note.label}
338 </span>
339 <Bell size={15} className="text-faint" />
340 <Face who="Priya" size={26} />
341 </div>
342 );
343}
344
345function Rail({ scene }: { scene: Scene }) {
346 const active = RAIL.findIndex((item) => item.scene === scene);
347 return (
348 <nav aria-hidden="true" className="relative flex w-14 shrink-0 flex-col items-center gap-1 border-r border-line bg-bg py-3">
349 <span
350 className="absolute top-3 left-1/2 -ml-[18px] size-9 rounded-lg bg-accent/15 transition-transform duration-500 ease-[cubic-bezier(.4,0,.2,1)]"
351 style={{ transform: `translateY(${active * 40}px)` }}
352 />
353 {RAIL.map((item, index) => (
354 <span
355 key={item.key}
356 data-tour={item.target}
357 className={cn(
358 "relative flex size-9 items-center justify-center rounded-lg transition-colors duration-500",
359 index === active ? "text-accent" : "text-faint",
360 )}
361 >
362 {item.icon}
363 </span>
364 ))}
365 </nav>
366 );
367}
368
369function SideHead({ children, action }: { children: ReactNode; action?: boolean }) {
370 return (
371 <p className="flex items-center justify-between px-2 text-[13px] font-semibold text-fg">
372 {children}
373 {action && <Plus size={14} className="text-faint" />}
374 </p>
375 );
376}
377
378function SideLabel({ children, tight }: { children: ReactNode; tight?: boolean }) {
379 return <p className={cn("mb-1 px-2 text-[11px] font-medium text-faint", tight ? "mt-3" : "mt-5")}>{children}</p>;
380}
381
382function SideRow({ on, children }: { on?: boolean; children: ReactNode }) {
383 return (
384 <div className={cn("flex items-center gap-2 rounded-md px-2 py-[5px] text-[13px]", on ? "bg-raised text-fg" : "text-muted")}>{children}</div>
385 );
386}
387
388/* Chat ---------------------------------------------------------------- */
389
390function ChatSide({ frame }: { frame: Frame }) {
391 // Otto works from the handoff until the merge; everyone else is around.
392 const ottoBusy = frame.handoff && !frame.merged;
393 const agents: { who: string; dot: string }[] = [
394 { who: "g1t", dot: "bg-success" },
395 { who: "Otto", dot: ottoBusy ? "bg-warn" : "bg-success" },
396 { who: "Margo", dot: "bg-success" },
397 { who: "Inky", dot: "bg-success" },
398 { who: "Izzy", dot: "bg-success" },
399 ];
400 return (
401 <>
402 <SideHead action>Chat</SideHead>
403 <SideLabel tight>Pinned</SideLabel>
404 <SideRow>
405 <FileText size={13} className="text-faint" />
406 <span className="flex-1">Release checklist</span>
407 </SideRow>
408 <SideRow>
409 <Pin size={13} className="text-faint" />
410 <span className="flex-1 truncate">Thursday release</span>
411 </SideRow>
412 <SideLabel tight>Channels</SideLabel>
413 {["web", "releases", "support"].map((name) => (
414 <SideRow key={name} on={name === "web"}>
415 <Hash size={13} className="text-faint" />
416 <span className="flex-1">{name}</span>
417 {name === "support" && <span className="rounded-full bg-accent px-1.5 text-[10px] font-semibold text-bg">3</span>}
418 </SideRow>
419 ))}
420 <SideLabel tight>Agents</SideLabel>
421 {agents.map((agent) => (
422 <SideRow key={agent.who}>
423 <Face who={agent.who} size={18} />
424 <span className="min-w-0 flex-1 truncate">
425 {agent.who}
426 <span className="text-faint"> · {AGENTS[agent.who].title}</span>
427 </span>
428 <span className={cn("size-1.5 shrink-0 rounded-full transition-colors duration-500", agent.dot)} />
429 </SideRow>
430 ))}
431 <SideLabel tight>Direct messages</SideLabel>
432 {["Dana", "Sam"].map((who) => (
433 <SideRow key={who}>
434 <Face who={who} size={18} />
435 <span className="flex-1">{PEOPLE[who]}</span>
436 </SideRow>
437 ))}
438 </>
439 );
440}
441
442function ChatMain({ frame }: { frame: Frame }) {
443 const typing = ASK.slice(0, frame.typed);
444 return (
445 <div className="flex h-full flex-col">
446 <div className="flex items-center gap-2 border-b border-line px-5 py-3">
447 <Hash size={15} className="text-faint" />
448 <span className="text-sm font-semibold text-fg">web</span>
449 <span className="truncate text-xs text-faint">The web app, its releases and its bugs</span>
450 <span className="ml-auto flex -space-x-1.5" aria-hidden="true">
451 {["Priya", "g1t", "Otto", "Margo", "Dana"].map((who) => (
452 <span key={who} className="rounded-lg ring-2 ring-surface">
453 <Face who={who} size={20} />
454 </span>
455 ))}
456 </span>
457 </div>
458 <div className="flex min-h-0 flex-1 flex-col justify-end overflow-hidden [mask-image:linear-gradient(to_bottom,transparent,black_2.5rem)] pb-1">
459 <div className="flex items-center gap-3 px-5 pt-1 pb-2 text-[11px] text-faint">
460 <span className="h-px flex-1 bg-line" />
461 Today
462 <span className="h-px flex-1 bg-line" />
463 </div>
464 <Message who="Priya" time="09:02">
465 <p>Morning! Thursday&apos;s release closes Wednesday night, so anything for it needs to land by then.</p>
466 </Message>
467 <Message who="Dana" time="09:12">
468 <p>Two enterprise customers hit the CSV export timeout again this morning. Both have over 100k rows.</p>
469 </Message>
470 {frame.sent && (
471 <Message who="Priya" time="09:15" enter>
472 <p>
473 <Rich text={ASK} />
474 </p>
475 </Message>
476 )}
477 {frame.g1tTyping && (
478 <div className="tour-in flex items-center gap-3 px-5 py-2 text-[12px] text-faint">
479 <Face who="g1t" size={28} />
480 <Dots />
481 g1t is typing
482 </div>
483 )}
484 {frame.handoff && (
485 <Message who="g1t" time="09:15" enter>
486 <p>
487 <Rich text="On it. @otto will make the fix and @margo will review it. I'll post here when it ships." />
488 </p>
489 <TaskCard frame={frame} />
490 </Message>
491 )}
492 {frame.shipped && (
493 <Message who="g1t" time="09:41" enter>
494 <p>
495 <Rich text="Shipped. A 200,000-row export now finishes in about 3 s. @izzy, can you let #support know?" />
496 </p>
497 </Message>
498 )}
499 {frame.izzy && (
500 <Message who="Izzy" time="09:42" enter>
501 <p>
502 <Rich text="Done. Told #support, and Dana's two customers can export again." />
503 </p>
504 </Message>
505 )}
506 </div>
507 <div className="px-5 pt-1 pb-4">
508 <div
509 data-tour="composer"
510 className={cn(
511 "flex items-center gap-2 rounded-xl bg-bg px-3 py-2.5 text-[13px] ring-1 transition-shadow duration-300",
512 frame.typed > 0 ? "ring-accent/50 shadow-[0_0_0_3px_rgb(182_168_255/0.08)]" : "ring-line",
513 )}
514 >
515 <span className="min-w-0 flex-1 truncate">
516 {frame.typed > 0 ? (
517 <span className="text-fg">
518 {typing}
519 <span className="tour-caret ml-px inline-block h-[14px] w-px translate-y-[2px] bg-accent" />
520 </span>
521 ) : (
522 <span className="text-faint">Message #web</span>
523 )}
524 </span>
525 <AtSign size={15} className="text-faint" />
526 <Paperclip size={15} className="text-faint" />
527 <span
528 data-tour="send"
529 className={cn(
530 "flex size-7 items-center justify-center rounded-md transition-colors duration-300",
531 frame.typed === ASK.length ? "bg-accent text-bg" : "text-faint",
532 )}
533 >
534 <SendHorizontal size={14} />
535 </span>
536 </div>
537 </div>
538 </div>
539 );
540}
541
542/* Agents -------------------------------------------------------------- */
543
544const SPECIALISTS: { who: string; tone: string }[] = [
545 { who: "Otto", tone: "bg-warn" },
546 { who: "Margo", tone: "bg-faint" },
547 { who: "Inky", tone: "bg-success" },
548 { who: "Izzy", tone: "bg-success" },
549 { who: "Dot", tone: "bg-success" },
550 { who: "David", tone: "bg-success" },
551]
552
553function AgentsSide() {
554 return (
555 <>
556 <SideHead action>Agents</SideHead>
557 <SideLabel>Pinned</SideLabel>
558 <SideRow>
559 <Face who="g1t" size={22} />
560 <span className="min-w-0 flex-1">
561 <span className="block leading-tight text-fg">g1t</span>
562 <span className="block text-[11px] leading-tight text-faint">Orchestrator</span>
563 </span>
564 <Pin size={12} className="text-faint" />
565 </SideRow>
566 <SideLabel>Specialists</SideLabel>
567 {SPECIALISTS.map((agent) => (
568 <SideRow key={agent.who} on={agent.who === "Otto"}>
569 <Face who={agent.who} size={22} />
570 <span className="min-w-0 flex-1">
571 <span className="block leading-tight">{agent.who}</span>
572 <span className="block truncate text-[11px] leading-tight text-faint">{AGENTS[agent.who].title}</span>
573 </span>
574 <span className={cn("size-1.5 rounded-full", agent.tone)} />
575 </SideRow>
576 ))}
577 </>
578 );
579}
580
581const FILES: { path: string; from: number }[] = [
582 { path: "services/export/csv.py", from: 3 },
583 { path: "services/export/query.py", from: 3 },
584 { path: "tests/test_export.py", from: 4 },
585];
586
587/** Agents consult each other in the open: a collapsed line anyone can expand. */
588function Consult({ frame, compact }: { frame: Frame; compact?: boolean }) {
589 return (
590 <div
591 className={cn(
592 "flex items-center gap-2.5 rounded-xl bg-bg px-3.5 py-2.5 text-[12px] ring-1 ring-line transition-[opacity,transform] duration-500",
593 frame.consult ? "translate-y-0 opacity-100" : "translate-y-1 opacity-0",
594 )}
595 >
596 <span className="flex -space-x-1">
597 <Face who="Otto" size={18} />
598 <Face who="Margo" size={18} />
599 </span>
600 <span className="min-w-0 flex-1 truncate text-muted">
601 <span className="text-fg-soft">Otto asked Margo</span> · 2 messages
602 {!compact && <span className="text-faint">: &ldquo;Is 1,000 a safe batch size for the 200k test?&rdquo;</span>}
603 </span>
604 <ChevronDown size={13} className="shrink-0 text-faint" />
605 <Soon />
606 </div>
607 );
608}
609
610/** The quiet tag on parts of the story that are not built yet. */
611function Soon() {
612 return (
613 <span className="shrink-0 rounded-full bg-raised px-1.5 py-0.5 font-mono text-[9px] tracking-wide whitespace-nowrap text-muted uppercase ring-1 ring-line-strong">
614 Coming soon
615 </span>
616 );
617}
618
619function AgentsMain({ frame }: { frame: Frame }) {
620 const cost = STEPS[frame.steps - 1]?.cost ?? 0;
621 return (
622 <div className="flex h-full flex-col">
623 <div className="flex items-center gap-3 border-b border-line px-5 pt-4 pb-3">
624 <Face who="Otto" size={36} />
625 <div className="min-w-0">
626 <p className="flex items-center gap-2 text-sm font-semibold text-fg">
627 Otto <AgentTag />
628 </p>
629 <p className="text-xs text-muted">@otto · Software Engineer, Engineering</p>
630 </div>
631 <span className="ml-auto flex items-center gap-1.5 rounded-full bg-warn/10 px-2.5 py-1 text-[11px] text-warn">
632 <span className="tour-pulse size-1.5 rounded-full bg-warn" />
633 Working
634 </span>
635 </div>
636 <div className="flex gap-5 border-b border-line px-5 text-[12px]">
637 {["Desk", "Profile", "Spend", "Activity"].map((tab) => (
638 <span key={tab} className={cn("py-2", tab === "Desk" ? "border-b-2 border-accent text-fg" : "text-faint")}>
639 {tab}
640 </span>
641 ))}
642 </div>
643 <div className="flex-1 space-y-3 overflow-hidden px-5 py-4">
644 <div className="rounded-xl bg-bg ring-1 ring-line">
645 <div className="flex items-start gap-3 px-4 pt-3.5 pb-3">
646 <div className="min-w-0 flex-1">
647 <p className="text-[13px] font-medium text-fg">CSV export times out for big accounts</p>
648 <p className="mt-0.5 text-[11px] text-faint">
649 From <span className="text-fg-soft">#web</span>, asked by Priya through <span className="text-fg-soft">@g1t</span>
650 </p>
651 </div>
652 <div className="w-32 text-right">
653 <p className="font-mono text-[12px] text-fg tabular-nums">
654 ${cost.toFixed(2)} <span className="text-faint">of $5</span>
655 </p>
656 <span className="mt-1.5 block h-1 overflow-hidden rounded-full bg-raised">
657 <span
658 className="block h-full origin-left rounded-full bg-accent transition-transform duration-700"
659 style={{ transform: `scaleX(${cost / 5})` }}
660 />
661 </span>
662 </div>
663 </div>
664 <ol className="space-y-1.5 border-t border-line px-4 py-3">
665 {STEPS.map((step, index) => {
666 const done = index < frame.steps;
667 const now = index === frame.steps;
668 return (
669 <li key={step.text} className={cn("flex items-center gap-2.5 text-[12.5px] transition-colors duration-300", done ? "text-fg-soft" : now ? "text-fg" : "text-faint")}>
670 <span className="flex size-4 shrink-0 items-center justify-center">
671 {done ? (
672 <span className="tour-pop flex size-4 items-center justify-center rounded-full bg-success/15 text-success">
673 <Check size={10} strokeWidth={3} />
674 </span>
675 ) : now ? (
676 <span className="tour-spin size-3.5 rounded-full border-2 border-accent/25 border-t-accent" />
677 ) : (
678 <span className="size-1.5 rounded-full bg-line-strong" />
679 )}
680 </span>
681 {step.text}
682 </li>
683 );
684 })}
685 </ol>
686 <div className="flex items-center gap-2 border-t border-line px-4 py-2.5">
687 <span className="text-[11px] text-faint">Files</span>
688 <span className="flex min-h-[20px] flex-1 flex-wrap gap-1.5">
689 {FILES.filter((file) => frame.steps >= file.from).map((file) => (
690 <span key={file.path} className="tour-pop rounded bg-raised px-1.5 py-0.5 font-mono text-[10.5px] text-muted ring-1 ring-line">
691 {file.path}
692 </span>
693 ))}
694 </span>
695 <span
696 data-tour="task-pull"
697 className={cn(
698 "flex items-center gap-1.5 rounded-md px-2 py-1 text-[12px] font-medium transition-opacity duration-500",
699 frame.steps >= STEPS.length ? "bg-accent/15 text-accent opacity-100" : "text-faint opacity-0",
700 )}
701 >
702 <GitPullRequest size={13} />
703 Pull request #431
704 <ArrowRight size={12} />
705 </span>
706 </div>
707 </div>
708 <Consult frame={frame} />
709 <div className="flex items-center gap-3 rounded-xl px-4 py-3 text-[12px] text-faint ring-1 ring-line ring-dashed">
710 <span className="rounded bg-raised px-1.5 py-0.5 text-[10px] uppercase">Queued</span>
711 Upgrade the date library across the web app
712 <span className="ml-auto">2nd in line</span>
713 </div>
714 </div>
715 </div>
716 );
717}
718
719/* Code ---------------------------------------------------------------- */
720
721function CodeSide() {
722 return (
723 <>
724 <SideHead>acme/web</SideHead>
725 <SideLabel>Project</SideLabel>
726 {[
727 ["Code", ""],
728 ["Issues", "12"],
729 ["Pull requests", "3"],
730 ["Checks", ""],
731 ["Deployments", ""],
732 ].map(([name, n]) => (
733 <SideRow key={name} on={name === "Pull requests"}>
734 <span className="flex-1">{name}</span>
735 {n && <span className="text-[11px] text-faint">{n}</span>}
736 </SideRow>
737 ))}
738 <SideLabel>Linked channels</SideLabel>
739 <SideRow>
740 <Hash size={13} className="text-faint" />
741 web
742 </SideRow>
743 </>
744 );
745}
746
747const DIFF: { sign: " " | "-" | "+"; text: string }[] = [
748 { sign: " ", text: "def export_csv(account, writer):" },
749 { sign: "-", text: " rows = list(rows_for_export(account))" },
750 { sign: "-", text: " for row in rows:" },
751 { sign: "-", text: " writer.writerow(row)" },
752 { sign: "+", text: " for batch in rows_for_export(account, batch_size=1_000):" },
753 { sign: "+", text: " writer.writerows(batch)" },
754 { sign: "+", text: " yield writer.flush()" },
755];
756
757function CodeMain({ frame }: { frame: Frame }) {
758 return (
759 <div className="flex h-full flex-col">
760 <div className="border-b border-line px-5 pt-4 pb-3">
761 <p className="text-[15px] font-semibold text-fg">
762 Stream CSV exports in batches <span className="font-normal text-faint">#431</span>
763 </p>
764 <p className="mt-1.5 flex items-center gap-2 text-[12px] text-muted">
765 <span
766 key={frame.merged ? "merged" : "open"}
767 className={cn(
768 "tour-pop flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium",
769 frame.merged ? "bg-merged/15 text-merged" : "bg-success/15 text-success",
770 )}
771 >
772 {frame.merged ? <GitMerge size={11} /> : <GitPullRequest size={11} />}
773 {frame.merged ? "Merged" : "Open"}
774 </span>
775 <span className="text-fg-soft">@otto</span> wants to merge into <span className="font-mono text-[11px] text-fg-soft">main</span> · fixes the
776 request from <span className="text-accent">#web</span>
777 </p>
778 </div>
779 <div className="grid min-h-0 flex-1 grid-cols-[1fr_15rem] gap-4 px-5 py-4">
780 <div className="min-w-0 space-y-3">
781 <div className="flex gap-3 text-[12.5px] leading-[1.6] text-fg-soft">
782 <Face who="Otto" size={24} />
783 <p>
784 Exports loaded every row before sending the first byte, so big accounts hit the 60 s limit. Rows now stream in
785 batches of 1,000. A 200,000-row account takes 3.1 s.
786 </p>
787 </div>
788 <div className="overflow-hidden rounded-xl bg-bg ring-1 ring-line">
789 <p className="flex items-center gap-2 border-b border-line px-3.5 py-2 font-mono text-[11px] text-muted">
790 <FileText size={12} />
791 services/export/csv.py
792 <span className="ml-auto">
793 <span className="text-success">+3</span> <span className="text-danger">−3</span>
794 </span>
795 </p>
796 <pre className="py-1.5 font-mono text-[11px] leading-[1.75]">
797 {DIFF.map((line, index) => (
798 <div
799 key={index}
800 className={cn(
801 "px-3.5 transition-opacity duration-500",
802 line.sign === "+" ? "bg-success/10 text-success" : line.sign === "-" ? "bg-danger/10 text-danger" : "text-muted",
803 frame.diff ? "opacity-100" : "opacity-0",
804 )}
805 style={{ transitionDelay: frame.diff ? `${index * 70}ms` : "0ms" }}
806 >
807 <span className="mr-3 inline-block w-2 text-faint select-none">{line.sign}</span>
808 {line.text}
809 </div>
810 ))}
811 </pre>
812 </div>
813 <div
814 className={cn(
815 "flex items-center gap-3 rounded-xl px-3.5 py-2.5 text-[12px] ring-1 transition-opacity duration-500",
816 frame.approved ? "bg-success/5 text-fg-soft opacity-100 ring-success/25" : "opacity-0 ring-line",
817 )}
818 >
819 <Face who="Margo" size={22} />
820 <span>
821 <span className="text-fg">@margo</span> approved: batching keeps memory flat, and the 200k-row test covers it.
822 </span>
823 <Check size={14} className="ml-auto shrink-0 text-success" />
824 </div>
825 </div>
826 <div className="space-y-3">
827 <div className="rounded-xl bg-bg ring-1 ring-line">
828 <p className="border-b border-line px-3.5 py-2 text-[11px] font-medium text-muted">
829 Checks · {frame.checks} of {CHECKS.length} passed
830 </p>
831 <ul className="space-y-2 px-3.5 py-2.5">
832 {CHECKS.map((check, index) => {
833 const done = index < frame.checks;
834 return (
835 <li key={check} className="flex items-center gap-2 text-[12px]">
836 {done ? (
837 <span className="tour-pop flex size-4 items-center justify-center rounded-full bg-success/15 text-success">
838 <Check size={10} strokeWidth={3} />
839 </span>
840 ) : (
841 <span className="tour-spin size-3.5 rounded-full border-2 border-warn/25 border-t-warn" />
842 )}
843 <span className={done ? "text-fg-soft" : "text-muted"}>{check}</span>
844 <span className="ml-auto font-mono text-[10px] text-faint">{done ? `${18 + index * 23}s` : ""}</span>
845 </li>
846 );
847 })}
848 </ul>
849 </div>
850 <div
851 className={cn(
852 "rounded-xl px-3.5 py-3 text-[12px] ring-1 transition-colors duration-500",
853 frame.merged ? "bg-merged/10 text-merged ring-merged/30" : frame.approved ? "bg-success/10 text-success ring-success/30" : "text-muted ring-line",
854 )}
855 >
856 <p className="flex items-center gap-2 font-medium">
857 <GitMerge size={13} />
858 {frame.merged ? "Merged by the merge queue" : frame.approved ? "In the merge queue" : "Waiting on checks and review"}
859 </p>
860 <p className="mt-1 text-[11px] text-faint">{frame.merged ? "Deploying to production" : "Main only moves to what passed."}</p>
861 </div>
862 <div
863 className={cn(
864 "rounded-xl bg-bg px-3.5 py-3 text-[12px] ring-1 ring-line transition-opacity duration-500",
865 frame.checks >= CHECKS.length ? "opacity-100" : "opacity-0",
866 )}
867 >
868 <p className="text-[11px] font-medium text-muted">Preview</p>
869 <p className="mt-1 truncate font-mono text-[11px] text-accent">pr-431.web.acme.g1t.page</p>
870 </div>
871 </div>
872 </div>
873 </div>
874 );
875}
876
877/* Docs ---------------------------------------------------------------- */
878
879function DocsSide() {
880 return (
881 <>
882 <SideHead action>Docs</SideHead>
883 <SideLabel>Product</SideLabel>
884 {["Getting started", "Billing", "Exporting data", "Integrations"].map((page) => (
885 <SideRow key={page} on={page === "Exporting data"}>
886 <FileText size={13} className="text-faint" />
887 {page}
888 </SideRow>
889 ))}
890 <SideLabel>Engineering</SideLabel>
891 {["Runbooks", "Decisions"].map((page) => (
892 <SideRow key={page}>
893 <FileText size={13} className="text-faint" />
894 {page}
895 </SideRow>
896 ))}
897 </>
898 );
899}
900
901function DocsMain({ frame, tag = true }: { frame: Frame; tag?: boolean }) {
902 return (
903 <div className="relative h-full px-10 py-7">
904 {tag && (
905 <span className="absolute top-4 right-5 rounded-full bg-raised px-2.5 py-1 font-mono text-[10px] tracking-wide text-muted uppercase ring-1 ring-line-strong">
906 Coming soon
907 </span>
908 )}
909 <p className="text-[11px] text-faint">Product / Exporting data</p>
910 <h3 className="mt-2 text-[22px] font-semibold tracking-tight text-fg">Exporting data</h3>
911 <p className="mt-1.5 flex items-center gap-2 text-[11px] text-faint">
912 Owned by <span className="text-fg-soft">@inky</span> ·{" "}
913 <span key={frame.docUpdated ? "now" : "then"} className="tour-fade">
914 {frame.docUpdated ? "updated just now from #431" : "updated 3 weeks ago"}
915 </span>
916 </p>
917 <div className="mt-5 max-w-xl space-y-3 text-[13px] leading-[1.7] text-fg-soft">
918 <p>Export any table as CSV from Settings, then Data. An export includes every row you can see, in the order shown.</p>
919 <p className="pt-2 text-[14px] font-semibold text-fg">Large accounts</p>
920 <div className="relative">
921 <p
922 className={cn(
923 "rounded-md px-3 py-2 transition-all duration-500",
924 frame.docUpdated ? "bg-danger/5 text-faint line-through decoration-danger/50" : "text-fg-soft",
925 )}
926 >
927 Exports over 50,000 rows can time out. Split them by date range.
928 </p>
929 <div
930 className={cn(
931 "mt-1.5 rounded-md border-l-2 border-success bg-success/10 px-3 py-2 text-fg transition-all duration-700",
932 frame.docUpdated ? "translate-y-0 opacity-100" : "translate-y-1 opacity-0",
933 )}
934 >
935 Exports of any size stream in batches of 1,000 rows. A 200,000-row account finishes in about 3 seconds.
936 </div>
937 </div>
938 <div
939 className={cn(
940 "flex items-center gap-2 pt-1 text-[11px] text-muted transition-opacity delay-300 duration-500",
941 frame.docUpdated ? "opacity-100" : "opacity-0",
942 )}
943 >
944 <Face who="Inky" size={18} />
945 <span>
946 Updated by <span className="text-fg">@inky</span> after <span className="text-accent">#431</span> merged
947 </span>
948 </div>
949 <p className="pt-3 text-[14px] font-semibold text-fg">Scheduled exports</p>
950 <p>Owners can send an export to a storage bucket every day or week. Each run uses the same columns as the last.</p>
951 </div>
952 </div>
953 );
954}
955
956/* The cursor ------------------------------------------------------------ */
957
958function Cursor({ frame, canvas, scale }: { frame: Frame; canvas: React.RefObject<HTMLDivElement | null>; scale: number }) {
959 const [at, setAt] = useState<{ x: number; y: number } | null>(null);
960 useIsoLayoutEffect(() => {
961 const root = canvas.current;
962 if (!root) return;
963 const box = root.getBoundingClientRect();
964 const target = frame.cursor === "idle" ? null : root.querySelector<HTMLElement>(`[data-tour="${frame.cursor}"]`);
965 if (!target) {
966 setAt({ x: W * 0.64, y: H * 0.88 });
967 return;
968 }
969 const rect = target.getBoundingClientRect();
970 // The composer is clicked towards its right, clear of the words being typed; everything else in its middle.
971 const across = frame.cursor === "composer" ? 0.82 : 0.5;
972 setAt({ x: (rect.left - box.left + rect.width * across) / scale, y: (rect.top - box.top + rect.height * 0.55) / scale });
973 }, [frame.cursor, scale, canvas]);
974 if (!at) return null;
975 return (
976 <div
977 aria-hidden="true"
978 className={cn("pointer-events-none absolute top-0 left-0 z-20 transition-[transform,opacity] duration-[900ms] ease-[cubic-bezier(.45,.05,.2,1)]", frame.cursorShown ? "opacity-100" : "opacity-0")}
979 style={{ transform: `translate(${at.x}px, ${at.y}px)` }}
980 >
981 <span
982 className={cn(
983 "absolute -top-4 -left-4 size-8 rounded-full bg-accent/30 transition-[transform,opacity] duration-300",
984 frame.click ? "scale-100 opacity-100" : "scale-50 opacity-0",
985 )}
986 />
987 <svg
988 width="18"
989 height="20"
990 viewBox="0 0 18 20"
991 className={cn("relative drop-shadow-[0_2px_6px_rgb(0_0_0/0.5)] transition-transform duration-150", frame.click && "scale-90")}
992 >
993 <path d="M1.5 1.5 L1.5 15.5 L5.5 12 L8.3 18 L11 16.8 L8.3 11 L13.5 11 Z" fill="#ededef" stroke="#0f0f11" strokeWidth="1.2" strokeLinejoin="round" />
994 </svg>
995 </div>
996 );
997}
998
999/* Desktop ------------------------------------------------------------- */
1000
1001const SIDES: Record<Scene, (props: { frame: Frame }) => ReactNode> = {
1002 chat: ChatSide,
1003 agents: AgentsSide,
1004 code: CodeSide,
1005 docs: DocsSide,
1006};
1007
1008function Desktop({ frame, reduced }: { frame: Frame; reduced: boolean }) {
1009 const outer = useRef<HTMLDivElement | null>(null);
1010 const canvas = useRef<HTMLDivElement | null>(null);
1011 const [scale, setScale] = useState(1);
1012 useEffect(() => {
1013 const element = outer.current;
1014 if (!element || typeof ResizeObserver === "undefined") return;
1015 const observer = new ResizeObserver(([entry]) => {
1016 const width = entry?.contentRect.width ?? W;
1017 if (width > 0) setScale(width / W);
1018 });
1019 observer.observe(element);
1020 return () => observer.disconnect();
1021 }, []);
1022 const scenes: Scene[] = ["chat", "agents", "code", "docs"];
1023 const mains: Record<Scene, ReactNode> = {
1024 chat: <ChatMain frame={frame} />,
1025 agents: <AgentsMain frame={frame} />,
1026 code: <CodeMain frame={frame} />,
1027 docs: <DocsMain frame={frame} />,
1028 };
1029 return (
1030 <div ref={outer} className="relative w-full overflow-hidden rounded-2xl shadow-2xl shadow-black/50 ring-1 ring-line" style={{ aspectRatio: `${W} / ${H}` }}>
1031 <div
1032 ref={canvas}
1033 className="absolute top-0 left-0 origin-top-left bg-surface text-left"
1034 style={{ width: W, height: H, transform: scale === 1 ? undefined : `scale(${scale})` }}
1035 >
1036 <TopBar frame={frame} />
1037 <div className="flex" style={{ height: H - 44 }}>
1038 <Rail scene={frame.scene} />
1039 <div className="relative min-w-0 flex-1">
1040 {scenes.map((scene) => {
1041 const Side = SIDES[scene];
1042 const on = frame.scene === scene;
1043 return (
1044 <div
1045 key={scene}
1046 aria-hidden={!on}
1047 className={cn(
1048 "absolute inset-0 flex transition-[opacity,transform] duration-500 ease-out",
1049 on ? "translate-y-0 opacity-100" : "pointer-events-none translate-y-1 opacity-0",
1050 )}
1051 >
1052 <div className="w-52 shrink-0 border-r border-line px-2 py-3">
1053 <Side frame={frame} />
1054 </div>
1055 <div className="min-w-0 flex-1">{mains[scene]}</div>
1056 </div>
1057 );
1058 })}
1059 </div>
1060 </div>
1061 {!reduced && <Cursor frame={frame} canvas={canvas} scale={scale} />}
1062 </div>
1063 </div>
1064 );
1065}
1066
1067/* Phone --------------------------------------------------------------- */
1068
1069const MODE: Record<Scene, { icon: ReactNode; label: string }> = {
1070 chat: { icon: <MessagesSquare size={14} />, label: "# web" },
1071 agents: { icon: <Bot size={14} />, label: "Otto's desk" },
1072 code: { icon: <GitPullRequest size={14} />, label: "Pull request #431" },
1073 docs: { icon: <BookOpen size={14} />, label: "Exporting data" },
1074};
1075
1076function Phone({ frame }: { frame: Frame }) {
1077 const note = honesty(frame);
1078 const scenes: Scene[] = ["chat", "agents", "code", "docs"];
1079 return (
1080 <div className="relative h-[34rem] w-full overflow-hidden rounded-2xl bg-surface text-left shadow-2xl shadow-black/50 ring-1 ring-line">
1081 <div className="flex h-12 items-center gap-2 border-b border-line px-3">
1082 <Mark className="size-5 text-fg" />
1083 <span key={frame.scene} className="tour-fade flex min-w-0 items-center gap-1.5 text-[13px] font-semibold text-fg">
1084 <span className="text-accent">{MODE[frame.scene].icon}</span>
1085 <span className="truncate">{MODE[frame.scene].label}</span>
1086 </span>
1087 <span
1088 className={cn(
1089 "ml-auto flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium ring-1",
1090 note.today ? "bg-success/10 text-success ring-success/25" : "bg-raised text-muted ring-line-strong",
1091 )}
1092 >
1093 <span className={cn("size-1.5 rounded-full", note.today ? "bg-success" : "bg-faint")} />
1094 {note.today ? "Today" : "Soon"}
1095 </span>
1096 </div>
1097 <div className="relative h-[calc(100%-3rem)]">
1098 {scenes.map((scene) => {
1099 const on = frame.scene === scene;
1100 return (
1101 <div
1102 key={scene}
1103 aria-hidden={!on}
1104 className={cn("absolute inset-0 transition-opacity duration-500", on ? "opacity-100" : "pointer-events-none opacity-0")}
1105 >
1106 {scene === "chat" && <PhoneChat frame={frame} />}
1107 {scene === "agents" && <PhoneAgents frame={frame} />}
1108 {scene === "code" && <PhoneCode frame={frame} />}
1109 {scene === "docs" && (
1110 <div className="origin-top-left scale-[0.86]" style={{ width: "116%" }}>
1111 <DocsMain frame={frame} tag={false} />
1112 </div>
1113 )}
1114 </div>
1115 );
1116 })}
1117 </div>
1118 </div>
1119 );
1120}
1121
1122function PhoneChat({ frame }: { frame: Frame }) {
1123 return (
1124 <div className="flex h-full flex-col">
1125 <div className="flex min-h-0 flex-1 flex-col justify-end overflow-hidden [mask-image:linear-gradient(to_bottom,transparent,black_2.5rem)] [&>div]:px-3">
1126 <Message who="Priya" time="09:02">
1127 <p>Morning! Thursday&apos;s release closes Wednesday night.</p>
1128 </Message>
1129 <Message who="Dana" time="09:12">
1130 <p>Two enterprise customers hit the CSV export timeout again this morning.</p>
1131 </Message>
1132 {frame.sent && (
1133 <Message who="Priya" time="09:15" enter>
1134 <p>
1135 <Rich text={ASK} />
1136 </p>
1137 </Message>
1138 )}
1139 {frame.g1tTyping && (
1140 <div className="tour-in flex items-center gap-3 py-2 text-[12px] text-faint">
1141 <Face who="g1t" />
1142 <Dots />
1143 </div>
1144 )}
1145 {frame.handoff && (
1146 <Message who="g1t" time="09:15" enter>
1147 <p>
1148 <Rich text="On it. @otto will fix it and @margo will review." />
1149 </p>
1150 <TaskCard frame={frame} />
1151 </Message>
1152 )}
1153 {frame.shipped && (
1154 <Message who="g1t" time="09:41" enter>
1155 <p>
1156 <Rich text="Shipped. @izzy, can you tell #support?" />
1157 </p>
1158 </Message>
1159 )}
1160 {frame.izzy && (
1161 <Message who="Izzy" time="09:42" enter>
1162 <p>
1163 <Rich text="Done. Told #support." />
1164 </p>
1165 </Message>
1166 )}
1167 </div>
1168 <div className="px-3 pt-1 pb-3">
1169 <div className={cn("flex items-center gap-2 rounded-xl bg-bg px-3 py-2.5 text-[13px] ring-1", frame.typed > 0 ? "ring-accent/50" : "ring-line")}>
1170 <span className="min-w-0 flex-1 truncate">
1171 {frame.typed > 0 ? (
1172 <span className="text-fg">
1173 {ASK.slice(Math.max(0, frame.typed - 34), frame.typed)}
1174 <span className="tour-caret ml-px inline-block h-[14px] w-px translate-y-[2px] bg-accent" />
1175 </span>
1176 ) : (
1177 <span className="text-faint">Message #web</span>
1178 )}
1179 </span>
1180 <SendHorizontal size={15} className={frame.typed === ASK.length ? "text-accent" : "text-faint"} />
1181 </div>
1182 </div>
1183 </div>
1184 );
1185}
1186
1187function PhoneAgents({ frame }: { frame: Frame }) {
1188 const cost = STEPS[frame.steps - 1]?.cost ?? 0;
1189 return (
1190 <div className="space-y-3 px-3 py-4">
1191 <div className="flex items-center gap-3">
1192 <Face who="Otto" size={32} />
1193 <div>
1194 <p className="flex items-center gap-2 text-sm font-semibold text-fg">
1195 Otto <AgentTag />
1196 </p>
1197 <p className="text-[11px] text-muted">Working · ${cost.toFixed(2)} of $5</p>
1198 </div>
1199 </div>
1200 <div className="rounded-xl bg-bg ring-1 ring-line">
1201 <p className="border-b border-line px-3.5 py-2.5 text-[13px] font-medium text-fg">CSV export times out for big accounts</p>
1202 <ol className="space-y-2 px-3.5 py-3">
1203 {STEPS.map((step, index) => {
1204 const done = index < frame.steps;
1205 const now = index === frame.steps;
1206 return (
1207 <li key={step.text} className={cn("flex items-start gap-2.5 text-[12.5px]", done ? "text-fg-soft" : now ? "text-fg" : "text-faint")}>
1208 <span className="mt-0.5 flex size-4 shrink-0 items-center justify-center">
1209 {done ? (
1210 <span className="tour-pop flex size-4 items-center justify-center rounded-full bg-success/15 text-success">
1211 <Check size={10} strokeWidth={3} />
1212 </span>
1213 ) : now ? (
1214 <span className="tour-spin size-3.5 rounded-full border-2 border-accent/25 border-t-accent" />
1215 ) : (
1216 <span className="size-1.5 rounded-full bg-line-strong" />
1217 )}
1218 </span>
1219 {step.text}
1220 </li>
1221 );
1222 })}
1223 </ol>
1224 <div className="flex flex-wrap gap-1.5 border-t border-line px-3.5 py-2.5">
1225 {FILES.filter((file) => frame.steps >= file.from).map((file) => (
1226 <span key={file.path} className="tour-pop rounded bg-raised px-1.5 py-0.5 font-mono text-[10px] text-muted ring-1 ring-line">
1227 {file.path}
1228 </span>
1229 ))}
1230 <span className="min-h-[18px]" />
1231 </div>
1232 </div>
1233 <div
1234 className={cn(
1235 "flex items-center gap-2 rounded-xl bg-accent/10 px-3.5 py-2.5 text-[12px] font-medium text-accent ring-1 ring-accent/25 transition-opacity duration-500",
1236 frame.steps >= STEPS.length ? "opacity-100" : "opacity-0",
1237 )}
1238 >
1239 <GitPullRequest size={13} />
1240 Pull request #431 opened
1241 <ArrowRight size={12} className="ml-auto" />
1242 </div>
1243 <Consult frame={frame} compact />
1244 <div className="flex items-center gap-2 rounded-xl px-3.5 py-2.5 text-[11.5px] text-faint ring-dashed">
1245 <span className="rounded bg-raised px-1.5 py-0.5 text-[9.5px] uppercase">Queued</span>
1246 Upgrade the date library
1247 </div>
1248 </div>
1249 );
1250}
1251
1252function PhoneCode({ frame }: { frame: Frame }) {
1253 return (
1254 <div className="space-y-3 px-3 py-4">
1255 <p className="text-[14px] font-semibold text-fg">
1256 Stream CSV exports in batches <span className="font-normal text-faint">#431</span>
1257 </p>
1258 <p className="text-[12px] leading-[1.6] text-muted">
1259 <span className="text-fg-soft">@otto</span> wants to merge into <span className="font-mono text-[11px] text-fg-soft">main</span>. Rows now
1260 stream in batches of 1,000; a 200,000-row account takes 3.1 s.
1261 </p>
1262 <div className="rounded-xl bg-bg ring-1 ring-line">
1263 <ul className="space-y-2 px-3.5 py-3">
1264 {CHECKS.map((check, index) => {
1265 const done = index < frame.checks;
1266 return (
1267 <li key={check} className="flex items-center gap-2 text-[12px]">
1268 {done ? (
1269 <span className="tour-pop flex size-4 items-center justify-center rounded-full bg-success/15 text-success">
1270 <Check size={10} strokeWidth={3} />
1271 </span>
1272 ) : (
1273 <span className="tour-spin size-3.5 rounded-full border-2 border-warn/25 border-t-warn" />
1274 )}
1275 <span className={done ? "text-fg-soft" : "text-muted"}>{check}</span>
1276 </li>
1277 );
1278 })}
1279 </ul>
1280 </div>
1281 <pre className={cn("overflow-hidden rounded-xl bg-bg py-1.5 font-mono text-[10.5px] leading-[1.7] ring-1 ring-line transition-opacity duration-500", frame.diff ? "opacity-100" : "opacity-0")}>
1282 {DIFF.slice(1).map((line, index) => (
1283 <div key={index} className={cn("truncate px-3", line.sign === "+" ? "bg-success/10 text-success" : "bg-danger/10 text-danger")}>
1284 {line.sign} {line.text.trim()}
1285 </div>
1286 ))}
1287 </pre>
1288 <div
1289 className={cn(
1290 "flex items-center gap-2 rounded-xl px-3.5 py-2.5 text-[12px] ring-1 transition-colors duration-500",
1291 frame.merged ? "bg-merged/10 text-merged ring-merged/30" : frame.approved ? "bg-success/10 text-success ring-success/30" : "text-muted ring-line",
1292 )}
1293 >
1294 <GitMerge size={13} />
1295 {frame.merged ? "Merged · deploying" : frame.approved ? "Approved by @margo" : "Waiting on checks and review"}
1296 </div>
1297 </div>
1298 );
1299}
1300
1301/* The tour -------------------------------------------------------------- */
1302
1303/** Keyframes the tour's parts use; transforms and opacity only. */
1304const STYLES = `
1305.tour-in { animation: tour-in 420ms cubic-bezier(.2,.7,.2,1) both; }
1306@keyframes tour-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
1307.tour-pop { animation: tour-pop 360ms cubic-bezier(.3,1.4,.5,1) both; }
1308@keyframes tour-pop { from { opacity: 0; transform: scale(.7); } to { opacity: 1; transform: none; } }
1309.tour-fade { animation: tour-fade 400ms ease-out both; }
1310@keyframes tour-fade { from { opacity: 0; } to { opacity: 1; } }
1311.tour-dot { animation: tour-dot 1.1s ease-in-out infinite; }
1312@keyframes tour-dot { 0%, 60%, 100% { opacity: .35; transform: translateY(0); } 30% { opacity: 1; transform: translateY(-2px); } }
1313.tour-caret { animation: tour-caret 1s steps(1) infinite; }
1314@keyframes tour-caret { 50% { opacity: 0; } }
1315.tour-spin { animation: tour-spin .9s linear infinite; }
1316@keyframes tour-spin { to { transform: rotate(360deg); } }
1317.tour-pulse { animation: tour-pulse 1.6s ease-in-out infinite; }
1318@keyframes tour-pulse { 50% { opacity: .35; } }
1319.ring-dashed { box-shadow: none; outline: 1px dashed var(--g1t-line-strong); outline-offset: -1px; }
1320@media (prefers-reduced-motion: reduce) {
1321 .tour-in, .tour-pop, .tour-fade, .tour-dot, .tour-caret, .tour-spin, .tour-pulse { animation: none; }
1322}
1323`;
1324
1325/**
1326 * The hero's tour: the frame (a full app on wider screens, one pane on a
1327 * phone), the step pills under it, and a line saying what is a preview.
1328 */
1329export function ProductTour({ className }: { className?: string }) {
1330 const clock = useTourClock();
1331 const { frame } = clock;
1332 return (
1333 <div ref={clock.rootRef} className={className}>
1334 <style dangerouslySetInnerHTML={{ __html: STYLES }} />
1335 <div
1336 role="img"
1337 aria-label="A tour of g1t: a person asks g1t in a channel to fix a slow export, an agent works the task, the pull request passes its checks and merges, g1t says it shipped, and the docs page updates."
1338 onMouseEnter={() => clock.hover(true)}
1339 onMouseLeave={() => clock.hover(false)}
1340 >
1341 <div className="hidden md:block">
1342 <Desktop frame={frame} reduced={clock.reduced} />
1343 </div>
1344 <div className="md:hidden">
1345 <Phone frame={frame} />
1346 </div>
1347 </div>
1348 <div className="mx-auto mt-5 grid max-w-xl grid-cols-4 gap-2" role="tablist" aria-label="Steps of the tour">
1349 {PILLS.map((pill, index) => {
1350 const on = clock.pill === index;
1351 return (
1352 <button
1353 key={pill.scene}
1354 type="button"
1355 role="tab"
1356 aria-selected={on}
1357 onClick={() => clock.jump(index)}
1358 className={cn(
1359 "group relative overflow-hidden rounded-full px-3 py-1.5 text-xs font-medium ring-1 transition-colors",
1360 on ? "bg-surface text-fg ring-line-strong" : "text-muted ring-line hover:text-fg-soft",
1361 )}
1362 >
1363 <span
1364 ref={(element) => {
1365 clock.barRefs.current[index] = element;
1366 }}
1367 aria-hidden="true"
1368 className="absolute inset-0 origin-left bg-accent/15"
1369 style={{ transform: `scaleX(${clock.reduced && on ? 1 : 0})` }}
1370 />
1371 <span className="relative flex items-center justify-center gap-1.5">
1372 {pill.label}
1373 {pill.soon && (
1374 <>
1375 <span className="hidden text-[10px] font-normal text-faint sm:inline">soon</span>
1376 <span className="size-1 rounded-full bg-faint sm:hidden" aria-label="coming soon" />
1377 </>
1378 )}
1379 </span>
1380 </button>
1381 );
1382 })}
1383 </div>
1384 <p className="mx-auto mt-3 max-w-xl text-center text-xs leading-5 text-faint text-balance">
1385 A preview of where g1t is going. Chat, agents you DM, pull requests, checks and the merge queue work today.
1386 Handoffs and consults between agents, live desks and Docs are coming soon.
1387 </p>
1388 </div>
1389 );
1390}