diff --git a/web/public/img/tau.jpg b/web/public/img/tau.jpg
new file mode 100644
index 000000000..9b2db7274
Binary files /dev/null and b/web/public/img/tau.jpg differ
diff --git a/web/src/app/App.tsx b/web/src/app/App.tsx
index 82b0450fb..04764917c 100644
--- a/web/src/app/App.tsx
+++ b/web/src/app/App.tsx
@@ -2,9 +2,11 @@ import { AppProviders } from "./providers/AppProviders";
import { DesktopShell } from "./features/desktop/DesktopShell";
import { Catalog } from "../design-system/catalog";
import { TemplatePreview } from "../design-system/previews";
+import { WelcomeSite } from "./features/website/WelcomeSite";
const DESIGN_SYSTEM_PATHS = new Set(["/design", "/design.html", "/design-system"]);
const TEMPLATE_PREVIEW_PREFIX = "/design/preview/";
+const WELCOME_PATHS = new Set(["/welcome", "/welcome.html"]);
export function App() {
const { pathname } = window.location;
@@ -14,6 +16,11 @@ export function App() {
if (DESIGN_SYSTEM_PATHS.has(pathname)) {
return ;
}
+ // The public site is pre-auth by nature: like /design, it returns before
+ // AppProviders so it mounts with no gateway, session, or query client.
+ if (WELCOME_PATHS.has(pathname)) {
+ return ;
+ }
return (
diff --git a/web/src/app/features/website/ActionSequence.tsx b/web/src/app/features/website/ActionSequence.tsx
new file mode 100644
index 000000000..eb906d116
--- /dev/null
+++ b/web/src/app/features/website/ActionSequence.tsx
@@ -0,0 +1,51 @@
+import { useEffect, useState } from "preact/hooks";
+import { ACTION_STEPS } from "./beats";
+
+const STEP_MS = 900;
+
+export interface ActionSequenceProps {
+ /** Only run once the beat is on screen. */
+ active: boolean;
+}
+
+/** ActionSequence — the "…and do anything else." beat. Each row advances from
+ * pending to running to done, one at a time, so the beat demonstrates that the
+ * agent acts rather than just retrieves. Mock motion: nothing is dispatched.
+ *
+ * Under reduced motion every row is rendered already-done, which keeps the
+ * meaning (these things get completed) without the theatre. */
+export function ActionSequence({ active }: ActionSequenceProps) {
+ const reduced =
+ typeof window !== "undefined" &&
+ (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false);
+
+ const [done, setDone] = useState(reduced ? ACTION_STEPS.length : 0);
+
+ useEffect(() => {
+ if (!active || reduced) return;
+ if (done >= ACTION_STEPS.length) return;
+
+ const timer = setTimeout(() => setDone((n) => Math.min(n + 1, ACTION_STEPS.length)), STEP_MS);
+ return () => clearTimeout(timer);
+ }, [active, done, reduced]);
+
+ return (
+
+ {ACTION_STEPS.map((step, i) => {
+ const complete = i < done;
+ const running = i === done && active && !reduced;
+ return (
+
+
+
+ {complete ? step.done : step.label}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/web/src/app/features/website/CapabilityBoxes.tsx b/web/src/app/features/website/CapabilityBoxes.tsx
new file mode 100644
index 000000000..e447d2659
--- /dev/null
+++ b/web/src/app/features/website/CapabilityBoxes.tsx
@@ -0,0 +1,27 @@
+import { FIND_LABELS } from "./beats";
+
+export interface CapabilityBoxesProps {
+ /** Only animate once the beat is on screen, so the reveal isn't spent
+ * off-screen before anyone sees it. */
+ active: boolean;
+}
+
+/** CapabilityBoxes — the "I can also find anything else…" beat. Labelled boxes
+ * settle into a grid one after another, each one a thing the agent surfaced.
+ * Staggering is done with a per-item CSS custom property rather than JS timers
+ * so the whole thing costs one class toggle. */
+export function CapabilityBoxes({ active }: CapabilityBoxesProps) {
+ return (
+
+ );
+}
diff --git a/web/src/app/features/website/FakePermissionDialog.tsx b/web/src/app/features/website/FakePermissionDialog.tsx
new file mode 100644
index 000000000..9e7ff97d5
--- /dev/null
+++ b/web/src/app/features/website/FakePermissionDialog.tsx
@@ -0,0 +1,95 @@
+import { useEffect, useState } from "preact/hooks";
+
+const CONSIDER_MS = 1600;
+const SETTLE_MS = 900;
+/* Matches the dismissal transition in website.css. */
+const FADE_MS = 450;
+
+export interface FakePermissionDialogProps {
+ /** Runs the sequence once the beat is on screen. */
+ active: boolean;
+ /** Fired once the refusal has settled, so the beat can show its aftermath. */
+ onAnswered?: () => void;
+}
+
+/** FakePermissionDialog — a *prop*. It imitates a browser camera/microphone
+ * prompt, waits, then answers "Don't allow" on its own. Nothing here touches
+ * getUserMedia and no permission is ever requested; the refusal is the script.
+ *
+ * Deliberately not the design-system Dialog: this is meant to read as the
+ * browser's chrome intruding on the page, so it borrows the shape of a native
+ * permission bubble instead of GSV's own modal vocabulary. */
+export function FakePermissionDialog({ active, onAnswered }: FakePermissionDialogProps) {
+ const [phase, setPhase] = useState<"hidden" | "asking" | "refusing" | "done" | "gone">(
+ "hidden",
+ );
+
+ useEffect(() => {
+ if (!active || phase !== "hidden") return;
+
+ let cancelled = false;
+ const timers: ReturnType[] = [];
+
+ setPhase("asking");
+ timers.push(
+ setTimeout(() => {
+ if (cancelled) return;
+ setPhase("refusing");
+ timers.push(
+ setTimeout(() => {
+ if (cancelled) return;
+ setPhase("done");
+ onAnswered?.();
+ // Leave the layout once the dismissal has played out, so the
+ // aftermath line sits where the prompt was rather than below the
+ // hole it would otherwise leave behind.
+ timers.push(
+ setTimeout(() => {
+ if (!cancelled) setPhase("gone");
+ }, FADE_MS),
+ );
+ }, SETTLE_MS),
+ );
+ }, CONSIDER_MS),
+ );
+
+ return () => {
+ cancelled = true;
+ timers.forEach(clearTimeout);
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [active]);
+
+ if (phase === "hidden" || phase === "gone") return null;
+
+ const refused = phase === "refusing" || phase === "done";
+
+ return (
+
+
+ gsv.space wants to
+
+
+
+
+ Use your camera
+
+
+
+ Use your microphone
+
+
+
+
+ Don’t allow
+
+ Allow
+
+
+ );
+}
diff --git a/web/src/app/features/website/LandingSection.tsx b/web/src/app/features/website/LandingSection.tsx
new file mode 100644
index 000000000..c3b6ddb3a
--- /dev/null
+++ b/web/src/app/features/website/LandingSection.tsx
@@ -0,0 +1,133 @@
+import { useState } from "preact/hooks";
+import { Button } from "../../components/ui/Button";
+import { TextInput } from "../../components/ui/TextInput";
+
+const DEMO_VIDEO = "https://gsv.space/demovid_edited.mp4";
+const DEMO_POSTER = "https://gsv.space/demovid-poster.jpg";
+
+/* Titles carry the page; the descriptions are held back until asked for. */
+const VALUE_PROPS = [
+ {
+ title: "one mind, every device",
+ body: "Your laptop, phone, and servers act as one computer, one context, one memory, and it stays awake even when they’re all asleep.",
+ },
+ {
+ title: "your account, your keys",
+ body: "Runs in your own Cloudflare account — your keys, your data, never routed through us. No open ports, no VPN, nothing exposed.",
+ },
+ {
+ title: "open from the ground up",
+ body: "MIT-licensed. Read every line yourself, run your own, fork it. Don’t take our word for it.",
+ },
+];
+
+function isPlausibleEmail(value: string): boolean {
+ const trimmed = value.trim();
+ return trimmed.length > 2 && trimmed.includes("@") && !trimmed.endsWith("@");
+}
+
+/** ValueProp — title only until it is hovered or focused, then the description
+ * opens beneath it.
+ *
+ * Hover and keyboard focus are handled in CSS (:hover / :focus-within) rather
+ * than in state: focus fires before click, so a JS toggle would be opened by
+ * the focus and immediately shut again by the click of the same tap. Click
+ * only pins the card open, which is what a touch user needs. */
+function ValueProp({ title, body }: { title: string; body: string }) {
+ const [pinned, setPinned] = useState(false);
+
+ return (
+
+
+
{body}
+
+ );
+}
+
+/** LandingSection — where the narrative resolves into an ordinary page: what
+ * the product is, a way in, and the demo.
+ *
+ * The sign-up form is a visual mock. Nothing is sent and nothing is stored;
+ * submitting only swaps in the confirmation state. */
+export function LandingSection() {
+ const [email, setEmail] = useState("");
+ const [joined, setJoined] = useState(false);
+ const [touched, setTouched] = useState(false);
+
+ const invalid = touched && email.trim().length > 0 && !isPlausibleEmail(email);
+
+ function submit(event: Event) {
+ event.preventDefault();
+ setTouched(true);
+ if (!isPlausibleEmail(email)) return;
+ setJoined(true);
+ }
+
+ return (
+
+
+
+
general systems vehicle
+
a mind for your machines
+
+
+
+ {VALUE_PROPS.map((prop) => (
+
+ ))}
+
+
+
+
get there first
+ {joined ? (
+
+ You’re on the list. We’ll be in touch when there’s a seat.
+
+ ) : (
+
+ )}
+
Mock form · nothing is sent
+
+
+
+
see it in action
+
+
+
+
+ );
+}
diff --git a/web/src/app/features/website/MediaWindow.tsx b/web/src/app/features/website/MediaWindow.tsx
new file mode 100644
index 000000000..b9240ac10
--- /dev/null
+++ b/web/src/app/features/website/MediaWindow.tsx
@@ -0,0 +1,32 @@
+import type { ComponentChildren } from "preact";
+
+export interface MediaWindowProps {
+ /** Filename or subject, shown in the title bar. */
+ title?: string;
+ /** Narrows the window for upright content, so a portrait photo isn't cropped
+ * to a letterbox to fit a landscape column. */
+ portrait?: boolean;
+ children: ComponentChildren;
+}
+
+/** MediaWindow — a small system window, the way the desktop app surfaces an
+ * image the agent opened from the conversation: title bar with window
+ * controls, then the content. It reads as something the machine put on screen
+ * for you, not as page decoration. */
+export function MediaWindow({ title, portrait = false, children }: MediaWindowProps) {
+ return (
+
+
+
+
+
+
+
+ {title ? (
+ {title}
+ ) : null}
+
+
{children}
+
+ );
+}
diff --git a/web/src/app/features/website/NarrativeBeat.tsx b/web/src/app/features/website/NarrativeBeat.tsx
new file mode 100644
index 000000000..605dfd81a
--- /dev/null
+++ b/web/src/app/features/website/NarrativeBeat.tsx
@@ -0,0 +1,179 @@
+import type { ComponentChildren } from "preact";
+import { useEffect, useRef, useState } from "preact/hooks";
+import { AsciiGalaxyScan } from "../../components/ui/AsciiGalaxyScan";
+// The narrative message reuses the chat dock's own message shape, so its base
+// rules (.gsv-sm / -body / -text) must be loaded even though SystemMessage
+// itself is not rendered — it carries a meta row this page has no use for.
+import "../../components/ui/SystemMessage.css";
+import type { Beat } from "./beats";
+import { ActionSequence } from "./ActionSequence";
+import { CapabilityBoxes } from "./CapabilityBoxes";
+import { FakePermissionDialog } from "./FakePermissionDialog";
+import { MediaWindow } from "./MediaWindow";
+
+const TYPE_MS = 34;
+/** Held at a line break, so the second half of a two-line beat lands separately. */
+const BREAK_MS = 520;
+
+function prefersReducedMotion(): boolean {
+ if (typeof window === "undefined") return false;
+ return window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
+}
+
+/** Splits *emphasised* runs out of a line and wraps them. Applied to whatever
+ * has been revealed so far, so emphasis appears as the words do. */
+function withEmphasis(text: string): ComponentChildren[] {
+ return text.split(/(\*[^*]*\*?)/g).map((part, i) => {
+ if (part.startsWith("*")) {
+ return {part.replace(/\*/g, "")};
+ }
+ return part;
+ });
+}
+
+/** Types `text` out once, the first time `active` goes true, pausing at line
+ * breaks. Returns the text revealed so far plus whether it is still typing —
+ * the caret only shows while typing, so a finished beat carries no cue. */
+function useTypewriter(text: string, active: boolean) {
+ const reduced = prefersReducedMotion();
+ const [shown, setShown] = useState(reduced ? text : "");
+ const [typing, setTyping] = useState(false);
+ const started = useRef(reduced);
+
+ useEffect(() => {
+ if (!active || started.current) return;
+ started.current = true;
+
+ let cancelled = false;
+ let timer: ReturnType | undefined;
+ const wait = (ms: number) => new Promise((res) => { timer = setTimeout(res, ms); });
+
+ async function run() {
+ setTyping(true);
+ for (let i = 1; i <= text.length; i++) {
+ if (cancelled) return;
+ setShown(text.slice(0, i));
+ await wait(text[i - 1] === "\n" ? BREAK_MS : TYPE_MS);
+ }
+ if (!cancelled) setTyping(false);
+ }
+
+ run();
+ return () => {
+ cancelled = true;
+ if (timer) clearTimeout(timer);
+ setTyping(false);
+ };
+ }, [active, text]);
+
+ return { shown, typing };
+}
+
+export interface NarrativeBeatProps {
+ beat: Beat;
+ /** True once this beat has been reached. */
+ active: boolean;
+ /** Index, used only for the data attribute the observer reads back. */
+ index: number;
+}
+
+/** NarrativeBeat — one full-viewport scroll-snap section carrying one moment of
+ * the agent's monologue plus whatever it puts on screen alongside.
+ *
+ * The message body clones the shape of a real assistant message in the app
+ * (`.gsv-sm` → `.gsv-sm-body` → `.gsv-sm-text`) minus the meta row, so it is
+ * literally the same construction the chat dock uses — only larger. */
+export function NarrativeBeat({ beat, active, index }: NarrativeBeatProps) {
+ const { shown, typing } = useTypewriter(beat.text, active);
+ const [answered, setAnswered] = useState(false);
+ // Tau's photograph is not in the repo yet; until it is, the window says so
+ // rather than showing a broken image.
+ const [missingPhoto, setMissingPhoto] = useState(false);
+
+ // Whatever the beat puts on screen waits for its line, so the copy reads first.
+ const settled = shown.length >= beat.text.length;
+ // Media beats run two columns: the agent speaks on the left, the thing it
+ // opened sits on the right — the desktop app's arrangement.
+ const split = beat.kind === "galaxy" || beat.kind === "photo";
+
+ const message = (
+
+
+ {/* The typed copy is decorative duplication — the full line is exposed
+ once, unanimated, for assistive tech. */}
+ {beat.text.replace(/\*/g, "")}
+
+
+ );
+}
diff --git a/web/src/app/features/website/TimelineRail.tsx b/web/src/app/features/website/TimelineRail.tsx
new file mode 100644
index 000000000..2b0a1ac2f
--- /dev/null
+++ b/web/src/app/features/website/TimelineRail.tsx
@@ -0,0 +1,43 @@
+import { BEATS } from "./beats";
+
+export interface TimelineRailProps {
+ /** Index of the beat currently on screen. */
+ current: number;
+ /** Shown only once the opening screen is behind us. */
+ visible: boolean;
+ /** Jump to a beat. */
+ onSelect: (index: number) => void;
+}
+
+/** TimelineRail — the conversation's spatial timeline, one marker per moment.
+ *
+ * Ported from the desktop app's `render_timeline` (host/apps/desktop/src/app/
+ * view.rs): 4×8px pill markers in 20px slots, the selected one in accent at
+ * full strength and the rest quieted to 0.68. The desktop app anchors this on
+ * the left; here it sits on the right, per the brief. */
+export function TimelineRail({ current, visible, onSelect }: TimelineRailProps) {
+ return (
+
+ );
+}
diff --git a/web/src/app/features/website/WelcomeSite.tsx b/web/src/app/features/website/WelcomeSite.tsx
new file mode 100644
index 000000000..611b973ef
--- /dev/null
+++ b/web/src/app/features/website/WelcomeSite.tsx
@@ -0,0 +1,160 @@
+import { useCallback, useEffect, useRef, useState } from "preact/hooks";
+import { BEATS, RAIL_FROM_BEAT, SKIP_FROM_BEAT } from "./beats";
+import { LandingSection } from "./LandingSection";
+import { NarrativeBeat } from "./NarrativeBeat";
+import { TimelineRail } from "./TimelineRail";
+import "./website.css";
+
+/** WelcomeSite — the public front door. A scroll-driven monologue that resolves
+ * into a conventional landing page.
+ *
+ * It owns its own scroll container rather than letting the document scroll:
+ * the app root is `overflow: hidden; height: 100dvh` (styles.css), so a nested
+ * scroller is the way to scroll here without mutating global page styles that
+ * the real app depends on. */
+export function WelcomeSite() {
+ const scrollerRef = useRef(null);
+ // `reached` is monotonic and drives one-shot animations — scrolling back up
+ // must not replay a beat. `current` tracks where the reader actually is, and
+ // drives the timeline rail.
+ const [reached, setReached] = useState(0);
+ const [current, setCurrent] = useState(0);
+ const [atLanding, setAtLanding] = useState(false);
+
+ // Scrolls to an element, suspending snap for the trip. Mandatory snap would
+ // otherwise capture a programmatic smooth scroll partway and strand it.
+ const scrollTo = useCallback((target: HTMLElement) => {
+ const root = scrollerRef.current;
+ if (!root) return;
+
+ const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
+ const top = target.offsetTop;
+
+ root.classList.add("is-skipping");
+ root.scrollTo({ top, behavior: reduced ? "auto" : "smooth" });
+
+ if (reduced) {
+ root.classList.remove("is-skipping");
+ return;
+ }
+
+ // Poll until the scroll settles. `scrollend` is not usable here — it fires
+ // for the already-settled position before the smooth scroll begins, which
+ // would re-arm snapping mid-flight.
+ const deadline = performance.now() + 2500;
+ const settle = () => {
+ if (Math.abs(root.scrollTop - top) < 2 || performance.now() > deadline) {
+ root.classList.remove("is-skipping");
+ return;
+ }
+ requestAnimationFrame(settle);
+ };
+ requestAnimationFrame(settle);
+ }, []);
+
+ // Which beat is on screen. Mirrors the catalog's scroll-spy: bias the
+ // observation band toward the upper middle so a beat counts as current once
+ // it has genuinely arrived, not when its first pixel appears.
+ useEffect(() => {
+ const root = scrollerRef.current;
+ if (!root) return;
+
+ const sections = Array.from(root.querySelectorAll("[data-beat-index]"));
+ if (sections.length === 0) return;
+
+ if (!("IntersectionObserver" in window)) {
+ // No observer: reveal everything rather than stranding the page on beat 0.
+ setReached(BEATS.length - 1);
+ setCurrent(BEATS.length - 1);
+ return;
+ }
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ const visible = entries
+ .filter((entry) => entry.isIntersecting)
+ .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
+ const first = visible[0];
+ if (!first) return;
+ const index = Number(first.target.getAttribute("data-beat-index"));
+ if (Number.isNaN(index)) return;
+ setCurrent(index);
+ setReached((prev) => Math.max(prev, index));
+ },
+ { root, rootMargin: "-25% 0px -55% 0px", threshold: 0 },
+ );
+
+ sections.forEach((section) => observer.observe(section));
+ return () => observer.disconnect();
+ }, []);
+
+ // Once the landing is genuinely on screen, drop the skip affordance and the
+ // rail (neither has anywhere left to go) and release scroll snapping, so the
+ // page below reads as an ordinary document. The margin holds this off until
+ // the landing has come up past the lower third — otherwise a single pixel of
+ // it would unsnap the last beat.
+ useEffect(() => {
+ const root = scrollerRef.current;
+ const landing = root?.querySelector("#gsv-site-landing");
+ if (!root || !landing || !("IntersectionObserver" in window)) return;
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ const entry = entries[0];
+ if (entry) setAtLanding(entry.isIntersecting);
+ },
+ { root, rootMargin: "0px 0px -66% 0px", threshold: 0 },
+ );
+ observer.observe(landing);
+ return () => observer.disconnect();
+ }, []);
+
+ useEffect(() => {
+ scrollerRef.current?.classList.toggle("is-free", atLanding);
+ }, [atLanding]);
+
+ function skip() {
+ const landing = scrollerRef.current?.querySelector("#gsv-site-landing");
+ if (landing) scrollTo(landing);
+ }
+
+ function goToBeat(index: number) {
+ const beat = scrollerRef.current?.querySelector(
+ `[data-beat-index="${index}"]`,
+ );
+ if (beat) scrollTo(beat);
+ }
+
+ const showSkip = reached >= SKIP_FROM_BEAT && !atLanding;
+ const showRail = reached >= RAIL_FROM_BEAT && !atLanding;
+
+ return (
+
+ {/* Texture layers sit above the field and below the copy. Fixed to the
+ scroller so the screen effect stays put while content moves through
+ it — the page reads as one continuous display, not as scrolling
+ wallpaper. */}
+
+
+
+
+ {BEATS.map((beat, index) => (
+ = index} />
+ ))}
+
+
+
+
+
+
+
+ );
+}
diff --git a/web/src/app/features/website/beats.ts b/web/src/app/features/website/beats.ts
new file mode 100644
index 000000000..ce9dbe5ec
--- /dev/null
+++ b/web/src/app/features/website/beats.ts
@@ -0,0 +1,118 @@
+/* The narrative script. One entry per scroll beat, in order.
+ *
+ * `kind` selects what renders alongside the line:
+ * "text" — the line alone
+ * "permission"— the line, then a faked camera/mic prompt that answers NO
+ * "galaxy" — the GSV galaxy scan, shown in a system window
+ * "photo" — a photograph, shown in a system window
+ * "boxes" — the "find anything" box animation
+ * "actions" — the "do anything" action animation
+ *
+ * A line break in `text` is a real break in the delivery — the typewriter
+ * respects it, so the second half lands after a beat of silence.
+ * Text wrapped in *asterisks* renders emphasised.
+ *
+ * Copy is the briefing's, verbatim. Edit here, not in the components. */
+
+export type BeatKind = "text" | "permission" | "galaxy" | "photo" | "boxes" | "actions";
+
+export interface Beat {
+ id: string;
+ text: string;
+ kind: BeatKind;
+ /** Window title shown in the media chrome. */
+ caption?: string;
+ /** Image path — "photo" beats only. */
+ src?: string;
+ /** Alternative text — "photo" beats only. */
+ alt?: string;
+}
+
+/** The beat index from which the persistent skip affordance is offered. Before
+ * this the page deliberately shows no sign that anything is interactive. */
+export const SKIP_FROM_BEAT = 3;
+
+/** The timeline rail appears once the opening screen is behind us — the first
+ * screen carries one word and nothing else. */
+export const RAIL_FROM_BEAT = 1;
+
+export const BEATS: Beat[] = [
+ {
+ id: "hi",
+ text: "hi",
+ kind: "text",
+ },
+ {
+ id: "listen",
+ text: "I can’t hear you.",
+ kind: "permission",
+ },
+ {
+ id: "alone",
+ text: "Are you still there?\nI guess I’ll just talk about me, then.",
+ kind: "text",
+ },
+ {
+ id: "connect",
+ text: "Right now, there isn’t much to do.\nI need to connect to a human.",
+ kind: "text",
+ },
+ {
+ id: "you",
+ text: "Could it be you?",
+ kind: "text",
+ },
+ {
+ id: "photos",
+ text: "I can sort your photos from anywhere:\nphone, computers, clouds.",
+ kind: "galaxy",
+ caption: "photos — sorting",
+ },
+ {
+ id: "tau",
+ text: "Here is Tau. He is Steve’s dog. Steve is my creator.\n: )",
+ kind: "photo",
+ src: "/img/tau.jpg",
+ alt: "Tau, a black dog",
+ caption: "tau.jpg",
+ },
+ {
+ id: "find",
+ text: "I can find anything in your machines.",
+ kind: "boxes",
+ },
+ {
+ id: "do",
+ text: "Actually, we haven’t found something I *can’t* do, yet…",
+ kind: "actions",
+ },
+ {
+ id: "future",
+ text: "Are you ready for the future?",
+ kind: "text",
+ },
+];
+
+/** Labels for the "find anything" beat — things the agent can surface. */
+export const FIND_LABELS = [
+ "emails",
+ "documents",
+ "bills",
+ "photos",
+ "receipts",
+ "contacts",
+ "invoices",
+ "tickets",
+];
+
+/** Steps for the "do anything" beat. `done` is the settled state each step
+ * animates into, so the sequence reads as work completing rather than looping. */
+export const ACTION_STEPS: { label: string; done: string }[] = [
+ { label: "sending email", done: "sent" },
+ { label: "checking feed", done: "caught up" },
+ { label: "downloading recipe", done: "saved" },
+ { label: "installing app", done: "installed" },
+ { label: "updating events", done: "calendar synced" },
+ { label: "paying bill", done: "paid" },
+ { label: "uninstalling app", done: "removed" },
+];
diff --git a/web/src/app/features/website/website.css b/web/src/app/features/website/website.css
new file mode 100644
index 000000000..c133097b9
--- /dev/null
+++ b/web/src/app/features/website/website.css
@@ -0,0 +1,877 @@
+/* The public site. Everything here is design-system tokens and type; the one
+ * deliberate departure is scale — narrative copy runs far larger than the app's
+ * dense UI sizes, so a single line can carry a whole screen. Those overrides are
+ * scoped to .gsv-site and never touch :root. */
+
+.gsv-site {
+ /* Editorial scale — the only sanctioned deviation from app type sizes. */
+ --site-msg: clamp(1.5rem, 4.4vw, 3.15rem);
+ --site-lead: clamp(1.05rem, 1.9vw, 1.45rem);
+ --site-hero: clamp(2.1rem, 6vw, 4rem);
+ --site-gutter: clamp(20px, 5vw, 64px);
+ --site-col: 62rem;
+
+ position: relative;
+ height: 100%;
+ overflow-y: auto;
+ overflow-x: hidden;
+ scroll-snap-type: y mandatory;
+ overscroll-behavior-y: contain;
+
+ /* The message field, exactly as the chat dock paints it (#070612), under the
+ shell's radial wash so the screen has depth rather than being flat black. */
+ background:
+ radial-gradient(1100px 720px at 50% -6%, rgba(150, 140, 255, 0.07), transparent 58%),
+ #070612;
+ color: var(--text);
+ font-family: "Space Grotesk", sans-serif;
+
+ /* Contains the multiply blend of the scanline layer. */
+ isolation: isolate;
+}
+
+/* Snapping is suspended in two cases:
+ — while the skip jump is in flight, since mandatory snap would otherwise
+ capture the scroll partway and strand it on a beat;
+ — once the landing is reached, because a page of ordinary prose needs to
+ scroll freely rather than being pulled back to its own top. */
+.gsv-site.is-skipping,
+.gsv-site.is-free {
+ scroll-snap-type: none;
+}
+
+/* ── screen texture ────────────────────────────────────────────────────────
+ The scanline + vignette pair lifted from AsciiGalaxyScan.css. Fixed to the
+ viewport so the page reads as content moving behind one continuous display,
+ rather than as a texture that scrolls along with it. */
+.gsv-site-texture {
+ position: fixed;
+ inset: 0;
+ pointer-events: none;
+ z-index: 4;
+}
+
+.gsv-site-scanlines {
+ background: repeating-linear-gradient(
+ 0deg,
+ rgba(0, 0, 0, 0) 0,
+ rgba(0, 0, 0, 0) 2px,
+ rgba(0, 0, 0, 0.22) 3px
+ );
+ mix-blend-mode: multiply;
+}
+
+.gsv-site-vignette {
+ background: radial-gradient(
+ ellipse 86% 84% at 50% 50%,
+ rgba(0, 0, 0, 0) 62%,
+ rgba(0, 0, 0, 0.42) 100%
+ );
+}
+
+.gsv-site-flow {
+ position: relative;
+ z-index: 1;
+}
+
+/* ── beats ─────────────────────────────────────────────────────────────── */
+
+.gsv-site-beat {
+ scroll-snap-align: start;
+ /* Deliberately NOT scroll-snap-stop: always — that forces every gesture to
+ land on the next beat and refuses to let a short flick through at all, so
+ the page reads as stuck. Plain snap-align still gives one beat per screen
+ while letting a longer scroll carry through several. */
+ /* Viewport units, not 100% — the flow wrapper is auto-height, so a percentage
+ min-height has nothing to resolve against and the beat would collapse to
+ its content. */
+ min-height: 100vh;
+ min-height: 100dvh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: clamp(56px, 12vh, 128px) var(--site-gutter);
+}
+
+.gsv-site-beat-inner {
+ width: 100%;
+ max-width: var(--site-col);
+ display: flex;
+ flex-direction: column;
+ gap: clamp(28px, 5vh, 52px);
+}
+
+/* Media beats run two columns: the agent speaks on the left, the window it
+ opened sits on the right — the desktop app's arrangement. */
+.gsv-site-beat.is-split .gsv-site-beat-inner {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr);
+ align-items: center;
+ gap: clamp(24px, 4vw, 56px);
+}
+
+.gsv-site-beat-said {
+ display: flex;
+ flex-direction: column;
+ gap: clamp(22px, 4vh, 40px);
+ min-width: 0;
+}
+
+.gsv-site-beat-shown {
+ min-width: 0;
+}
+
+@media (max-width: 900px) {
+ .gsv-site-beat.is-split .gsv-site-beat-inner {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
+
+/* The message body is the chat dock's construction (.gsv-sm / -body / -text,
+ from SystemMessage.css) with only the size raised. No bubble, no avatar, no
+ border — bare prose on the field, as in the app. */
+.gsv-site-msg-text {
+ font-family: "Space Grotesk", sans-serif;
+ font-size: var(--site-msg);
+ font-weight: 400;
+ line-height: 1.28;
+ letter-spacing: -0.015em;
+ color: #eceaff;
+ text-wrap: pretty;
+ /* Phosphor bloom, in the house layered-text-shadow idiom. */
+ text-shadow:
+ 0 0 6px rgba(190, 184, 255, 0.28),
+ 0 0 22px rgba(150, 140, 255, 0.16);
+ /* Reserve the line box so typing never reflows the beat. */
+ min-height: 1.3em;
+}
+
+/* Two-column beats give the copy less room, so the line steps down a size. */
+.gsv-site-beat.is-split .gsv-site-msg-text {
+ font-size: clamp(1.35rem, 2.5vw, 2.15rem);
+}
+
+.gsv-site-msg-text em {
+ font-style: italic;
+ color: var(--accent-bright);
+}
+
+.gsv-site-caret {
+ display: inline-block;
+ width: 0.5em;
+ height: 0.92em;
+ margin-left: 0.1em;
+ vertical-align: -0.08em;
+ background: var(--accent);
+ box-shadow: 0 0 12px rgba(179, 174, 255, 0.5);
+ animation: gsvSiteCaret 1.05s steps(1) infinite;
+}
+
+@keyframes gsvSiteCaret {
+ 0%, 50% { opacity: 1; }
+ 50.01%, 100% { opacity: 0; }
+}
+
+/* Screen-reader copy: the typed text is aria-hidden decoration, so the real
+ line is exposed here once, unanimated. */
+.gsv-site-sr {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ margin: -1px;
+ padding: 0;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+ clip-path: inset(50%);
+ white-space: nowrap;
+ border: 0;
+}
+
+/* ── media window ──────────────────────────────────────────────────────
+ A small system window, the way the desktop app surfaces something the agent
+ opened out of the conversation. */
+
+.gsv-site-win {
+ margin: 0;
+ width: 100%;
+ border: 1px solid var(--border-raised);
+ background: var(--node-bg);
+ box-shadow: 0 24px 60px rgba(3, 2, 12, 0.62);
+ animation: gsvSiteIn 0.32s ease both;
+ overflow: hidden;
+}
+
+.gsv-site-win-bar {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 11px;
+ border-bottom: 1px solid var(--border);
+ background: var(--header-bar, #100e2a);
+}
+
+.gsv-site-win-dots {
+ display: inline-flex;
+ gap: 5px;
+ flex: none;
+}
+
+.gsv-site-win-dots i {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: var(--border-raised);
+}
+
+.gsv-site-win-title {
+ margin: 0;
+ min-width: 0;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ color: var(--text-dim);
+ text-transform: uppercase;
+}
+
+.gsv-site-win-body {
+ position: relative;
+ background: #05050d;
+ isolation: isolate;
+}
+
+/* Upright content: the window narrows and the crop goes portrait, so a phone
+ photograph keeps its subject instead of being letterboxed into a landscape
+ column. Held clear of the viewport height so it never overflows its beat. */
+.gsv-site-win.is-portrait {
+ max-width: min(22rem, 100%);
+ margin-inline: auto;
+}
+
+.gsv-site-photo {
+ display: block;
+ width: 100%;
+ aspect-ratio: 4 / 5;
+ max-height: 58vh;
+ object-fit: cover;
+ object-position: center 48%;
+}
+
+/* Placeholder shown until a real photograph is dropped in at the path the
+ beat names. Deliberately legible rather than pretty — it should read as a
+ missing asset, not as a design choice. */
+.gsv-site-photo-missing {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ aspect-ratio: 4 / 5;
+ padding: 24px;
+ text-align: center;
+ color: var(--text-dim);
+ background: repeating-linear-gradient(
+ 45deg,
+ #100d28 0,
+ #100d28 12px,
+ #0b0922 12px,
+ #0b0922 24px
+ );
+}
+
+.gsv-site-photo-missing code {
+ font-family: var(--gsv-font-mono);
+ font-size: 12px;
+ color: var(--accent-bright);
+}
+
+/* The galaxy brings its own 16/9 frame; drop its border so the window chrome is
+ the only frame. */
+.gsv-site-win-body .gsv-ascii-galaxy {
+ border: 0;
+ border-radius: 0;
+ min-height: 0;
+}
+
+/* ── faked permission prompt ───────────────────────────────────────────── */
+
+.gsv-site-perm {
+ width: min(24rem, 100%);
+ padding: 16px 18px 14px;
+ border: 1px solid var(--border-raised);
+ border-radius: 10px;
+ background: color-mix(in srgb, var(--node-bg) 100%, #ffffff 6%);
+ box-shadow: 0 22px 60px rgba(3, 2, 12, 0.7);
+ /* `backwards`, not `both`: a forwards-filling animation keeps asserting its
+ end state (opacity 1) and would outrank the .is-done dismissal below. */
+ animation: gsvSiteDrop 0.26s ease backwards;
+ transition: opacity 0.4s ease, transform 0.4s ease;
+}
+
+.gsv-site-perm.is-done {
+ opacity: 0;
+ transform: translateY(-6px);
+}
+
+.gsv-site-perm-origin {
+ color: var(--text-dim);
+ text-transform: none;
+ letter-spacing: 0.06em;
+}
+
+.gsv-site-perm-list {
+ margin: 12px 0 16px;
+ padding: 0;
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ gap: 7px;
+}
+
+.gsv-site-perm-item {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ color: var(--text-hi);
+}
+
+.gsv-site-perm-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--error);
+ box-shadow: 0 0 8px rgba(255, 111, 140, 0.5);
+}
+
+.gsv-site-perm-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 9px;
+}
+
+.gsv-site-perm-btn {
+ padding: 7px 14px;
+ border: 1px solid var(--border-raised);
+ border-radius: 6px;
+ font-family: "Space Grotesk", sans-serif;
+ font-size: 12px;
+ color: var(--text);
+ transition: background 0.18s, border-color 0.18s, color 0.18s, transform 0.18s;
+}
+
+.gsv-site-perm-btn.is-allow {
+ border-color: var(--primary-hi);
+ color: var(--accent-bright);
+}
+
+/* The refusal: the deny button takes focus and depresses on its own. */
+.gsv-site-perm-btn.is-picked {
+ background: var(--selected);
+ border-color: var(--accent);
+ color: #ffffff;
+ transform: scale(0.96);
+}
+
+.gsv-site-perm.is-refused .gsv-site-perm-btn.is-allow {
+ opacity: 0.35;
+}
+
+.gsv-site-perm-result {
+ color: var(--error);
+ text-transform: uppercase;
+ animation: gsvSiteIn 0.28s ease both;
+}
+
+/* ── "find anything" boxes ─────────────────────────────────────────────── */
+
+.gsv-site-boxes {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ /* Held to the left column so these beats sit where the media beats put their
+ copy, rather than spanning the full width. */
+ max-width: 34rem;
+}
+
+.gsv-site-box {
+ padding: 10px 15px;
+ border: 1px solid var(--border);
+ background: linear-gradient(180deg, #100e2a, var(--node-bg));
+ color: var(--text-dim);
+ text-transform: uppercase;
+ opacity: 0;
+ transform: translateY(10px) scale(0.98);
+}
+
+.gsv-site-boxes.is-live .gsv-site-box {
+ animation: gsvSiteIn 0.3s ease both;
+ animation-delay: calc(var(--i) * 90ms);
+}
+
+/* ── "do anything" actions ─────────────────────────────────────────────── */
+
+.gsv-site-actions {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ gap: 7px;
+ /* Stacked and held left, matching the media beats' copy column. */
+ max-width: 30rem;
+}
+
+.gsv-site-action {
+ display: flex;
+ align-items: center;
+ gap: 11px;
+ padding: 10px 14px;
+ border: 1px solid var(--border);
+ background: linear-gradient(180deg, #100e2a, var(--node-bg));
+ color: var(--text-dim);
+ transition: color 0.3s ease, border-color 0.3s ease;
+}
+
+.gsv-site-action-mark {
+ width: 9px;
+ height: 9px;
+ flex: none;
+ border: 1px solid var(--border-raised);
+ background: transparent;
+ transition: background 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease;
+}
+
+.gsv-site-action.is-running {
+ color: var(--accent-bright);
+ border-color: var(--primary-hi);
+}
+
+.gsv-site-action.is-running .gsv-site-action-mark {
+ background: var(--update);
+ border-color: var(--update);
+ animation: gsvSiteBlink 0.9s step-end infinite;
+}
+
+.gsv-site-action.is-done {
+ color: var(--online);
+ border-color: color-mix(in srgb, var(--online) 34%, var(--border));
+}
+
+.gsv-site-action.is-done .gsv-site-action-mark {
+ background: var(--online);
+ border-color: var(--online);
+ box-shadow: 0 0 10px rgba(94, 242, 160, 0.45);
+}
+
+.gsv-site-action-label {
+ text-transform: uppercase;
+}
+
+@keyframes gsvSiteBlink {
+ 0%, 48% { opacity: 1; }
+ 49%, 100% { opacity: 0.25; }
+}
+
+/* ── timeline rail ─────────────────────────────────────────────────────
+ The conversation's spatial timeline, ported from the desktop app's
+ render_timeline: 4×8px pill markers in 20px slots, 5px apart, the selected
+ one in accent at full strength and the rest quieted to 0.68. */
+
+.gsv-site-rail {
+ position: fixed;
+ right: 0;
+ top: 50%;
+ transform: translateY(-50%);
+ z-index: 48;
+ width: 82px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 5px;
+ opacity: 0;
+ visibility: hidden;
+ transition: opacity 0.3s ease, visibility 0.3s;
+}
+
+.gsv-site-rail.is-shown {
+ opacity: 1;
+ visibility: visible;
+}
+
+.gsv-site-rail-slot {
+ width: 32px;
+ height: 20px;
+ flex: none;
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+ padding: 0;
+ border: 0;
+ background: none;
+ cursor: pointer;
+}
+
+.gsv-site-rail-mark {
+ width: 4px;
+ height: 8px;
+ border-radius: 999px;
+ background: var(--text-dim);
+ opacity: 0.68;
+ transition: background 0.2s ease, opacity 0.2s ease, height 0.2s ease;
+}
+
+.gsv-site-rail-slot:hover .gsv-site-rail-mark {
+ opacity: 1;
+}
+
+.gsv-site-rail-slot.is-selected .gsv-site-rail-mark {
+ background: var(--accent);
+ opacity: 1;
+ box-shadow: 0 0 10px rgba(179, 174, 255, 0.5);
+}
+
+.gsv-site-rail-slot:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 1px;
+}
+
+/* ── skip ──────────────────────────────────────────────────────────────
+ The corner-anchored launcher treatment from SetupScreen.css (.gsv-guide-launch),
+ which is the only persistent floating button precedent in the app. */
+
+.gsv-site-skip {
+ position: fixed;
+ right: 28px;
+ bottom: 28px;
+ z-index: 49;
+ display: inline-flex;
+ align-items: center;
+ gap: 9px;
+ padding: 11px 17px;
+ font-family: var(--gsv-font-mono);
+ font-size: 11px;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--accent-bright);
+ background: color-mix(in srgb, var(--panel) 100%, #ffffff 6%);
+ border: 1px solid var(--primary-hi);
+ box-shadow: 0 14px 40px rgba(5, 3, 18, 0.55);
+ cursor: pointer;
+ opacity: 0;
+ visibility: hidden;
+ transform: translateY(8px);
+ transition: background 0.14s, border-color 0.14s, color 0.14s,
+ opacity 0.28s ease, transform 0.28s ease, visibility 0.28s;
+}
+
+.gsv-site-skip.is-shown {
+ opacity: 1;
+ visibility: visible;
+ transform: translateY(0);
+}
+
+.gsv-site-skip:hover {
+ background: var(--active);
+ border-color: var(--accent);
+ color: #ffffff;
+}
+
+.gsv-site-skip:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+
+/* ── landing ───────────────────────────────────────────────────────────── */
+
+.gsv-site-landing {
+ position: relative;
+ /* The landing needs its own snap point: under mandatory snapping a region
+ with no snap position is unreachable, and the narrative would dead-end on
+ the last beat. Snapping is then switched off entirely once it is on screen
+ (.is-free) so the page below can be read normally. */
+ scroll-snap-align: start;
+ padding: clamp(72px, 16vh, 160px) var(--site-gutter) clamp(96px, 18vh, 180px);
+ border-top: 1px solid var(--border);
+ background: linear-gradient(180deg, rgba(11, 10, 30, 0.6), var(--void));
+}
+
+.gsv-site-landing-inner {
+ width: 100%;
+ max-width: var(--site-col);
+ margin: 0 auto;
+ display: flex;
+ flex-direction: column;
+ gap: clamp(48px, 9vh, 88px);
+}
+
+.gsv-site-eyebrow {
+ color: var(--text-dim);
+ text-transform: uppercase;
+ margin: 0 0 14px;
+}
+
+.gsv-site-hero-title {
+ margin: 0;
+ font-size: var(--site-hero);
+ font-weight: 500;
+ line-height: 1.05;
+ letter-spacing: -0.025em;
+ color: var(--text-hi);
+ text-shadow: 0 0 24px rgba(150, 140, 255, 0.2);
+}
+
+/* Value props — title only at rest; the description opens on hover or focus. */
+
+.gsv-site-props {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
+ gap: 1px;
+ background: var(--border);
+ border: 1px solid var(--border);
+}
+
+.gsv-site-prop {
+ background: var(--node-bg);
+ transition: background 0.2s ease;
+}
+
+/* Hover and focus open the card in CSS — see the note in LandingSection.tsx for
+ why this is not driven from state. Click pins it, for touch. */
+.gsv-site-prop:hover,
+.gsv-site-prop:focus-within,
+.gsv-site-prop.is-pinned {
+ background: color-mix(in srgb, var(--node-bg) 100%, #ffffff 4%);
+}
+
+.gsv-site-prop-toggle {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ width: 100%;
+ padding: 26px 24px;
+ border: 0;
+ background: none;
+ text-align: left;
+ cursor: pointer;
+}
+
+.gsv-site-prop-title {
+ color: var(--accent-bright);
+ text-transform: none;
+}
+
+/* A quiet plus that turns to a minus — the only hint the card opens. */
+.gsv-site-prop-cue {
+ position: relative;
+ width: 11px;
+ height: 11px;
+ flex: none;
+}
+
+.gsv-site-prop-cue::before,
+.gsv-site-prop-cue::after {
+ content: "";
+ position: absolute;
+ inset: 5px 0 auto;
+ height: 1px;
+ background: var(--text-dim);
+ transition: transform 0.22s ease, background 0.22s ease;
+}
+
+.gsv-site-prop-cue::after {
+ transform: rotate(90deg);
+}
+
+.gsv-site-prop:hover .gsv-site-prop-cue::before,
+.gsv-site-prop:hover .gsv-site-prop-cue::after,
+.gsv-site-prop:focus-within .gsv-site-prop-cue::before,
+.gsv-site-prop:focus-within .gsv-site-prop-cue::after,
+.gsv-site-prop.is-pinned .gsv-site-prop-cue::before,
+.gsv-site-prop.is-pinned .gsv-site-prop-cue::after {
+ background: var(--accent-bright);
+}
+
+.gsv-site-prop:hover .gsv-site-prop-cue::after,
+.gsv-site-prop:focus-within .gsv-site-prop-cue::after,
+.gsv-site-prop.is-pinned .gsv-site-prop-cue::after {
+ transform: rotate(0deg);
+}
+
+.gsv-site-prop-body {
+ margin: 0;
+ padding: 0 24px;
+ max-height: 0;
+ overflow: hidden;
+ opacity: 0;
+ color: var(--prose-dim);
+ transition: max-height 0.28s ease, opacity 0.28s ease, padding 0.28s ease;
+}
+
+.gsv-site-prop:hover .gsv-site-prop-body,
+.gsv-site-prop:focus-within .gsv-site-prop-body,
+.gsv-site-prop.is-pinned .gsv-site-prop-body {
+ max-height: 14rem;
+ padding: 0 24px 26px;
+ opacity: 1;
+}
+
+.gsv-site-prop-toggle:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: -2px;
+}
+
+/* ── the ask ───────────────────────────────────────────────────────────
+ The one thing the page wants, so it is given its own panel rather than
+ sitting as another quiet block. */
+
+.gsv-site-cta {
+ padding: clamp(30px, 5vw, 48px);
+ border: 1px solid var(--primary-hi);
+ background:
+ radial-gradient(120% 140% at 0% 0%, rgba(150, 140, 255, 0.12), transparent 62%),
+ var(--node-bg);
+ box-shadow: 0 24px 70px rgba(5, 3, 18, 0.5);
+}
+
+.gsv-site-cta-title {
+ margin: 0 0 22px;
+ font-size: clamp(1.7rem, 3.6vw, 2.6rem);
+ font-weight: 500;
+ line-height: 1.1;
+ letter-spacing: -0.02em;
+ color: var(--text-hi);
+ text-shadow: 0 0 20px rgba(150, 140, 255, 0.22);
+}
+
+.gsv-site-cta-form {
+ display: flex;
+ align-items: flex-end;
+ gap: 12px;
+ flex-wrap: wrap;
+ max-width: 36rem;
+}
+
+.gsv-site-cta-form > :first-child {
+ flex: 1 1 17rem;
+ min-width: 0;
+}
+
+.gsv-site-cta-done {
+ margin: 0;
+ padding: 16px 18px;
+ border: 1px solid color-mix(in srgb, var(--online) 40%, var(--border));
+ background: var(--bg-online);
+ color: var(--online);
+}
+
+.gsv-site-cta-note {
+ margin: 14px 0 0;
+ color: var(--text-muted);
+ text-transform: uppercase;
+}
+
+.gsv-site-demo-title {
+ margin: 0 0 18px;
+ color: var(--text-hi);
+ text-transform: none;
+}
+
+.gsv-site-demo-video {
+ display: block;
+ width: 100%;
+ /* The width/height attributes are kept on the element as an intrinsic-ratio
+ hint (so the box is reserved before the poster arrives), which means the
+ used height must be released here or the attribute wins and the video
+ renders 720px tall regardless of its width. */
+ height: auto;
+ aspect-ratio: 16 / 9;
+ border: 1px solid var(--border);
+ background: #05050d;
+ object-fit: cover;
+}
+
+/* ── shared motion ─────────────────────────────────────────────────────── */
+
+@keyframes gsvSiteIn {
+ from { opacity: 0; transform: translateY(12px) scale(0.98); }
+ to { opacity: 1; transform: translateY(0) scale(1); }
+}
+
+@keyframes gsvSiteDrop {
+ from { opacity: 0; transform: translateY(-10px) scale(0.98); }
+ to { opacity: 1; transform: translateY(0) scale(1); }
+}
+
+/* ── narrow viewports ──────────────────────────────────────────────────── */
+
+/* On desktop the centred column stops well short of the rail. On narrow
+ viewports it does not, so the rail narrows and the content reserves room for
+ it — otherwise the markers sit on top of the copy and the media window. */
+@media (max-width: 900px) {
+ .gsv-site-rail {
+ width: 44px;
+ }
+
+ .gsv-site-rail-slot {
+ width: 20px;
+ justify-content: center;
+ }
+
+ .gsv-site-beat,
+ .gsv-site-landing {
+ padding-right: calc(var(--site-gutter) + 34px);
+ }
+}
+
+@media (max-width: 640px) {
+ .gsv-site-skip {
+ right: 16px;
+ bottom: 16px;
+ }
+
+ .gsv-site-cta-form {
+ align-items: stretch;
+ }
+}
+
+/* ── reduced motion ────────────────────────────────────────────────────
+ Null the motion rather than shortening it, per the house convention. Every
+ element must still land in its final, readable state. */
+
+@media (prefers-reduced-motion: reduce) {
+ .gsv-site {
+ scroll-snap-type: none;
+ }
+
+ .gsv-site-caret,
+ .gsv-site-action.is-running .gsv-site-action-mark {
+ animation: none;
+ }
+
+ .gsv-site-win,
+ .gsv-site-perm,
+ .gsv-site-perm-result,
+ .gsv-site-boxes.is-live .gsv-site-box {
+ animation: none;
+ }
+
+ .gsv-site-box {
+ opacity: 1;
+ transform: none;
+ }
+
+ .gsv-site-perm.is-done {
+ display: none;
+ }
+
+ .gsv-site-skip,
+ .gsv-site-rail,
+ .gsv-site-prop-body,
+ .gsv-site-prop-cue::before,
+ .gsv-site-prop-cue::after {
+ transition: none;
+ }
+}