diff --git a/app/src/dev/seedScreens.ts b/app/src/dev/seedScreens.ts index f9b297d6..a3e4f127 100644 --- a/app/src/dev/seedScreens.ts +++ b/app/src/dev/seedScreens.ts @@ -61,7 +61,7 @@ export function seedScreens(fake: FakeBotloft, crewId: CrewId): void { } return; } - written = Math.min(LANDING.length, written + 70); + written = Math.min(LANDING.length, written + 24); const done = written === LANDING.length; if (done) { fake.screens.add(designer.id, "index.html", LANDING, { folder: "site" }); diff --git a/app/src/features/bots/BotCursor.tsx b/app/src/features/bots/BotCursor.tsx new file mode 100644 index 00000000..3f64649e --- /dev/null +++ b/app/src/features/bots/BotCursor.tsx @@ -0,0 +1,63 @@ +// A bot's cursor: an arrow in its color with its name, gliding to where it +// works. The browser shows it where the bot clicks (spec 21.8), the design +// area where the bot is writing a screen (spec 22.5). + +import type { Bot } from "../../lib/protocol.gen"; + +/** Dark text on light colors, light text on dark ones. */ +export function inkOn(color: string): string { + const hex = color.replace("#", ""); + const [r, g, b] = [0, 2, 4].map((at) => Number.parseInt(hex.slice(at, at + 2), 16) / 255); + const luminance = 0.2126 * (r ?? 0) + 0.7152 * (g ?? 0) + 0.0722 * (b ?? 0); + return luminance > 0.55 ? "#141414" : "#ffffff"; +} + +export function BotCursor({ + bot, + left, + top, + typing = false, + flip = false, + lift = false, +}: { + bot: Pick; + /** Where the tip points, as CSS lengths inside the positioned parent. */ + left: string; + top: string; + /** Dots after the name: the bot is writing. */ + typing?: boolean; + /** The name goes left of the arrow, near the right edge. */ + flip?: boolean; + /** The name goes above the arrow, near the bottom edge. */ + lift?: boolean; +}) { + return ( + + + {bot.name} + + + + {bot.name} + {typing && •••} + + + ); +} diff --git a/app/src/features/browser/LiveView.tsx b/app/src/features/browser/LiveView.tsx index c4d1bdb0..8c7b16a0 100644 --- a/app/src/features/browser/LiveView.tsx +++ b/app/src/features/browser/LiveView.tsx @@ -5,17 +5,10 @@ import { type ReactNode, useEffect, useState } from "react"; import { useT } from "../../i18n"; import type { Bot, BrowserAction, BrowserFrame } from "../../lib/protocol.gen"; +import { BotCursor } from "../bots/BotCursor"; const PAGE = { width: 1280, height: 800 }; -/** Dark text on light colors, light text on dark ones. */ -function inkOn(color: string): string { - const hex = color.replace("#", ""); - const [r, g, b] = [0, 2, 4].map((at) => Number.parseInt(hex.slice(at, at + 2), 16) / 255); - const luminance = 0.2126 * (r ?? 0) + 0.7152 * (g ?? 0) + 0.0722 * (b ?? 0); - return luminance > 0.55 ? "#141414" : "#ffffff"; -} - /** Where on the screen, in percent, an action happened. */ function spot(action: BrowserAction | null, frame: BrowserFrame | null) { if (!action || action.x === null || action.y === null) { @@ -83,7 +76,6 @@ export function LiveView({ }, [action?.at, action?.kind]); const width = frame?.width ?? PAGE.width; const height = frame?.height ?? PAGE.height; - const ink = inkOn(bot.color); const acting = !dim && !held; return ( @@ -113,29 +105,12 @@ export function LiveView({ /> )} {acting && cursor && ( - - - {bot.name} - - - - {bot.name} - {action?.kind === "type" && •••} - - + )} {children} diff --git a/app/src/features/browser/browser.test.tsx b/app/src/features/browser/browser.test.tsx index 1188e626..86abd473 100644 --- a/app/src/features/browser/browser.test.tsx +++ b/app/src/features/browser/browser.test.tsx @@ -54,7 +54,7 @@ describe("browser panel", () => { const screenshot = within(panel()).getByRole("figure", { name: "What Scout sees" }); const image = screenshot.querySelector("img"); expect(image?.getAttribute("src")).toBe("data:image/jpeg;base64,AAAA"); - const cursor = screenshot.querySelector(".browser-cursor") as HTMLElement; + const cursor = screenshot.querySelector(".bot-cursor") as HTMLElement; expect(cursor.style.left).toBe("50%"); expect(cursor.style.top).toBe("50%"); expect(within(panel()).getByText("Live")).toBeDefined(); diff --git a/app/src/features/screens/LiveFrame.tsx b/app/src/features/screens/LiveFrame.tsx index 3fa8c6a9..8a8bed04 100644 --- a/app/src/features/screens/LiveFrame.tsx +++ b/app/src/features/screens/LiveFrame.tsx @@ -1,9 +1,13 @@ // A screen drawn at its device's real size and scaled to fit (spec 22.5). // Each new version loads in a hidden frame and takes the front once it is -// ready, so a screen being written grows without flickering. +// ready, so a screen being written grows without flickering. While the bot +// writes it, its cursor follows the end of the page and a frame marks the +// part being built (spec 22.3). -import { useEffect, useReducer } from "react"; -import type { ScreenDevice } from "../../lib/protocol.gen"; +import { useEffect, useReducer, useRef, useState } from "react"; +import type { Bot, ScreenDevice } from "../../lib/protocol.gen"; +import { BotCursor } from "../bots/BotCursor"; +import { type Mark, readMark } from "./cursorMark"; export const DEVICES: Record = { desktop: { width: 1280, height: 800 }, @@ -11,6 +15,9 @@ export const DEVICES: Record = mobile: { width: 390, height: 844 }, }; +/** Where the cursor waits while nothing on the page shows yet. */ +const PARKED = { x: 40, y: 40 }; + /** The screen runs its own scripts, away from the app (spec 22.6). */ const SANDBOX = "allow-scripts allow-forms allow-popups allow-modals"; @@ -53,6 +60,7 @@ export function LiveFrame({ scale, title, interactive = false, + writer = null, }: { url: string; device: ScreenDevice; @@ -60,6 +68,8 @@ export function LiveFrame({ title: string; /** Takes clicks and scrolling; otherwise the frame is only a picture. */ interactive?: boolean; + /** The bot writing it now, whose cursor shows. */ + writer?: Pick | null; }) { const [state, change] = useReducer(frames, { urls: [url, null], @@ -69,6 +79,30 @@ export function LiveFrame({ }); useEffect(() => change({ kind: "show", url }), [url]); const size = DEVICES[device]; + const windows = useRef<(Window | null)[]>([null, null]); + // Each frame's own mark: the one behind speaks before it is on show, and + // its layout is the next version's, so only the front one's shows. + const [marks, setMarks] = useState<[Mark | null, Mark | null]>([null, null]); + const writing = writer !== null; + useEffect(() => { + if (!writing) { + setMarks([null, null]); + return; + } + const listen = (event: MessageEvent) => { + const frame = event.source === null ? -1 : windows.current.indexOf(event.source as Window); + const read = frame === 0 || frame === 1 ? readMark(event.data, size) : undefined; + if (read) { + setMarks((current) => (frame === 0 ? [read, current[1]] : [current[0], read])); + } + }; + window.addEventListener("message", listen); + return () => window.removeEventListener("message", listen); + }, [writing, size]); + const mark = marks[state.front]; + const box = writing ? mark?.box : null; + // Nothing to see yet (the styles come first): the cursor waits at the top. + const point = writing && mark ? (mark.point ?? PARKED) : null; return (
{ + windows.current[frame] = node?.contentWindow ?? null; + }} src={src} title={title} sandbox={SANDBOX} @@ -102,6 +139,30 @@ export function LiveFrame({ /> ); })} + {box && writer && ( + + )} + {point && writer && ( + size.width * 0.7} + lift={point.y > size.height * 0.88} + /> + )}
); } diff --git a/app/src/features/screens/ScreenFocus.tsx b/app/src/features/screens/ScreenFocus.tsx index 38702eb4..f6cc165c 100644 --- a/app/src/features/screens/ScreenFocus.tsx +++ b/app/src/features/screens/ScreenFocus.tsx @@ -92,6 +92,7 @@ export function ScreenFocus({ scale={scale} title={screen.name} interactive + writer={screen.writing ? bot : null} /> diff --git a/app/src/features/screens/ScreensPanel.tsx b/app/src/features/screens/ScreensPanel.tsx index d4930f37..ea06219e 100644 --- a/app/src/features/screens/ScreensPanel.tsx +++ b/app/src/features/screens/ScreensPanel.tsx @@ -233,7 +233,13 @@ function Artboard({ : undefined } > - +