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