From a8d752d217684e03c662a409d0d2d30078d4a8ec Mon Sep 17 00:00:00 2001 From: jessicat <8797119+jess-cat@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:59:30 +0200 Subject: [PATCH 1/2] add scroll-driven public website mockup at /welcome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A ten-beat narrative that opens inside the desktop app's message surface and resolves into a landing page. The route branches in App.tsx before AppProviders, so it mounts with no gateway, session, or query client — the same escape hatch /design already uses. Reuses the app's own construction rather than restating it: the chat dock's message shape and field colour, the scanline/vignette pair from AsciiGalaxyScan, the corner-launcher treatment from SetupScreen for the skip control, and the desktop app's conversation timeline (4x8px pill markers, ported from host/apps/desktop render_timeline) as the progress rail. Type scale is the only deliberate departure from app sizes, scoped to .gsv-site. The camera/microphone prompt is a prop that answers itself; nothing calls getUserMedia. The sign-up form is a visual mock that sends nothing. Tau's photograph is not in the repo yet, so the window falls back to a labelled placeholder at /img/tau.jpg. --- web/src/app/App.tsx | 7 + .../app/features/website/ActionSequence.tsx | 51 ++ .../app/features/website/CapabilityBoxes.tsx | 27 + .../features/website/FakePermissionDialog.tsx | 95 ++ .../app/features/website/LandingSection.tsx | 133 +++ web/src/app/features/website/MediaWindow.tsx | 29 + .../app/features/website/NarrativeBeat.tsx | 179 ++++ web/src/app/features/website/TimelineRail.tsx | 43 + web/src/app/features/website/WelcomeSite.tsx | 160 ++++ web/src/app/features/website/beats.ts | 118 +++ web/src/app/features/website/website.css | 848 ++++++++++++++++++ 11 files changed, 1690 insertions(+) create mode 100644 web/src/app/features/website/ActionSequence.tsx create mode 100644 web/src/app/features/website/CapabilityBoxes.tsx create mode 100644 web/src/app/features/website/FakePermissionDialog.tsx create mode 100644 web/src/app/features/website/LandingSection.tsx create mode 100644 web/src/app/features/website/MediaWindow.tsx create mode 100644 web/src/app/features/website/NarrativeBeat.tsx create mode 100644 web/src/app/features/website/TimelineRail.tsx create mode 100644 web/src/app/features/website/WelcomeSite.tsx create mode 100644 web/src/app/features/website/beats.ts create mode 100644 web/src/app/features/website/website.css 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 ( + + ); +} 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 ( + + ); +} 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. +

    + ) : ( +
    + { + setEmail(value); + setTouched(true); + }} + /> +
    + +
    +

    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..170e759c4 --- /dev/null +++ b/web/src/app/features/website/MediaWindow.tsx @@ -0,0 +1,29 @@ +import type { ComponentChildren } from "preact"; + +export interface MediaWindowProps { + /** Filename or subject, shown in the title bar. */ + title?: string; + 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, 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..e71a973da --- /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, "")} + +
    +
    + ); + + return ( +
    +
    +
    + {message} + + {beat.kind === "permission" ? ( + setAnswered(true)} /> + ) : null} + {beat.kind === "permission" && answered ? ( + + ) : null} + + {beat.kind === "boxes" ? : null} + {beat.kind === "actions" ? : null} +
    + + {split ? ( +
    + {beat.kind === "galaxy" ? ( + + {/* The grid is cut well below the component's defaults so it + doesn't compete with the scroll. */} + + + ) : null} + + {beat.kind === "photo" ? ( + + {missingPhoto ? ( +
    + Photo not found + {beat.src} +
    + ) : ( + {beat.alt setMissingPhoto(true)} + /> + )} +
    + ) : null} +
    + ) : null} +
    +
    + ); +} 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. */} +