From 78f15da9c49a48f26bcd67d45c3b8d0217fc4823 Mon Sep 17 00:00:00 2001 From: Can Vardar Date: Fri, 4 Sep 2026 12:36:33 +0300 Subject: [PATCH 1/2] feat(web): add touch-aware hero demo guide and autoplay Detect coarse pointers, show touch-specific guide copy and actions, autoplay the share loop on touch devices, and refine compact hero layout styles. --- apps/web/app/hero-demo.css | 204 ++++++++++++++---- apps/web/app/landing.css | 16 +- apps/web/components/hero-demo/demo-guide.tsx | 119 +++++++--- .../web/components/hero-demo/mac-terminal.tsx | 77 ++++--- apps/web/components/hero-demo/phone-app.tsx | 50 +++-- .../hero-demo/use-coarse-pointer.ts | 27 +++ .../components/hero-demo/use-demo-autoplay.ts | 98 +++++++++ apps/web/components/product-demo.tsx | 37 +++- 8 files changed, 504 insertions(+), 124 deletions(-) create mode 100644 apps/web/components/hero-demo/use-coarse-pointer.ts create mode 100644 apps/web/components/hero-demo/use-demo-autoplay.ts 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" + /> + + +

); case "connecting": @@ -68,35 +81,71 @@ function Note({ step, send }: { step: GuideStep; send: DemoSend }) { return (

- 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(); + } + } > -
+
- - + + {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}
- +