Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added web/public/img/tau.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 7 additions & 0 deletions web/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -14,6 +16,11 @@ export function App() {
if (DESIGN_SYSTEM_PATHS.has(pathname)) {
return <Catalog />;
}
// 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 <WelcomeSite />;
}

return (
<AppProviders>
Expand Down
51 changes: 51 additions & 0 deletions web/src/app/features/website/ActionSequence.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ul class="gsv-site-actions" aria-hidden="true">
{ACTION_STEPS.map((step, i) => {
const complete = i < done;
const running = i === done && active && !reduced;
return (
<li
class={`gsv-site-action${complete ? " is-done" : ""}${running ? " is-running" : ""}`}
key={step.label}
>
<span class="gsv-site-action-mark" aria-hidden="true" />
<span class="gsv-site-action-label gsv-sublabel">
{complete ? step.done : step.label}
</span>
</li>
);
})}
</ul>
);
}
27 changes: 27 additions & 0 deletions web/src/app/features/website/CapabilityBoxes.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div class={`gsv-site-boxes${active ? " is-live" : ""}`} aria-hidden="true">
{FIND_LABELS.map((label, i) => (
<span
class="gsv-site-box gsv-sublabel"
key={label}
style={{ "--i": String(i) } as Record<string, string>}
>
{label}
</span>
))}
</div>
);
}
95 changes: 95 additions & 0 deletions web/src/app/features/website/FakePermissionDialog.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof setTimeout>[] = [];

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 (
<div
class={`gsv-site-perm${refused ? " is-refused" : ""}${phase === "done" ? " is-done" : ""}`}
// Not a real dialog: it takes no input and traps no focus. Announcing it
// as one would promise an interaction that does not exist.
aria-hidden="true"
>
<div class="gsv-site-perm-head">
<span class="gsv-site-perm-origin gsv-sublabel">gsv.space wants to</span>
</div>
<ul class="gsv-site-perm-list">
<li class="gsv-site-perm-item gsv-prose">
<span class="gsv-site-perm-dot" aria-hidden="true" />
Use your camera
</li>
<li class="gsv-site-perm-item gsv-prose">
<span class="gsv-site-perm-dot" aria-hidden="true" />
Use your microphone
</li>
</ul>
<div class="gsv-site-perm-actions">
<span class={`gsv-site-perm-btn is-deny${refused ? " is-picked" : ""}`}>
Don’t allow
</span>
<span class="gsv-site-perm-btn is-allow">Allow</span>
</div>
</div>
);
}
133 changes: 133 additions & 0 deletions web/src/app/features/website/LandingSection.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<li class={`gsv-site-prop${pinned ? " is-pinned" : ""}`}>
<button
type="button"
class="gsv-site-prop-toggle"
aria-expanded={pinned}
onClick={() => setPinned((v) => !v)}
>
<span class="gsv-site-prop-title gsv-section">{title}</span>
<span class="gsv-site-prop-cue" aria-hidden="true" />
</button>
<p class="gsv-site-prop-body gsv-prose">{body}</p>
</li>
);
}

/** 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 (
<section class="gsv-site-landing" id="gsv-site-landing" aria-label="About GSV">
<div class="gsv-site-landing-inner">
<header class="gsv-site-hero">
<p class="gsv-site-eyebrow gsv-sublabel">general systems vehicle</p>
<h1 class="gsv-site-hero-title">a mind for your machines</h1>
</header>

<ul class="gsv-site-props">
{VALUE_PROPS.map((prop) => (
<ValueProp key={prop.title} title={prop.title} body={prop.body} />
))}
</ul>

<div class="gsv-site-cta">
<h2 class="gsv-site-cta-title">get there first</h2>
{joined ? (
<p class="gsv-site-cta-done gsv-prose" role="status">
You’re on the list. We’ll be in touch when there’s a seat.
</p>
) : (
<form class="gsv-site-cta-form" onSubmit={submit} noValidate>
<TextInput
label="Email"
type="text"
size="large"
requirement="required"
placeholder="your@email.com"
value={email}
status={invalid ? "error" : "none"}
message={invalid ? "Enter a valid email address." : ""}
inputProps={{ autoComplete: "email", inputMode: "email" }}
onChange={(value) => {
setEmail(value);
setTouched(true);
}}
/>
<Button variant="primary" label="Early trial access" type="submit" />
</form>
)}
<p class="gsv-site-cta-note gsv-sublabel">Mock form · nothing is sent</p>
</div>

<div class="gsv-site-demo">
<h2 class="gsv-site-demo-title gsv-section">see it in action</h2>
<video
class="gsv-site-demo-video"
src={DEMO_VIDEO}
poster={DEMO_POSTER}
controls
preload="none"
playsInline
width={1280}
height={720}
/>
</div>
</div>
</section>
);
}
32 changes: 32 additions & 0 deletions web/src/app/features/website/MediaWindow.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<figure class={`gsv-site-win${portrait ? " is-portrait" : ""}`}>
<div class="gsv-site-win-bar">
<span class="gsv-site-win-dots" aria-hidden="true">
<i />
<i />
<i />
</span>
{title ? (
<figcaption class="gsv-site-win-title gsv-sublabel">{title}</figcaption>
) : null}
</div>
<div class="gsv-site-win-body">{children}</div>
</figure>
);
}
Loading
Loading