diff --git a/apps/web/app/hero-demo.css b/apps/web/app/hero-demo.css
index 73afd39..3e09bec 100644
--- a/apps/web/app/hero-demo.css
+++ b/apps/web/app/hero-demo.css
@@ -586,13 +586,16 @@
}
/* A frosted pill under the handwriting keeps it legible over the busiest
- parts of the wallpaper without turning the note into a UI card. */
+ parts of the wallpaper without turning the note into a UI card. The tint
+ is set for the light wallpaper, whose pale sky and warm corner sit right
+ under the note (white on them needs about half-strength navy to clear
+ 4.5:1); the dark wallpaper below eases it off. */
.demoNote {
max-width: 60cqw;
margin: 0;
padding: 0.35cqw 1cqw 0.45cqw;
border-radius: 1.4cqw;
- background: rgba(8, 20, 48, 0.28);
+ background: rgba(8, 20, 48, 0.55);
color: #fff;
font-family: var(--font-hand), "Bradley Hand", "Segoe Print", cursive;
font-size: 2.45cqw;
@@ -627,8 +630,10 @@
scale: -1 -1;
}
+/* Waiting notes step back a little; in colour, not opacity, so the tint
+ behind them keeps doing its job. */
.demoNote.isQuiet {
- opacity: 0.82;
+ color: rgba(255, 255, 255, 0.86);
}
.demoNoteArrow {
@@ -1931,59 +1936,94 @@
/* ── Compact stage (stacked layout) ────────────────────────────────────── */
+/* Stacked under the copy, the pair keeps the desktop composition and its
+ centring, but with no column of text beside it the phone can take a larger
+ share of the Mac's width (the pair grows to match) and the terminal's type
+ steps up, so both stay legible on a tablet held upright. The stage is sized
+ by the hero media's aspect ratio (landing.css), so the Mac hangs from the
+ top and the phone's bottom edge lands just inside it. */
@media (max-width: 1023px), (prefers-reduced-motion: reduce) {
- /* Stacked, the Mac is wider than the stage and bleeds a little off both
- sides, so the pair is pinned to the left rather than centred. */
.demoStage {
- --mw: 108cqw;
- --bleed: calc(var(--mw) * 0.06);
- --pw: calc(var(--mw) * 0.3);
+ --pair: 1.135;
+ --pw: calc(var(--mw) * 0.36);
+ --mac-top: 0px;
}
- /* Stacked, the stage is sized to its contents, so anchor to the bottom. */
- .demoStage .macbook {
- top: auto;
- bottom: calc(var(--mw) * 0.03);
- left: calc(-1 * var(--bleed));
- }
-
- .demoStage .iphone {
- top: auto;
- right: 0.5cqw;
- bottom: 0;
- left: auto;
+ .demoMacWindow .macContent {
+ font-size: 1.9cqw;
}
}
-/* Phones: side by side the pair would be thumbnails, so the scene stacks.
- The Mac takes the full width with the terminal filling its display, and
- the phone sits below at two thirds of the width, large enough for its own
- controls to be tapped. The hero media's aspect ratio (landing.css) is what
- gives the stage the height for both. */
+/* Phones: anything MacBook-shaped at this width has a display 170px tall,
+ which fits neither the terminal nor the note. So the scene flips to match
+ the visitor, who is holding a phone: the MacBook is reduced to its display
+ (bezel, notch and rounded corners, no chassis), drawn taller than 16:10 so
+ the terminal shows seven lines above the note, and the iPhone becomes the
+ main object, most of the width and cropped to its upper part, fading out
+ below the join button. The two overlap by a sliver so they read as one
+ scene. The hero media's aspect ratio (landing.css) is what gives the stage
+ the height for both. */
@media (max-width: 760px) {
.demoStage {
--mw: 100cqw;
- --pw: 60cqw;
+ --pw: 86cqw;
}
+ /* Nearly square: seven lines of terminal, then a two-line note with its
+ pill button, all above where the phone overlaps the panel. */
.demoStage .macbook {
top: 0;
bottom: auto;
left: 0;
+ aspect-ratio: 1 / 0.96;
+ }
+
+ .demoStage .macbookLid {
+ inset: 0;
+ border-radius: 3.4cqw;
+ }
+
+ .demoStage .macbookScreen {
+ inset: 1.1cqw;
+ border-radius: 2.4cqw;
+ }
+
+ .demoStage .macbookNotch {
+ top: 1.1cqw;
+ }
+
+ .demoStage .macbookBase {
+ display: none;
}
.demoStage .iphone {
top: auto;
- right: auto;
+ right: 0;
bottom: 0;
- left: 20cqw;
+ left: auto;
+ aspect-ratio: 402 / 600;
+ /* The lower part of the phone fades out rather than being cut; the mask
+ also clips the shell's overhang and the drop shadow, so the shadow goes. */
+ filter: none;
+ mask-image: linear-gradient(to bottom, #000 80%, transparent 97%);
+ }
+
+ /* The shell reaches past the mask by more than its corner radius, so the
+ crop shows a phone that continues: no bottom edge, no corner starting to
+ curve inside the fade. The ring, bezel and screen are 100% tall and
+ follow; what they push lower (home indicator, key bar) is under the mask. */
+ .demoStage .iphoneShell {
+ bottom: -20cqw;
}
.demoStage .macWindow {
- top: 9%;
+ top: 7%;
left: 4%;
width: 92%;
- height: 62%;
+ height: 58%;
+ /* The window's corners scale with the screen; at this size the desktop
+ ratio gives 5px, too tight for 14px type. */
+ border-radius: 2.6cqw;
}
/* At phone widths the Dock would compete with the note for the strip under
@@ -1992,13 +2032,21 @@
display: none;
}
+ /* Menu titles keep the desktop widths, so the height only goes up enough
+ to stay visible at 3px: taller and they turn from dashes into blobs next
+ to the notch. The Apple mark, a 4px smudge at the desktop ratio, grows. */
.macMenuPill,
.macMenuDot {
- height: 1.4cqw;
+ height: 0.95cqw;
}
.macMenuDot {
- width: 1.4cqw;
+ width: 0.95cqw;
+ }
+
+ .macMenuIcon {
+ width: 2cqw;
+ height: 2cqw;
}
/* The Mac is now the width of a phone screen, so everything inside it is
@@ -2043,7 +2091,7 @@
}
.demoGuide {
- top: 73%;
+ top: 67%;
right: 4%;
left: 4%;
}
@@ -2055,6 +2103,24 @@
font-size: 4.2cqw;
}
+ /* The phone is below the panel here, not beside it, so the arrows that
+ point at it turn to point down-right. */
+ .demoNoteArrow.isTrailing {
+ scale: -1 -1;
+ vertical-align: -0.15em;
+ animation-name: demoArrowNudgeDown;
+ }
+
+ @keyframes demoArrowNudgeDown {
+ 0%,
+ 100% {
+ translate: 0 0;
+ }
+ 45% {
+ translate: 0.1em 0.08em;
+ }
+ }
+
/* Pressed keys echo in the title bar's free right half rather than under
the window, where the note now takes the full width. */
.demoKeycapTrail {
@@ -2071,6 +2137,70 @@
}
}
+/* ── Touch input ───────────────────────────────────────────────────────── */
+
+/* The guide's notes carry a keyboard phrasing and a touch phrasing; one of
+ them shows. The query is the complement of the scripts' `(hover: hover) and
+ (pointer: fine)` (use-coarse-pointer.ts), written out so it also holds
+ before hydration and in browsers without `not (…)` media queries. */
+.demoWhenTouch {
+ display: none;
+}
+
+@media (hover: none), (pointer: coarse), (pointer: none) {
+ .demoWhenKeys {
+ display: none;
+ }
+
+ .demoWhenTouch {
+ display: inline;
+ }
+
+ /* Without a keyboard the shell is display only (product-demo.tsx drops the
+ hidden input, the drag grip and the keycaps): the window stops advertising
+ affordances it no longer has, and the title bar gives the page its scroll
+ back. */
+ .demoMacWindow .macTitlebar {
+ cursor: default;
+ touch-action: auto;
+ }
+
+ .demoMacWindow .macContent {
+ cursor: default;
+ }
+
+ /* The button is the one way through the loop here, so it stops being an
+ underlined word in the handwriting and becomes a pill: a 40px target in
+ the page's sans, on its own line under the note's sentence. */
+ .demoNoteAction {
+ display: flex;
+ width: fit-content;
+ min-height: 40px;
+ align-items: center;
+ padding: 0 16px;
+ margin: 0.4em 0 0.1em;
+ border-radius: 999px;
+ background: #fff;
+ color: #10254d;
+ font-family: var(--font-sans);
+ font-size: 15px;
+ font-weight: 600;
+ line-height: 1;
+ letter-spacing: -0.01em;
+ text-decoration: none;
+ box-shadow: 0 0.15cqw 0.6cqw rgba(0, 20, 60, 0.35);
+ transition: scale var(--motion-fast) ease;
+ }
+
+ .demoNoteAction:active {
+ scale: 0.97;
+ }
+
+ .demoNoteAction:focus-visible {
+ outline-offset: 2px;
+ }
+}
+
/* ── Mac in dark appearance ────────────────────────────────────────────── */
/* The Mac follows the visitor's appearance the way a real one would: the
@@ -2224,6 +2354,10 @@
outline-color: #7aa7ff;
}
+ .demoNote {
+ background: rgba(8, 20, 48, 0.34);
+ }
+
/* Keycaps read as dark keys on a dark keyboard. */
.demoKeys kbd,
.demoKeycap {
@@ -2263,10 +2397,6 @@
opacity: 1;
transform: none;
}
-
- .demoNote.isQuiet {
- opacity: 0.82;
- }
}
@media (forced-colors: active) {
diff --git a/apps/web/app/landing.css b/apps/web/app/landing.css
index 7bb9d06..f5a397c 100644
--- a/apps/web/app/landing.css
+++ b/apps/web/app/landing.css
@@ -1359,13 +1359,16 @@ html.lenis body {
order: -1;
}
+ /* Stacked scene (hero-demo.css): the pair is centred in the column, so the
+ media box stays on the page grid. Its height is the phone's, which at 36%
+ of the Mac's width hangs to 0.78 of it; the ratio leaves a little over. */
.landingHeroMedia {
align-self: auto;
- width: calc(100% + var(--page-pad));
+ width: 100%;
height: auto;
min-height: 0;
- margin: 0 0 0 calc(-1 * var(--page-pad));
- aspect-ratio: 100 / 68;
+ margin: 0;
+ aspect-ratio: 100 / 70;
}
.landingConnectionVisual {
@@ -1439,12 +1442,13 @@ html.lenis body {
min-height: 48px;
}
- /* Stacked scene (hero-demo.css): the Mac's height plus the phone's, less
- the sliver where the phone overlaps the Mac's base. */
+ /* Phone scene (hero-demo.css): the Mac display panel (0.96 of the width)
+ plus the cropped phone (0.86 wide at 402:600, so 1.284 tall), less the
+ sliver where the phone overlaps the panel's bottom edge (~0.054). */
.landingHeroMedia {
width: 100%;
margin-left: 0;
- aspect-ratio: 100 / 178;
+ aspect-ratio: 100 / 219;
}
.landingSplit,
diff --git a/apps/web/components/hero-demo/demo-guide.tsx b/apps/web/components/hero-demo/demo-guide.tsx
index e4515a9..0535feb 100644
--- a/apps/web/components/hero-demo/demo-guide.tsx
+++ b/apps/web/components/hero-demo/demo-guide.tsx
@@ -1,15 +1,22 @@
"use client";
+import type { ReactNode } from "react";
import { type DemoState, type GuideStep, guideStep } from "./demo-session";
import type { DemoSend, Keycap } from "./use-demo-session";
/**
* Handwritten margin notes that nudge the visitor through the loop. Each
- * note is also the fallback for people who cannot type the prefix (touch
- * screens, keyboard layouts where `\` needs AltGr): clicking it performs the
- * same action the keys would, and the keycap trail still shows the keys. The
- * container is a polite live region so each new note is read out as the loop
- * advances, not only seen.
+ * note is also the fallback for people who cannot type the prefix (keyboard
+ * layouts where `\` needs AltGr): clicking it performs the same action the
+ * keys would, and the keycap trail still shows the keys.
+ *
+ * Every note carries two phrasings. With a keyboard it names the chord and
+ * offers the button as a shortcut; on touch there are no keys to press, so the
+ * chord goes and the button, drawn as a pill, becomes the one way through.
+ * Which phrasing shows is decided by CSS (`.demoWhenKeys` / `.demoWhenTouch`
+ * in hero-demo.css) on the same pointer query the scripts use, so the note is
+ * right from the first paint rather than after hydration. The container is a
+ * polite live region so each new note is read out as the loop advances.
*/
export function DemoGuide({ state, send }: { state: DemoState; send: DemoSend }) {
const step = guideStep(state);
@@ -26,14 +33,15 @@ function Note({ step, send }: { step: GuideStep; send: DemoSend }) {
return (
- share this session: press then or{" "}
-
+ keyboard="share for me"
+ touch="Share this session"
+ />
);
case "sharing":
@@ -46,15 +54,20 @@ function Note({ step, send }: { step: GuideStep; send: DemoSend }) {
case "tap":
return (
- it just showed up on the phone — tap the session{" "}
-
-
+ keyboard="or tap it for me"
+ touch="Open it for me"
+ />
+
+
+
- keep typing — it's live on both.{" "}
-
- {" "}
-
-
+
+ keep typing — it's live on both.{" "}
+
+ {" "}
+
+
+ it's live on both — the same shell, keystroke for keystroke.
+ send({ type: "hostCommand", command: "unshare" })}
+ keyboard="closes the share"
+ touch="Close the share"
+ />
);
case "done":
return (
that's the whole loop — nothing left the Mac until you said so.{" "}
-
+ send({ type: "reset" })}
+ keyboard="start over ↺"
+ touch="Start over ↺"
+ />
);
}
}
+/** Shown only when a keyboard and a fine pointer are at hand. */
+function Keyboard({ children }: { children: ReactNode }) {
+ return {children};
+}
+
+/** Shown only on touch. */
+function Touch({ children }: { children: ReactNode }) {
+ return {children};
+}
+
+/**
+ * The note's one control. A single button so there is one tab stop; the two
+ * labels inside it are toggled by the same CSS as the surrounding text, so a
+ * screen reader only ever meets the one that is showing.
+ */
+function Action({
+ onClick,
+ keyboard,
+ touch,
+}: {
+ onClick: () => void;
+ keyboard: string;
+ touch: string;
+}) {
+ return (
+
+ );
+}
+
/**
* A sketched arrow, drawn with a slightly bowed shaft and an open head so it
* matches the handwritten notes. Leading arrows point up-left at the window;
- * trailing ones are mirrored to point up-right at the phone.
+ * trailing ones are mirrored to point up-right at the phone (and turned to
+ * point down-right where the phone sits below the panel, see hero-demo.css).
*/
function HandArrow({ trailing = false }: { trailing?: boolean }) {
return (
diff --git a/apps/web/components/hero-demo/mac-terminal.tsx b/apps/web/components/hero-demo/mac-terminal.tsx
index 4b137f5..f539d4d 100644
--- a/apps/web/components/hero-demo/mac-terminal.tsx
+++ b/apps/web/components/hero-demo/mac-terminal.tsx
@@ -8,16 +8,25 @@ import type { useWindowDrag } from "./use-window-drag";
type Drag = ReturnType;
+/**
+ * The terminal window on the Mac. With a keyboard and a fine pointer it is a
+ * live shell: tapping focuses it, the title bar drags it. On touch the loop is
+ * driven from the guide's buttons instead, so the window is display only: no
+ * hidden input to summon a soft keyboard over the page, no drag grip to fight
+ * the page's scroll, and no caret promising input.
+ */
export function MacTerminal({
state,
send,
windowRef,
drag,
+ touch = false,
}: {
state: DemoState;
send: DemoSend;
windowRef: RefObject;
drag: Drag;
+ touch?: boolean;
}) {
const scrollRef = useRef(null);
const { focus, inputProps } = useTerminalInput("mac", send);
@@ -31,16 +40,20 @@ export function MacTerminal({
aria-label="Host terminal"
data-transport={hostTransport(state)}
data-armed={state.armed ? "true" : "false"}
- onPointerDown={(event) => {
- // Let real text selection happen inside the scrollback; anything else
- // focuses the shell so the next keystroke lands in it.
- if (event.button !== 0) return;
- if (window.getSelection()?.toString()) return;
- event.preventDefault();
- focus();
- }}
+ onPointerDown={
+ touch
+ ? undefined
+ : (event) => {
+ // Let real text selection happen inside the scrollback; anything
+ // else focuses the shell so the next keystroke lands in it.
+ if (event.button !== 0) return;
+ if (window.getSelection()?.toString()) return;
+ event.preventDefault();
+ focus();
+ }
+ }
>
-
+
@@ -49,28 +62,36 @@ export function MacTerminal({
{hostTitle(state)}
-
-
+
+ {touch ? null : }
{/* Drawn in the title bar, but placed after the shell in the DOM so Tab
reaches the terminal first and window-moving second. */}
-
+ {touch ? null : (
+
+ )}
);
}
diff --git a/apps/web/components/hero-demo/phone-app.tsx b/apps/web/components/hero-demo/phone-app.tsx
index e0d5d86..0eadeb7 100644
--- a/apps/web/components/hero-demo/phone-app.tsx
+++ b/apps/web/components/hero-demo/phone-app.tsx
@@ -54,7 +54,16 @@ const SESSION_CWD = `${DEMO_HOME_DIR}/projects/api`;
* on the stack. Sheets present the way iOS does: the list shrinks into a card
* on black while the system status bar stays put above everything.
*/
-export function PhoneApp({ state, send }: { state: DemoState; send: DemoSend }) {
+export function PhoneApp({
+ state,
+ send,
+ touch = false,
+}: {
+ state: DemoState;
+ send: DemoSend;
+ /** No keyboard at hand: the terminal is display only (see MacTerminal). */
+ touch?: boolean;
+}) {
const { screen } = state.viewer;
const isDetail = screen === "terminal";
const hasSheet = screen === "join" || screen === "settings";
@@ -70,7 +79,7 @@ export function PhoneApp({ state, send }: { state: DemoState; send: DemoSend })
{overlay ? : null}
@@ -579,8 +600,13 @@ function TerminalScreen({
sendKey("ArrowUp")} />
-
-
+ {/* The keyboard key would open nothing where there is no input. */}
+ {touch ? null : (
+ <>
+
+
+ >
+ )}
diff --git a/apps/web/components/hero-demo/use-coarse-pointer.ts b/apps/web/components/hero-demo/use-coarse-pointer.ts
new file mode 100644
index 0000000..b524dde
--- /dev/null
+++ b/apps/web/components/hero-demo/use-coarse-pointer.ts
@@ -0,0 +1,27 @@
+"use client";
+
+import { useSyncExternalStore } from "react";
+
+/**
+ * The hero's input surfaces (typing into the shell, dragging the window) are
+ * only offered where a real keyboard and a fine pointer are at hand. Anything
+ * else counts as touch: a phone, a tablet without a trackpad, a kiosk. Layout
+ * is decided separately by width; a keyboard-equipped iPad gets the compact
+ * layout but keeps typing, a touch-only desktop-sized screen does not.
+ */
+const FINE_POINTER = "(hover: hover) and (pointer: fine)";
+
+function subscribe(onChange: () => void): () => void {
+ const query = window.matchMedia(FINE_POINTER);
+ query.addEventListener("change", onChange);
+ return () => query.removeEventListener("change", onChange);
+}
+
+const getSnapshot = () => !window.matchMedia(FINE_POINTER).matches;
+// Server render and the hydration pass assume a fine pointer, so the markup
+// matches; a touch device switches on the first client render.
+const getServerSnapshot = () => false;
+
+export function useCoarsePointer(): boolean {
+ return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
+}
diff --git a/apps/web/components/hero-demo/use-demo-autoplay.ts b/apps/web/components/hero-demo/use-demo-autoplay.ts
new file mode 100644
index 0000000..ee1318a
--- /dev/null
+++ b/apps/web/components/hero-demo/use-demo-autoplay.ts
@@ -0,0 +1,98 @@
+"use client";
+
+import { type RefObject, useCallback, useEffect, useRef, useState } from "react";
+import { type DemoState, guideStep } from "./demo-session";
+import type { DemoSend } from "./use-demo-session";
+
+/** How much of the stage has to be on screen before the loop starts. */
+const VISIBLE_RATIO = 0.5;
+/** Pauses that read as a person deciding, not a script firing. */
+const BEFORE_SHARE_MS = 1800;
+const BEFORE_TAP_MS = 1600;
+const BEFORE_TYPING_MS = 900;
+const KEYSTROKE_MS = 120;
+const BEFORE_UNSHARE_MS = 3200;
+/** What the phone "types" once it is attached. */
+const TYPED_COMMAND = "ls";
+
+type Phase = "waiting" | "playing" | "stopped";
+
+/**
+ * Walks the demo once on its own where nobody is going to type: on touch, most
+ * visitors scroll past a demo that waits for a tap, and would never see that
+ * the phone attaches or that a keystroke lands on both screens. The run starts
+ * when the stage is mostly in view, follows the same steps the guide asks
+ * for, and stops for good the moment the visitor does anything themselves or
+ * scrolls the stage away. Off when motion is reduced, and off with a keyboard,
+ * where the notes invite the visitor to drive.
+ */
+export function useDemoAutoplay(
+ stageRef: RefObject,
+ state: DemoState,
+ send: DemoSend,
+ enabled: boolean,
+) {
+ const [phase, setPhase] = useState("waiting");
+ const stop = useCallback(() => setPhase("stopped"), []);
+
+ // Start when the stage scrolls into view; give up if it scrolls away again.
+ useEffect(() => {
+ if (!enabled || phase === "stopped") return;
+ const stage = stageRef.current;
+ if (!stage || typeof IntersectionObserver === "undefined") return;
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
+ const observer = new IntersectionObserver(
+ ([entry]) => {
+ if (!entry) return;
+ if (entry.intersectionRatio >= VISIBLE_RATIO) setPhase("playing");
+ else setPhase((current) => (current === "playing" ? "stopped" : current));
+ },
+ { threshold: [VISIBLE_RATIO] },
+ );
+ observer.observe(stage);
+ return () => observer.disconnect();
+ }, [enabled, phase, stageRef]);
+
+ // Drive the loop step by step. Each step schedules only its own next move,
+ // so the state machine's own latencies (relay, ticket, P2P) still pace it.
+ const step = guideStep(state);
+ const sendRef = useRef(send);
+ sendRef.current = send;
+ useEffect(() => {
+ if (!enabled || phase !== "playing") return;
+ const timers: number[] = [];
+ const after = (delay: number, run: () => void) => {
+ timers.push(window.setTimeout(run, delay));
+ };
+ switch (step) {
+ case "share":
+ after(BEFORE_SHARE_MS, () => sendRef.current({ type: "hostCommand", command: "share" }));
+ break;
+ case "tap":
+ after(BEFORE_TAP_MS, () => sendRef.current({ type: "tapSession" }));
+ break;
+ case "live": {
+ let at = BEFORE_TYPING_MS;
+ for (const key of TYPED_COMMAND) {
+ after(at, () => sendRef.current({ type: "key", key }));
+ at += KEYSTROKE_MS;
+ }
+ after(at, () => sendRef.current({ type: "key", key: "Enter" }));
+ after(at + BEFORE_UNSHARE_MS, () =>
+ sendRef.current({ type: "hostCommand", command: "unshare" }),
+ );
+ break;
+ }
+ case "done":
+ setPhase("stopped");
+ break;
+ case "sharing":
+ case "connecting":
+ break;
+ }
+ return () => timers.forEach((timer) => window.clearTimeout(timer));
+ }, [enabled, phase, step]);
+
+ /** Call from anything the visitor does; the script yields to them. */
+ return { stop } as const;
+}
diff --git a/apps/web/components/product-demo.tsx b/apps/web/components/product-demo.tsx
index 646f82f..63d1b0a 100644
--- a/apps/web/components/product-demo.tsx
+++ b/apps/web/components/product-demo.tsx
@@ -1,12 +1,14 @@
"use client";
-import { useRef } from "react";
+import { useCallback, useRef } from "react";
import { DemoGuide, KeycapTrail } from "./hero-demo/demo-guide";
import { MacDock, MacMenuBar } from "./hero-demo/mac-desktop";
-import { guideStep, hostTransport } from "./hero-demo/demo-session";
+import { type DemoAction, guideStep, hostTransport } from "./hero-demo/demo-session";
import { MacTerminal } from "./hero-demo/mac-terminal";
import { PhoneApp } from "./hero-demo/phone-app";
import { useWrapperAnnouncements } from "./hero-demo/terminal-view";
+import { useCoarsePointer } from "./hero-demo/use-coarse-pointer";
+import { useDemoAutoplay } from "./hero-demo/use-demo-autoplay";
import { useDemoSession } from "./hero-demo/use-demo-session";
import { useWindowDrag } from "./hero-demo/use-window-drag";
@@ -16,22 +18,39 @@ import { useWindowDrag } from "./hero-demo/use-window-drag";
* machine (`hero-demo/demo-session.ts`), so what the visitor types on the Mac
* is what the phone shows, and sharing from the shell is what makes the
* session appear in the app.
+ *
+ * With a keyboard the visitor types the loop. On touch the shell is display
+ * only: the loop plays itself once when the stage comes into view, and the
+ * guide's buttons and the phone let the visitor take over at any point.
*/
export function ProductDemo() {
const { state, send, keycaps } = useDemoSession();
const liveRef = useWrapperAnnouncements(state);
+ const stageRef = useRef(null);
const windowRef = useRef(null);
+ const touch = useCoarsePointer();
// The window's drag offset lives on the screen so the guide notes and the
// keycap trail, which annotate the window, move with it.
const drag = useWindowDrag(windowRef);
+ const { stop } = useDemoAutoplay(stageRef, state, send, touch);
+ // Anything the visitor does ends the scripted run before it is applied.
+ const sendFromVisitor = useCallback(
+ (action: DemoAction) => {
+ stop();
+ send(action);
+ },
+ [send, stop],
+ );
return (