Skip to content

Commit dc151ef

Browse files
committed
feat(app): the bot's cursor follows each part of a screen as it is written
While a bot writes a screen, its cursor (the arrow in its color with its name, shared with the browser as BotCursor) glides to the end of what is written, and a frame in its color marks the part being built, on the artboard and on the screen in focus. Only the frame on show counts, so the cursor matches what is seen; measures are scaled by the view the script saw. With nothing visible yet the cursor waits at the top; near the right or bottom edge its name moves aside. The preview's drafts use the daemon's own script, and it writes at a pace closer to a model's.
1 parent 4fdfbe5 commit dc151ef

12 files changed

Lines changed: 373 additions & 43 deletions

File tree

‎app/src/dev/seedScreens.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ export function seedScreens(fake: FakeBotloft, crewId: CrewId): void {
6161
}
6262
return;
6363
}
64-
written = Math.min(LANDING.length, written + 70);
64+
written = Math.min(LANDING.length, written + 24);
6565
const done = written === LANDING.length;
6666
if (done) {
6767
fake.screens.add(designer.id, "index.html", LANDING, { folder: "site" });
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// A bot's cursor: an arrow in its color with its name, gliding to where it
2+
// works. The browser shows it where the bot clicks (spec 21.8), the design
3+
// area where the bot is writing a screen (spec 22.5).
4+
5+
import type { Bot } from "../../lib/protocol.gen";
6+
7+
/** Dark text on light colors, light text on dark ones. */
8+
export function inkOn(color: string): string {
9+
const hex = color.replace("#", "");
10+
const [r, g, b] = [0, 2, 4].map((at) => Number.parseInt(hex.slice(at, at + 2), 16) / 255);
11+
const luminance = 0.2126 * (r ?? 0) + 0.7152 * (g ?? 0) + 0.0722 * (b ?? 0);
12+
return luminance > 0.55 ? "#141414" : "#ffffff";
13+
}
14+
15+
export function BotCursor({
16+
bot,
17+
left,
18+
top,
19+
typing = false,
20+
flip = false,
21+
lift = false,
22+
}: {
23+
bot: Pick<Bot, "name" | "color">;
24+
/** Where the tip points, as CSS lengths inside the positioned parent. */
25+
left: string;
26+
top: string;
27+
/** Dots after the name: the bot is writing. */
28+
typing?: boolean;
29+
/** The name goes left of the arrow, near the right edge. */
30+
flip?: boolean;
31+
/** The name goes above the arrow, near the bottom edge. */
32+
lift?: boolean;
33+
}) {
34+
return (
35+
<span
36+
aria-hidden
37+
className={`bot-cursor ${flip ? "flex-row-reverse" : ""}`}
38+
style={{ left, top, ...(flip && { translate: "calc(-100% + 18px) -3px" }) }}
39+
>
40+
<svg width="22" height="22" viewBox="0 0 24 24" className="shrink-0 drop-shadow-sm">
41+
<title>{bot.name}</title>
42+
<path
43+
d="M4 2.5 19.5 12l-7 1.6-3.4 6.9Z"
44+
fill={bot.color}
45+
stroke="#ffffff"
46+
strokeWidth="1.6"
47+
strokeLinejoin="round"
48+
/>
49+
</svg>
50+
<span
51+
className={`${flip ? "mr-1" : "ml-3.5"} -mt-1 inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2 py-0.5 font-semibold text-[11px] shadow-sm`}
52+
style={{
53+
background: bot.color,
54+
color: inkOn(bot.color),
55+
...(lift && { marginTop: "-20px" }),
56+
}}
57+
>
58+
{bot.name}
59+
{typing && <span className="bot-typing">•••</span>}
60+
</span>
61+
</span>
62+
);
63+
}

‎app/src/features/browser/LiveView.tsx‎

Lines changed: 7 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,10 @@
55
import { type ReactNode, useEffect, useState } from "react";
66
import { useT } from "../../i18n";
77
import type { Bot, BrowserAction, BrowserFrame } from "../../lib/protocol.gen";
8+
import { BotCursor } from "../bots/BotCursor";
89

910
const PAGE = { width: 1280, height: 800 };
1011

11-
/** Dark text on light colors, light text on dark ones. */
12-
function inkOn(color: string): string {
13-
const hex = color.replace("#", "");
14-
const [r, g, b] = [0, 2, 4].map((at) => Number.parseInt(hex.slice(at, at + 2), 16) / 255);
15-
const luminance = 0.2126 * (r ?? 0) + 0.7152 * (g ?? 0) + 0.0722 * (b ?? 0);
16-
return luminance > 0.55 ? "#141414" : "#ffffff";
17-
}
18-
1912
/** Where on the screen, in percent, an action happened. */
2013
function spot(action: BrowserAction | null, frame: BrowserFrame | null) {
2114
if (!action || action.x === null || action.y === null) {
@@ -83,7 +76,6 @@ export function LiveView({
8376
}, [action?.at, action?.kind]);
8477
const width = frame?.width ?? PAGE.width;
8578
const height = frame?.height ?? PAGE.height;
86-
const ink = inkOn(bot.color);
8779
const acting = !dim && !held;
8880

8981
return (
@@ -113,29 +105,12 @@ export function LiveView({
113105
/>
114106
)}
115107
{acting && cursor && (
116-
<span
117-
aria-hidden
118-
className="browser-cursor"
119-
style={{ left: `${cursor.x}%`, top: `${cursor.y}%` }}
120-
>
121-
<svg width="22" height="22" viewBox="0 0 24 24" className="drop-shadow-sm">
122-
<title>{bot.name}</title>
123-
<path
124-
d="M4 2.5 19.5 12l-7 1.6-3.4 6.9Z"
125-
fill={bot.color}
126-
stroke="#ffffff"
127-
strokeWidth="1.6"
128-
strokeLinejoin="round"
129-
/>
130-
</svg>
131-
<span
132-
className="ml-3.5 -mt-1 inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2 py-0.5 font-semibold text-[11px] shadow-sm"
133-
style={{ background: bot.color, color: ink }}
134-
>
135-
{bot.name}
136-
{action?.kind === "type" && <span className="browser-typing">•••</span>}
137-
</span>
138-
</span>
108+
<BotCursor
109+
bot={bot}
110+
left={`${cursor.x}%`}
111+
top={`${cursor.y}%`}
112+
typing={action?.kind === "type"}
113+
/>
139114
)}
140115
{children}
141116
</figure>

‎app/src/features/browser/browser.test.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ describe("browser panel", () => {
5454
const screenshot = within(panel()).getByRole("figure", { name: "What Scout sees" });
5555
const image = screenshot.querySelector("img");
5656
expect(image?.getAttribute("src")).toBe("data:image/jpeg;base64,AAAA");
57-
const cursor = screenshot.querySelector(".browser-cursor") as HTMLElement;
57+
const cursor = screenshot.querySelector(".bot-cursor") as HTMLElement;
5858
expect(cursor.style.left).toBe("50%");
5959
expect(cursor.style.top).toBe("50%");
6060
expect(within(panel()).getByText("Live")).toBeDefined();

‎app/src/features/screens/LiveFrame.tsx‎

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,23 @@
11
// A screen drawn at its device's real size and scaled to fit (spec 22.5).
22
// Each new version loads in a hidden frame and takes the front once it is
3-
// ready, so a screen being written grows without flickering.
3+
// ready, so a screen being written grows without flickering. While the bot
4+
// writes it, its cursor follows the end of the page and a frame marks the
5+
// part being built (spec 22.3).
46

5-
import { useEffect, useReducer } from "react";
6-
import type { ScreenDevice } from "../../lib/protocol.gen";
7+
import { useEffect, useReducer, useRef, useState } from "react";
8+
import type { Bot, ScreenDevice } from "../../lib/protocol.gen";
9+
import { BotCursor } from "../bots/BotCursor";
10+
import { type Mark, readMark } from "./cursorMark";
711

812
export const DEVICES: Record<ScreenDevice, { width: number; height: number }> = {
913
desktop: { width: 1280, height: 800 },
1014
tablet: { width: 834, height: 1112 },
1115
mobile: { width: 390, height: 844 },
1216
};
1317

18+
/** Where the cursor waits while nothing on the page shows yet. */
19+
const PARKED = { x: 40, y: 40 };
20+
1421
/** The screen runs its own scripts, away from the app (spec 22.6). */
1522
const SANDBOX = "allow-scripts allow-forms allow-popups allow-modals";
1623

@@ -53,13 +60,16 @@ export function LiveFrame({
5360
scale,
5461
title,
5562
interactive = false,
63+
writer = null,
5664
}: {
5765
url: string;
5866
device: ScreenDevice;
5967
scale: number;
6068
title: string;
6169
/** Takes clicks and scrolling; otherwise the frame is only a picture. */
6270
interactive?: boolean;
71+
/** The bot writing it now, whose cursor shows. */
72+
writer?: Pick<Bot, "name" | "color"> | null;
6373
}) {
6474
const [state, change] = useReducer(frames, {
6575
urls: [url, null],
@@ -69,6 +79,30 @@ export function LiveFrame({
6979
});
7080
useEffect(() => change({ kind: "show", url }), [url]);
7181
const size = DEVICES[device];
82+
const windows = useRef<(Window | null)[]>([null, null]);
83+
// Each frame's own mark: the one behind speaks before it is on show, and
84+
// its layout is the next version's, so only the front one's shows.
85+
const [marks, setMarks] = useState<[Mark | null, Mark | null]>([null, null]);
86+
const writing = writer !== null;
87+
useEffect(() => {
88+
if (!writing) {
89+
setMarks([null, null]);
90+
return;
91+
}
92+
const listen = (event: MessageEvent) => {
93+
const frame = event.source === null ? -1 : windows.current.indexOf(event.source as Window);
94+
const read = frame === 0 || frame === 1 ? readMark(event.data, size) : undefined;
95+
if (read) {
96+
setMarks((current) => (frame === 0 ? [read, current[1]] : [current[0], read]));
97+
}
98+
};
99+
window.addEventListener("message", listen);
100+
return () => window.removeEventListener("message", listen);
101+
}, [writing, size]);
102+
const mark = marks[state.front];
103+
const box = writing ? mark?.box : null;
104+
// Nothing to see yet (the styles come first): the cursor waits at the top.
105+
const point = writing && mark ? (mark.point ?? PARKED) : null;
72106

73107
return (
74108
<div
@@ -84,6 +118,9 @@ export function LiveFrame({
84118
return (
85119
<iframe
86120
key={frame}
121+
ref={(node) => {
122+
windows.current[frame] = node?.contentWindow ?? null;
123+
}}
87124
src={src}
88125
title={title}
89126
sandbox={SANDBOX}
@@ -102,6 +139,30 @@ export function LiveFrame({
102139
/>
103140
);
104141
})}
142+
{box && writer && (
143+
<span
144+
aria-hidden
145+
className="pointer-events-none absolute rounded-[4px] border-2 transition-[left,top,width,height] duration-500 ease-out"
146+
style={{
147+
left: box.x * scale - 3,
148+
top: box.y * scale - 3,
149+
width: box.width * scale + 6,
150+
height: box.height * scale + 6,
151+
borderColor: writer.color,
152+
boxShadow: `0 0 0 4px ${writer.color}26`,
153+
}}
154+
/>
155+
)}
156+
{point && writer && (
157+
<BotCursor
158+
bot={writer}
159+
left={`${point.x * scale}px`}
160+
top={`${point.y * scale}px`}
161+
typing
162+
flip={point.x > size.width * 0.7}
163+
lift={point.y > size.height * 0.88}
164+
/>
165+
)}
105166
</div>
106167
);
107168
}

‎app/src/features/screens/ScreenFocus.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ export function ScreenFocus({
9292
scale={scale}
9393
title={screen.name}
9494
interactive
95+
writer={screen.writing ? bot : null}
9596
/>
9697
</div>
9798
</div>

‎app/src/features/screens/ScreensPanel.tsx‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,13 @@ function Artboard({
233233
: undefined
234234
}
235235
>
236-
<LiveFrame url={screen.url} device={device} scale={scale} title={screen.name} />
236+
<LiveFrame
237+
url={screen.url}
238+
device={device}
239+
scale={scale}
240+
title={screen.name}
241+
writer={screen.writing ? bot : null}
242+
/>
237243
<button
238244
type="button"
239245
aria-label={t.open(screen.name)}

0 commit comments

Comments
 (0)