diff --git a/dashboard/CONTEXT.md b/dashboard/CONTEXT.md index b35f01bb34..72c9d876c8 100644 --- a/dashboard/CONTEXT.md +++ b/dashboard/CONTEXT.md @@ -234,7 +234,7 @@ _Avoid_: Pull, sync, rebase **PR Environment**: A Branch made automatically for one pull request from a Git branch of the same repository. Its Own Copies of the repository's Services run the pull request's code with one replica each, and it closes when the pull request closes. Its changes reach its Destination only through a Conditional Save. -_Avoid_: Preview, preview deployment, review app +_Avoid_: Preview, preview deployment, review app. Exception: the public site calls it a preview environment, the phrase its readers use. **Conditional Save**: A PR Environment's changes saved for one Destination, which go live with the pull request's merge commit: they are saved there in the same step that admits the deployment of that commit, so code and settings go out together. A push whose commit doesn't contain the merge commit lands nothing; the changes wait for one that does. A setting the Destination left alone, or only edited without deploying, is saved; one it changed live becomes an ordinary change to deploy there instead; one it did both to keeps its own edit, and the pull request's value is only offered beside it. Changing the PR Environment's settings or the pull request's target Git branch withdraws it, and a pull request closed without merging drops it. diff --git a/dashboard/src/components/icons/ployz-logo.tsx b/dashboard/src/components/icons/ployz-logo.tsx index 94e4e23e01..426f0212a2 100644 --- a/dashboard/src/components/icons/ployz-logo.tsx +++ b/dashboard/src/components/icons/ployz-logo.tsx @@ -25,14 +25,3 @@ export function PloyzMark({ ) } - -export function PloyzLogo({ className }: { className?: string }) { - return ( - - - - ployz - - - ) -} diff --git a/dashboard/src/components/marketing/kit.tsx b/dashboard/src/components/marketing/kit.tsx new file mode 100644 index 0000000000..bafc56a89d --- /dev/null +++ b/dashboard/src/components/marketing/kit.tsx @@ -0,0 +1,300 @@ +import { useEffect, useRef, useState } from "react"; +import type { ReactNode, RefObject } from "react"; +import { Position, getBezierPath, getSmoothStepPath } from "@xyflow/react"; +import { LockIcon } from "lucide-react"; +import { Avatar, AvatarFallback } from "#/components/ui/avatar"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "#/components/ui/card"; +import { cn } from "#/lib/utils"; + +// The lander's building blocks: pictures of the dashboard and of the servers under it. Canvas cards +// are the product's Card laid out like ServiceNode, in the product's status colours; wires take the +// canvas's own smooth-step edge shape. + +/** A status dot in the product's colours (getServiceStatusClasses): quiet once deployed, pink while staged. */ +export type Tone = "quiet" | "changed" | "success"; + +const DOT = { + quiet: ["bg-muted", "bg-muted-foreground"], + changed: ["bg-changed-soft", "bg-changed"], + success: ["bg-success-soft", "bg-success"], +} as const satisfies Record; + +export function StatusDot({ tone = "quiet" }: { tone?: Tone }) { + const [outer, inner] = DOT[tone]; + return ( + + + + ); +} + +export function ServiceCard({ + id, + icon, + name, + sub, + status, + tone = "quiet", + state, + className, +}: { + id?: string; + icon: ReactNode; + name: string; + sub: string; + status: string; + tone?: Tone; + /** Card's own staged state, while a change waits for Deploy. */ + state?: "changed"; + className?: string; +}) { + return ( + + +
+ + {icon} + +
+ {name} + {sub} +
+
+
+ +
+ + {status} +
+
+
+ ); +} + +// ---- wires ------------------------------------------------------------------------------------------ + +type Box = { x: number; y: number; w: number; h: number }; + +/** + * A line between two [data-node] elements. By default it runs like a canvas edge: up from the top of + * `from` (the service that is used) to the bottom of `to` (its user), arrow at the user, with packets + * travelling down it the way requests do. `across` runs it sideways, right edge to left edge, with + * packets travelling from `from` to `to`. + */ +export type Wire = { + from: string; + to: string; + across?: boolean; + packets: number; + seconds: number; + arrow?: boolean; + /** Classes for this wire alone, such as hiding it where the layout stacks. */ + className?: string; +}; + +function wirePath(wire: Wire, a: Box, b: Box) { + const [d] = wire.across + ? getBezierPath({ sourceX: a.x + a.w, sourceY: a.y + a.h / 2, sourcePosition: Position.Right, targetX: b.x, targetY: b.y + b.h / 2, targetPosition: Position.Left }) + : getSmoothStepPath({ sourceX: a.x + a.w / 2, sourceY: a.y, sourcePosition: Position.Top, targetX: b.x + b.w / 2, targetY: b.y + b.h + 5, targetPosition: Position.Bottom, borderRadius: 12 }); + return d; +} + +/** + * Draws `wires` between the cards of its parent, which must be `relative isolate`: over the parent's + * backgrounds, under its [data-node] cards (landing.css lifts those), so packets vanish into what they + * reach. A card hidden at this breakpoint drops its wires. + */ +export function Wires({ wires }: { wires: readonly Wire[] }) { + const svg = useRef(null); + const [paths, setPaths] = useState([]); + + useEffect(() => { + const scope = svg.current?.parentElement; + if (!scope) return; + const measure = () => { + const frame = scope.getBoundingClientRect(); + const box = (id: string): Box | null => { + const el = scope.querySelector(`[data-node="${id}"]`); + if (!el || el.offsetParent === null) return null; + const r = el.getBoundingClientRect(); + return { x: r.left - frame.left, y: r.top - frame.top, w: r.width, h: r.height }; + }; + setPaths( + wires.map((wire) => { + const a = box(wire.from); + const b = box(wire.to); + return a && b ? wirePath(wire, a, b) : ""; + }), + ); + }; + measure(); + const observer = new ResizeObserver(measure); + observer.observe(scope); + scope.querySelectorAll("[data-node]").forEach((el) => observer.observe(el)); + return () => observer.disconnect(); + }, [wires]); + + return ( + + {wires.map((wire, i) => { + const d = paths[i]; + if (!d) return null; + const { seconds, packets } = wire; + return ( + + + {Array.from({ length: packets }, (_, n) => ( + + ))} + + ); + })} + + ); +} + +function Packet({ path, seconds, delay, reverse }: { path: string; seconds: number; delay: number; reverse: boolean }) { + const timing = { dur: `${seconds}s`, begin: `-${delay.toFixed(2)}s`, repeatCount: "indefinite" }; + return ( + + + + + ); +} + +/** The arrowhead every wire on the page points with; render once. */ +export function WireDefs() { + return ( + + + + + + + + ); +} + +// ---- time --------------------------------------------------------------------------------------------- + +/** + * Whether a scene has scrolled into view: undefined while server-rendered (so nothing is hidden without + * JavaScript), false until it first shows, then true for good. Scenes pass it to `data-inview`, which + * landing.css keys its one-shot reveals to. + */ +export function useInView(): [RefObject, boolean | undefined] { + const ref = useRef(null); + const [seen, setSeen] = useState(); + useEffect(() => { + const el = ref.current; + if (!el) return; + setSeen(false); + const observer = new IntersectionObserver( + ([entry]) => { + if (entry?.isIntersecting) { + setSeen(true); + observer.disconnect(); + } + }, + { threshold: 0.35 }, + ); + observer.observe(el); + return () => observer.disconnect(); + }, []); + return [ref, seen]; +} + +/** + * Plays `frames` in a loop while `active`, each for its `ms`. Under reduced motion, or before it + * starts, it holds `rest`: the frame that tells the story best on its own. + */ +export function useLoop(frames: readonly [F, ...F[]], active: boolean, rest: F): F { + const [index, setIndex] = useState(); + useEffect(() => { + if (!active || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; + let current = 0; + setIndex(0); + let timer = window.setTimeout(function next() { + current = (current + 1) % frames.length; + setIndex(current); + timer = window.setTimeout(next, frames[current]?.ms); + }, frames[0].ms); + return () => window.clearTimeout(timer); + }, [active, frames]); + return (index === undefined ? undefined : frames[index]) ?? rest; +} + +// ---- props -------------------------------------------------------------------------------------------- + +export function ServerFace({ name, spec, ip }: { name: string; spec: string; ip: string }) { + return ( +
+
+
+ {name} + {spec} +
+ {ip} +
+
+
+ + + +
+
+ {Array.from({ length: 8 }, (_, i) => ( + + ))} +
+
+
+ ); +} + +export function Terminal({ title, light, className, children }: { title: string; light?: boolean; className?: string; children: ReactNode }) { + return ( +
+
+ + + + {title} +
+
{children}
+
+ ); +} + +/** When the `order`th thing in a scene appears, once the scene scrolls into view: lines, then whatever follows them. */ +export function revealDelay(order: number) { + return `${(0.2 + order * 0.45).toFixed(2)}s`; +} + +/** One terminal line. Inside a scene that uses useInView, lines appear in `order` once it shows. */ +export function Line({ order = 0, prompt, children, className }: { order?: number; prompt?: string; children?: ReactNode; className?: string }) { + return ( +

+ {prompt ? {prompt} : null} + {children} +

+ ); +} + +export function BrowserWindow({ url, children }: { url: string; children: ReactNode }) { + return ( +
+
+ + + + + + {url} + +
+ {children} +
+ ); +} diff --git a/dashboard/src/components/marketing/landing.css b/dashboard/src/components/marketing/landing.css new file mode 100644 index 0000000000..97edcfafc8 --- /dev/null +++ b/dashboard/src/components/marketing/landing.css @@ -0,0 +1,386 @@ +/* The public lander's scenes (routes/_public/index.tsx). Plain CSS over the product tokens in + styles.css; loaded by the lander's route only. Everything sits in Tailwind's components layer, so + utility classes on the same element still win. styles.css already stops CSS animation under reduced + motion; the block at the end holds only what that rule can't. */ + +@layer components { + /* Colours only the lander uses: server hardware and its lights, the canvas dots, and wires. */ + :root { + --lp-graphite: #1b1b1b; + --lp-graphite-ink: #ededed; + --lp-graphite-muted: #9a9a9a; + --lp-screw: #2b2b2b; + --lp-screw-edge: #454545; + --lp-led: #4fd08a; + --lp-dot: #c4c4c4; + --lp-wire: #3a3a3a; + } + + /* ---- the canvas floor: the product's dot grid ---------------------------------------------------- */ + .lp-dots { + background-color: var(--secondary); + background-image: radial-gradient(circle, var(--lp-dot) 1.1px, transparent 1.3px); + background-size: 20px 20px; + } + + /* ---- wires between canvas cards, and what travels them ----------------------------------------- */ + /* A wire's endpoints sit above it, so packets disappear into the card they reach. */ + [data-node] { + position: relative; + z-index: 1; + } + .lp-wire { + fill: none; + stroke: var(--lp-wire); + stroke-width: 1.5; + } + .lp-arrow { + fill: var(--lp-wire); + } + .lp-packet { + fill: var(--success); + stroke: var(--background); + stroke-width: 2.5; + } + + /* ---- a server: a machine, not a box --------------------------------------------------------------- */ + .lp-face { + position: relative; + display: flex; + align-items: center; + gap: 0.75rem 1.25rem; + min-height: 3.75rem; + padding: 0.75rem 2.25rem; + border-radius: 0.75rem; + background: var(--lp-graphite); + color: var(--lp-graphite-ink); + font-family: var(--font-mono); + font-size: 0.8125rem; + line-height: 1.2; + } + /* rack screws */ + .lp-face::before, + .lp-face::after { + content: ""; + position: absolute; + top: 50%; + width: 0.5rem; + height: 0.5rem; + margin-top: -0.25rem; + border-radius: 9999px; + background: var(--lp-screw); + border: 1px solid var(--lp-screw-edge); + } + .lp-face::before { + left: 0.875rem; + } + .lp-face::after { + right: 0.875rem; + } + .lp-face b { + font-size: 0.9375rem; + } + .lp-face b, + .lp-face .lp-muted { + white-space: nowrap; + } + /* a phone keeps the name and the spec; the activity lights go */ + @media (width < 40rem) { + .lp-face .lp-act { + display: none; + } + } + .lp-face .lp-muted { + color: var(--lp-graphite-muted); + } + .lp-leds, + .lp-act { + display: flex; + gap: 0.3125rem; + } + .lp-leds i { + width: 0.5rem; + height: 0.5rem; + border-radius: 9999px; + background: var(--lp-led); + } + .lp-leds i:nth-child(2) { + animation: lp-blink 0.7s steps(2, jump-none) infinite; + } + .lp-leds i:nth-child(3) { + animation: lp-blink 1.9s steps(2, jump-none) infinite; + } + .lp-act i { + width: 0.3125rem; + height: 0.75rem; + border-radius: 2px; + background: var(--lp-led); + opacity: 0.15; + animation: lp-flicker 1.3s steps(1, end) infinite; + } + .lp-act i:nth-child(3n) { + animation-duration: 0.9s; + } + .lp-act i:nth-child(3n + 1) { + animation-duration: 1.7s; + } + /* the stand between the dashboard and the server it runs on */ + .lp-leg { + width: 0.5rem; + height: 1.25rem; + background: var(--lp-graphite); + } + + /* ---- scrolling rows: requests answered, and the stack ----------------------------------------------- */ + .lp-ticker { + display: flex; + align-items: center; + gap: 0.625rem; + overflow: hidden; + padding: 0.3125rem 0.3125rem 0.3125rem 0.75rem; + border: 1px solid var(--border); + border-radius: 9999px; + background: var(--background); + font-family: var(--font-mono); + font-size: 0.75rem; + white-space: nowrap; + } + /* width 0: the scrolling row is wider than any layout, and must not count toward it */ + .lp-ticker-window { + flex: 1; + width: 0; + min-width: 5rem; + overflow: hidden; + mask-image: linear-gradient(90deg, transparent, #000 1rem, #000 calc(100% - 1rem), transparent); + } + .lp-ticker-row { + display: flex; + gap: 0.375rem; + width: max-content; + animation: lp-marquee 9s linear infinite; + } + .lp-ticker-row i { + padding: 0.125rem 0.5rem; + border-radius: 9999px; + background: var(--success-soft); + color: var(--success); + font-style: normal; + font-weight: 600; + } + /* ---- "Deploy" and the stack scrolling past it ------------------------------------------------------- */ + .lp-deploy { + --lp-word: 1.15em; + display: flex; + align-items: center; + gap: 0.35em; + font-size: clamp(2.5rem, 7vw, 5.5rem); + font-weight: 600; + letter-spacing: -0.04em; + line-height: var(--lp-word); + } + .lp-deploy-label { + padding: 0 0.2em; + border-radius: 0.12em; + background: var(--changed); + color: var(--changed-foreground); + } + /* three lines tall: the current word in the middle, its neighbours fading above and below */ + .lp-deploy-window { + height: calc(var(--lp-word) * 3); + overflow: hidden; + mask-image: linear-gradient(transparent, #000 38%, #000 62%, transparent); + } + .lp-deploy-words { + display: flex; + flex-direction: column; + padding-top: var(--lp-word); + white-space: nowrap; + animation: lp-words 16s cubic-bezier(0.7, 0, 0.2, 1) infinite; + } + + /* ---- terminals, in theme tokens: a server's root prompt adds `.dark` (Terminal), your laptop's doesn't -- */ + .lp-term { + overflow: hidden; + border: 1px solid var(--border); + border-radius: 0.875rem; + background: var(--background); + color: var(--foreground); + font-family: var(--font-mono); + font-size: 0.8125rem; + line-height: 1.7; + /* commands must read as typed: no "--" ligatures */ + font-variant-ligatures: none; + } + .lp-term-bar { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.625rem 0.875rem; + border-bottom: 1px solid var(--border); + background: var(--secondary); + color: var(--muted-foreground); + font-size: 0.75rem; + } + .lp-term-bar i { + width: 0.625rem; + height: 0.625rem; + border-radius: 9999px; + background: var(--border); + } + .lp-term-bar span { + margin-left: 0.5rem; + } + /* Long lines wrap, as a terminal does, so nothing hides past a phone's edge. */ + .lp-term-body { + padding: 0.875rem 1rem 1rem; + white-space: pre-wrap; + overflow-wrap: anywhere; + } + .lp-prompt, + .lp-dim { + color: var(--muted-foreground); + } + .lp-ok { + color: var(--success); + } + .lp-caret { + display: inline-block; + width: 0.5rem; + height: 1em; + vertical-align: -0.15em; + background: currentColor; + animation: lp-blink 1.1s steps(2, jump-none) infinite; + } + + /* Lines type in once their scene scrolls into view (useInView); each line's delay is inline (Line). */ + [data-inview="false"] .lp-line { + visibility: hidden; + } + [data-inview="true"] .lp-line { + animation: lp-appear 0.01s both; + } + /* Something that arrives after a scene's lines. Its delay is set inline (revealDelay) only once the scene + is in view, so hiding it after hydration is immediate. */ + [data-inview="false"] .lp-late { + opacity: 0; + translate: 0 0.5rem; + } + [data-inview="true"] .lp-late { + transition: + opacity 0.6s ease, + translate 0.6s ease; + } + + /* ---- a browser window with the customer's site in it (their brand, not ours) ------------------------ */ + .lp-browser { + overflow: hidden; + border: 1px solid var(--border); + border-radius: 0.875rem; + background: var(--background); + } + .lp-browser-bar { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--border); + background: var(--muted); + } + .lp-browser-bar > i { + width: 0.625rem; + height: 0.625rem; + border-radius: 9999px; + background: var(--border); + } + .lp-browser-url { + display: flex; + flex: 1; + align-items: center; + gap: 0.375rem; + min-width: 0; + margin-left: 0.5rem; + padding: 0.25rem 0.625rem; + border-radius: 0.5rem; + background: var(--background); + font-family: var(--font-mono); + font-size: 0.75rem; + } + + /* ---- motion ------------------------------------------------------------------------------------ */ + @keyframes lp-blink { + 50% { + opacity: 0.25; + } + } + @keyframes lp-flicker { + 0%, + 60% { + opacity: 1; + } + 61%, + 100% { + opacity: 0.15; + } + } + @keyframes lp-marquee { + to { + transform: translateX(-50%); + } + } + /* eight words (DEPLOY_WORDS), two seconds each; the ninth line repeats the first */ + @keyframes lp-words { + 0%, + 10% { + transform: translateY(0); + } + 12.5%, + 22.5% { + transform: translateY(calc(var(--lp-word) * -1)); + } + 25%, + 35% { + transform: translateY(calc(var(--lp-word) * -2)); + } + 37.5%, + 47.5% { + transform: translateY(calc(var(--lp-word) * -3)); + } + 50%, + 60% { + transform: translateY(calc(var(--lp-word) * -4)); + } + 62.5%, + 72.5% { + transform: translateY(calc(var(--lp-word) * -5)); + } + 75%, + 85% { + transform: translateY(calc(var(--lp-word) * -6)); + } + 87.5%, + 97.5% { + transform: translateY(calc(var(--lp-word) * -7)); + } + 100% { + transform: translateY(calc(var(--lp-word) * -8)); + } + } + @keyframes lp-appear { + from { + visibility: hidden; + } + to { + visibility: visible; + } + } + + /* SVG packets aren't CSS animation, and the activity lights would otherwise end dim. */ + @media (prefers-reduced-motion: reduce) { + .lp-packet { + display: none; + } + .lp-act i { + opacity: 0.8; + } + } +} diff --git a/dashboard/src/components/marketing/scenes.tsx b/dashboard/src/components/marketing/scenes.tsx new file mode 100644 index 0000000000..92b5f3dda2 --- /dev/null +++ b/dashboard/src/components/marketing/scenes.tsx @@ -0,0 +1,377 @@ +import { + ChevronsUpDownIcon, + CircleCheckIcon, + GitPullRequestIcon, + HistoryIcon, + LayoutGridIcon, + PackageIcon, + PlusIcon, + SearchIcon, + ServerIcon, + SlidersHorizontalIcon, + TerminalIcon, + WorkflowIcon, +} from "lucide-react"; +import { DeploymentStatusIcon } from "#/components/deployment-status-icon"; +import { GitHubMarkIcon } from "#/components/icons/github-mark"; +import { Badge } from "#/components/ui/badge"; +import { buttonVariants } from "#/components/ui/button-variants"; +import { Kbd } from "#/components/ui/kbd"; +import { cn } from "#/lib/utils"; +import { BrowserWindow, Line, ServerFace, ServiceCard, StatusDot, Terminal, Wires, revealDelay, useInView, useLoop } from "#/components/marketing/kit"; +import type { Tone, Wire } from "#/components/marketing/kit"; + +// The lander's pictures. Each is the dashboard, or the server under it, doing one thing in the +// product's own words and colours. Scenes are decoration: inert inside, and named for screen readers. +// Addresses sit under up.ployz.app, where Hosted DNS grants Cluster Domains. + +const gitIcon = ; +const imageIcon = ; + +// ---- the hero: the dashboard running an app, and the server it runs on -------------------------------- + +const HERO_WIRES: readonly Wire[] = [ + { from: "web", to: "ticker", arrow: false, packets: 3, seconds: 1.6 }, + { from: "api", to: "web", packets: 3, seconds: 1.8 }, + { from: "postgres", to: "api", packets: 3, seconds: 1.8 }, + { from: "redis", to: "api", packets: 2, seconds: 2.4 }, + { from: "redis", to: "worker", packets: 2, seconds: 2.8 }, +]; + +export function HeroScene() { + return ( +
+
+
+ +
+ +
+ + +
+ + + + +
+ +
+
+
+
+
+ + +
+ +
+ ); +} + +// The rail's places (dashboard-navigation-model): an Environment's four, then the organization's two. +const RAIL_PLACES = [WorkflowIcon, HistoryIcon, TerminalIcon, SlidersHorizontalIcon]; +const RAIL_ORG = [LayoutGridIcon, ServerIcon]; + +function Rail() { + const place = (Icon: typeof ServerIcon, key: number, current = false) => ( + + + + ); + return ( +
+ {RAIL_PLACES.map((Icon, i) => place(Icon, i, i === 0))} + + {RAIL_ORG.map((Icon, i) => place(Icon, i))} +
+ ); +} + +function TopBar() { + return ( +
+ + acme + + + / + + production + + + + + + Create + +
+ ); +} + +/** Requests answered, scrolling by: proof the app is serving, without claiming any metrics. */ +function Ticker({ id, host }: { id: string; host: string }) { + return ( +
+ + GET {host} +
+
+ {Array.from({ length: 24 }, (_, i) => ( + 200 + ))} +
+
+
+ ); +} + +// ---- one command: a bare root prompt, handed to Ployz (#1225's `ployz up`) ----------------------------- + +const START_WIRES: readonly Wire[] = [ + { from: "bare", to: "up", across: true, packets: 2, seconds: 2, className: "max-lg:hidden" }, + { from: "up", to: "live", across: true, packets: 2, seconds: 2, className: "max-lg:hidden" }, +]; + +export function GetStartedScene() { + const [ref, inview] = useInView(); + return ( +
+
+ + + + + +
+
+ + + ployz login + + + ployz up --server root@203.0.113.12 + + + Installing Ployz on 203.0.113.12 + + + Building web + + + ✓ https://web.acme.up.ployz.app + + +
+
+ + + +
+ +
+ ); +} + +// ---- what it runs: Railpack 0.39.0's providers (core/crates/ployz-build), plus any image ------------------ + +// landing.css's lp-words keyframes step through exactly eight words: change both together. +const DEPLOY_WORDS = ["Next.js", "Rails", "Laravel", "Django", "Go", "Bun", "Postgres", "anything"]; + +/** "Deploy" and the stack scrolling past it. The first word repeats at the end, so the loop is seamless. */ +export function DeployTicker() { + return ( +
+ Deploy + + + {[...DEPLOY_WORDS, DEPLOY_WORDS[0]].map((word, i) => ( + {word} + ))} + + +
+ ); +} + +// ---- a preview environment for every pull request ------------------------------------------------------ + +const PREVIEW_WIRES: readonly Wire[] = [{ from: "preview", to: "pull-request", arrow: false, packets: 3, seconds: 1.8 }]; + +export function PreviewScene() { + return ( +
+ +
+ + + +
+ +
+ ); +} + +function PullRequestCard({ id }: { id: string }) { + return ( +
+
+ + Add pricing page + #142 +
+

feature/pricing → main

+
+ + pr-142 deployed + web-pr-142.acme.up.ployz.app +
+
+ ); +} + +const PLANS = [ + { name: "Starter", price: "$0" }, + { name: "Pro", price: "$12" }, + { name: "Team", price: "$39" }, +]; + +/** The customer's own site: their brand, not ours, so neutral. `pricing` adds the pull request's new section. */ +function SiteMock({ pricing }: { pricing?: boolean }) { + return ( +
+
+ acme + Product + Pricing + Log in +
+
+

Invoices that chase themselves.

+

Send, remind and get paid, without the follow-up emails.

+ Start free trial +
+ {pricing ? ( +
+ + New + +
+ {PLANS.map(({ name, price }) => ( +
+ {name} + + {price} + /mo + +
+ ))} +
+
+ ) : null} +
+ ); +} + +// ---- review: an edit waits in pink until Deploy ------------------------------------------------------- + +// One service's card through a deploy, as ServiceNode shows it (getServiceDeploymentSemantics). +type CardLook = { tone: Tone; state?: "changed"; status: string }; +type ReviewFrame = { ms: number; card: CardLook; bar: "staged" | "deploying" | null }; +// Staged is where it rests: the pink trail, card to Deploy, in one still. +const REVIEW_STAGED: ReviewFrame = { ms: 3000, card: { tone: "changed", state: "changed", status: "1 change" }, bar: "staged" }; +const REVIEW_FRAMES: readonly [ReviewFrame, ...ReviewFrame[]] = [ + { ms: 2200, card: { tone: "quiet", status: "Deployed · 1 container observed" }, bar: null }, + REVIEW_STAGED, + { ms: 1800, card: { tone: "quiet", status: "Deploying…" }, bar: "deploying" }, + { ms: 3000, card: { tone: "quiet", status: "Deployed · 3 containers observed" }, bar: null }, +]; + +export function ReviewScene() { + const [ref, inview] = useInView(); + const frame = useLoop(REVIEW_FRAMES, inview === true, REVIEW_STAGED); + return ( +
+ + +
+ ); +} + +/** The canvas's bottom bar, drawn with its own classes (styles.css): changes to deploy, then the running attempt. */ +function BottomBarPicture({ bar }: { bar: ReviewFrame["bar"] }) { + if (bar === "staged") { + return ( +
+
+
+

Apply 1 change

+
+ Details + Deploy +
+
+ ); + } + if (bar === "deploying") { + return ( +
+
+ + + +
+

Deploying · Scale web to 3

+

web · Starting containers

+
+ Logs +
+
+ ); + } + return null; +} + +// ---- the bill: Ployz's part is flat; the server is your provider's --------------------------------------- + +const RECEIPT = [ + { item: "Ployz", detail: "Preview environments, servers, replicas", price: "$0" }, + { item: "Custom domains", detail: "Optional", price: "$9" }, +]; + +export function ReceiptScene() { + return ( +
+
+ Your Ployz bill + /mo +
+
+ {RECEIPT.map(({ item, detail, price }) => ( +
+
+ {item} + {detail} +
+ {price} +
+ ))} +
+

Your servers are billed by your provider.

+
+ ); +} + +// ---- the last gag: the old root prompt, done with ------------------------------------------------------ + +export function ExitPrompt() { + return ( + + exit + + ); +} diff --git a/dashboard/src/routes/_public/-components/LoginPanel.tsx b/dashboard/src/routes/_public/-components/LoginPanel.tsx index b3491bb042..30f271abe1 100644 --- a/dashboard/src/routes/_public/-components/LoginPanel.tsx +++ b/dashboard/src/routes/_public/-components/LoginPanel.tsx @@ -102,11 +102,8 @@ export function LoginPanel() { return (
- - P -
-

Login to Ployz

+

Sign in to Ployz

Push-to-deploy on your own servers.

diff --git a/dashboard/src/routes/_public/-components/PublicChrome.tsx b/dashboard/src/routes/_public/-components/PublicChrome.tsx new file mode 100644 index 0000000000..57b107b401 --- /dev/null +++ b/dashboard/src/routes/_public/-components/PublicChrome.tsx @@ -0,0 +1,94 @@ +import { useState, type ReactNode } from "react"; +import { Link, useHydrated, useMatch } from "@tanstack/react-router"; +import { buttonVariants } from "#/components/ui/button-variants"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "#/components/ui/dialog"; +import { cn } from "#/lib/utils"; +import { LoginPanel } from "#/routes/_public/-components/LoginPanel"; + +// The frame every signed-out page shares (the _public layout): the wordmark, the header and the footer. + +function Wordmark({ className }: { className?: string }) { + return ployz; +} + +/** + * A link to /auth that, once the page is interactive, opens the sign-in panel in a dialog over the page + * instead. The dialog portals to , outside the layout's `.light` root, so it carries `light` itself. + */ +export function LoginDialog({ className, children }: { className?: string; children: ReactNode }) { + const isHydrated = useHydrated(); + const [open, setOpen] = useState(false); + + return ( + + { + if (!isHydrated) return; + event.preventDefault(); + setOpen(true); + }} + > + {children} + + + + Sign in + Sign in with GitHub to continue. + + + + + ); +} + +export function PublicHeader() { + // On /auth the page is the sign-in, so the frame drops its own sign-in links. + const onSignIn = useMatch({ from: "/_public/auth", shouldThrow: false }); + return ( +
+
+ + + + + {onSignIn ? null : ( +
+ Sign in + Deploy +
+ )} +
+
+ ); +} + +export function PublicFooter() { + const onSignIn = useMatch({ from: "/_public/auth", shouldThrow: false }); + return ( +
+
+ +
+ Keep the platform. Drop the bill. + {onSignIn ? null : ( + Sign in + )} +
+
+
+ ); +} diff --git a/dashboard/src/routes/_public/auth.tsx b/dashboard/src/routes/_public/auth.tsx index fe9a4d640a..fc9d54e91c 100644 --- a/dashboard/src/routes/_public/auth.tsx +++ b/dashboard/src/routes/_public/auth.tsx @@ -24,12 +24,13 @@ export const Route = createFileRoute('/_public/auth')({ component: RouteComponent, }) +// The header and footer come from the _public layout; the panel sits between them. function RouteComponent() { return ( -
+
-
+ ) } diff --git a/dashboard/src/routes/_public/index.tsx b/dashboard/src/routes/_public/index.tsx index 33881d4b23..9446c8dd76 100644 --- a/dashboard/src/routes/_public/index.tsx +++ b/dashboard/src/routes/_public/index.tsx @@ -1,8 +1,22 @@ import { getAuthSession } from '#/auth/auth' -import { Link, createFileRoute, redirect } from '@tanstack/react-router' +import { createFileRoute, redirect } from '@tanstack/react-router' +import type { ReactNode } from 'react' import { buildMarketingMeta } from '#/components/marketing/meta' -import { PloyzLogo } from '#/components/icons/ployz-logo' +import { LoginDialog } from '#/routes/_public/-components/PublicChrome' +import { WireDefs } from '#/components/marketing/kit' +import { + DeployTicker, + ExitPrompt, + GetStartedScene, + HeroScene, + PreviewScene, + ReceiptScene, + ReviewScene, +} from '#/components/marketing/scenes' +import landingCss from '#/components/marketing/landing.css?url' +import { Badge } from '#/components/ui/badge' import { buttonVariants } from '#/components/ui/button-variants' +import { cn } from '#/lib/utils' export const Route = createFileRoute('/_public/')({ beforeLoad: async () => { @@ -14,24 +28,429 @@ export const Route = createFileRoute('/_public/')({ }, head: () => ({ meta: buildMarketingMeta({ - title: 'Ployz', - description: 'Deploy and run applications on servers you control.', + title: 'Ployz: keep the platform, drop the bill', + description: + 'Git push deploys and preview environments, on servers you own. Open source, and free on your own servers.', }), + links: [{ rel: 'stylesheet', href: landingCss }], }), - component: StubLander, + component: Lander, }) -// ponytail: stub until the separate marketing site ships. -function StubLander() { - return ( -
- -

- Deploy and run applications on servers you control. -

- - Sign in - +// Written for Carol: a solo developer who already pays a hosted platform and has never had to run a +// server. It sells the #1225 flow and launches with it; only database moves, snapshots, backups and +// rollbacks are Soon. Always light, whatever the visitor's theme; colour fields use the brand's paper +// tokens (tokens.css), and Intent Pink stays on Deploy and staged changes. The header, footer and light +// scope come from the _public layout. +function Lander() { + return ( +
+ + + + + + + + + + +
) } + +/** + * Deploy, the page's one action: it opens sign-in over the page. `intent` is the product's solid pink + * Deploy, used only in the hero and the closer. + */ +function DeployButton({ intent }: { intent?: boolean }) { + return Deploy +} + +/** A small mono label, like the tags on a rack unit. */ +function Tag({ children }: { children: ReactNode }) { + return {children} +} + +/** A word in a heading set on a paper block, to point at it. */ +function Mark({ children }: { children: ReactNode }) { + return {children} +} + +function Caption({ children, className }: { children: ReactNode; className?: string }) { + return

{children}

+} + +function Section({ id, className, children }: { id?: string; className?: string; children: ReactNode }) { + return ( +
+
{children}
+
+ ) +} + +function Title({ children }: { children: ReactNode }) { + return

{children}

+} + +// ---- the hero, and the dashboard on its server --------------------------------------------------------- + +function Hero() { + return ( +
+
+

+ Keep the platform. Drop the bill. +

+

+ Git push deploys and preview environments, on servers you own. +

+ +

Open source · Free on your own servers

+
+
+ +
+ Your whole app on one canvas, running on a server you own. +
+ ) +} + +// ---- one command, from a root prompt ---------------------------------------------------------------------- + +function OneCommand() { + return ( +
+ + From <Mark>root prompt</Mark> to <Mark>https</Mark> in one command. + +
+ +
+ Sign in, point Ployz at your server, and it installs itself, builds your app and gives it an address. +
+ ) +} + +// ---- what it runs, and where ---------------------------------------------------------------------------------- + +const WHERE = [ + { title: 'Start on one server', body: 'It runs your whole app: web, workers and databases.' }, + { title: 'Add servers', body: 'One command each, all on one private network.' }, + { title: 'Leave any time', body: 'Open source. If Ployz Cloud went away, your apps would keep running.' }, +] + +function AnyStack() { + return ( +
+ + Builds your app without a Dockerfile, or runs any Docker image. +
+ {WHERE.map(({ title, body }) => ( +
+

{title}

+

{body}

+
+ ))} +
+
+ ) +} + +// ---- the platform parts --------------------------------------------------------------------------------------- + +function Platform() { + return ( +
+ Everything you'd pay a platform for. +
+ + + + + + +
+
+ ) +} + +function Feature({ field, title, body, children }: { field: string; title: string; body: string; children: ReactNode }) { + return ( +
+
{children}
+

{title}

+

{body}

+
+ ) +} + +// ---- what Ployz runs for you ------------------------------------------------------------------------------------- + +const RUNS: { tag: string; body: ReactNode; parts: string[]; soon?: string[] }[] = [ + { + tag: 'Start', + body: ( + <> + ployz up puts Ployz on your server, builds your app and gives it an https address. + + ), + parts: ['One command', 'No Dockerfile needed', 'https address'], + }, + { + tag: 'Ship', + body: 'Every push builds and ships. Every pull request gets its own environment.', + parts: ['Git push deploys', 'Preview environments', 'Logs and variables'], + }, + { + tag: 'Grow', + body: 'Add a server with one command, and spread your app across both.', + parts: ['Up to 50 replicas', 'Private network', 'No Kubernetes'], + soon: ['Database moves', 'Snapshots, backups and rollbacks'], + }, + { + tag: 'Agents', + body: 'Anything you can do, your agent can do too, with output it can read.', + parts: ['A command for every action', 'JSON output', 'The canvas follows along'], + }, +] + +function Runs() { + return ( +
+ You build the app. Ployz runs the servers. +
+ {RUNS.map(({ tag, body, parts, soon }) => ( +
+ {tag} +

{body}

+
    + {parts.map((part) => ( +
  • + {part} +
  • + ))} + {soon?.map((part) => ( +
  • + {part} + Soon +
  • + ))} +
+
+ ))} +
+
+ ) +} + +// ---- price, proof and comparison ------------------------------------------------------------------------------------ + +function Pricing() { + return ( +
+
+
+ Pricing +

+ Free on your own servers. Custom domains are $9 a month. +

+ + Your servers are billed by your provider. We make money on custom domains, never on usage. +
+ +
+
+ ) +} + +function Founder() { + return ( +
+
+
+ “I moved my apps to one big server and got about ten times the machine for a tenth of the cost. Then I missed + the platform, so I built it.” +
+
Nick Potts, who builds Ployz
+
+
+ ) +} + +// Fair to each category: where a category is mixed, it says so. The last row is Ployz's honest gap. +// Ployz comes first so it's on screen before a phone scrolls the table sideways. +const COMPARE = [ + { row: 'Where your app runs', ployz: 'Your servers', hosted: 'Their servers', selfHosted: 'Your servers' }, + { row: 'What you pay for', ployz: 'Your servers, plus $9/mo for custom domains', hosted: 'Plans and usage, often per seat', selfHosted: 'Your servers' }, + { row: 'A preview environment per pull request', ployz: 'Yes', hosted: 'Yes', selfHosted: 'Some tools' }, + { row: 'Your whole app on one canvas', ployz: 'Yes', hosted: 'Some platforms', selfHosted: 'Some tools' }, + { row: 'Dashboard changes reviewed before they deploy', ployz: 'Yes', hosted: 'Some platforms', selfHosted: 'Rarely' }, + { row: 'Adding a second server', ployz: 'One command', hosted: 'They handle it', selfHosted: 'Varies by tool' }, + { row: 'If the company disappears', ployz: 'Your apps keep running', hosted: 'You move your app', selfHosted: 'Your apps keep running' }, + { row: "Who patches the server's OS", ployz: 'You do', hosted: 'They do', selfHosted: 'You do' }, +] + +function Compare() { + return ( +
+ The platform, without the landlord. +
+ + + + + + + + + + {COMPARE.map(({ row, ployz, hosted, selfHosted }) => ( + + + + + + + ))} + +
+ + Ployz + + Hosted platforms + + Self-hosted tools +
+ {row} + {ployz}{hosted}{selfHosted}
+
+
+ ) +} + +// Carol's questions, answered honestly: what isn't built yet says so. +const QUESTIONS: { q: string; a: ReactNode }[] = [ + { + q: 'How is this different from the platform I pay for now?', + a: "It runs on servers you control. You still get a preview environment for every pull request, your whole app on one canvas, and dashboard changes reviewed before they deploy. It's free on your own servers, and custom domains are $9 a month. The trade: the server is yours, so its OS updates are yours too.", + }, + { + q: 'How is it different from other self-hosted tools?', + a: 'One canvas for your whole app, dashboard changes reviewed before they deploy, a preview environment for every pull request, and one command to add a server to your private network. No Kubernetes, and your apps keep running if Ployz Cloud goes away.', + }, + { + q: 'Do I need to know Linux?', + a: ( + <> + You need a Linux server you can reach as root over SSH. ployz up does the rest. + Ployz doesn't update the server's operating system; that stays with you. + + ), + }, + { + q: 'What will this cost me?', + a: 'Ployz is free on your own servers, and $9 a month only if you want custom domains. Your provider bills you for the servers.', + }, + { + q: 'What happens if Ployz goes away?', + a: 'Your servers keep running your apps. The engine and the dashboard are open source, and you can host the dashboard yourself.', + }, + { + q: 'What happens if my server dies?', + a: "Services on that server stop. Replicas on your other servers keep running, but Ployz doesn't fail over for you, and a volume lives on the server it's on. Keep a copy of your data off the server; snapshots and backups are coming soon.", + }, + { + q: 'Are my databases backed up?', + a: 'Not by Ployz yet: snapshots, backups and rollbacks are coming soon. Until then, keep regular dumps off the server, or keep your managed database and point your app at it with a variable.', + }, + { + q: 'Can I move to a bigger server later?', + a: 'Moving a database to a bigger server without going down is coming soon. Today you can add servers and run replicas across them.', + }, + { + q: 'Will my app work? Do I need a Dockerfile?', + a: "Ployz works out how to build Node, Python, Ruby, PHP, Go, Rust, Java, Elixir and more, or builds from your Dockerfile if you choose it. Any Docker image runs too. Compose files aren't supported.", + }, + { + q: 'Do I get https and my own domain?', + a: 'Every first deploy gets an https address. Custom domains are $9 a month.', + }, + { + q: 'Can my coding agent run it?', + a: ( + <> + Yes. Everything the dashboard does, the ployz CLI does too, with JSON output.{' '} + ployz setup agent teaches your agent how, and its changes show on your canvas within seconds. + + ), + }, + { + q: 'Is it open source?', + a: 'Yes. The engine is Apache-2.0 and the dashboard is AGPL-3.0.', + }, + { + q: 'Does it scale?', + a: "Add servers with one command, and run a service as up to 50 replicas across them, without Kubernetes. There's no autoscaling.", + }, + { + q: 'Where do I see logs? Can I get alerts?', + a: "Every service's logs are in the dashboard. Ployz doesn't send alerts, so use an uptime monitor.", + }, + { + q: 'Is it secure?', + a: "Your servers talk over one private network. Ployz doesn't harden or patch the operating system, so use SSH keys, a firewall and automatic updates.", + }, + { + q: 'Can my team use it?', + a: 'Not yet. Ployz is built for one person today, and there are no team roles or permissions.', + }, + { + q: 'Which servers can I use?', + a: 'Any Linux server with systemd that you can reach as root over SSH, on x86-64 or ARM, from any provider.', + }, +] + +function Questions() { + return ( +
+ Questions +
+ {QUESTIONS.map(({ q, a }) => ( +
+ + {q} + + + + + +

{a}

+
+ ))} +
+
+ ) +} + +function Closer() { + return ( +
+
+
+ Your servers. The whole platform. + +
+ +
+
+ ) +} diff --git a/dashboard/src/routes/_public/route.tsx b/dashboard/src/routes/_public/route.tsx index 06f3784e48..d4cf266c7b 100644 --- a/dashboard/src/routes/_public/route.tsx +++ b/dashboard/src/routes/_public/route.tsx @@ -1,4 +1,16 @@ -import { createFileRoute } from "@tanstack/react-router"; +import { Outlet, createFileRoute } from "@tanstack/react-router"; +import { PublicFooter, PublicHeader } from "#/routes/_public/-components/PublicChrome"; -// Pathless group for signed-out pages; LoginPanel sends sign-ins from here to /cloud. -export const Route = createFileRoute("/_public")({}); +// Pathless group for signed-out pages (the lander and /auth). They share one header and footer and stay +// light whatever the visitor's theme; LoginPanel sends sign-ins from here to /cloud. +export const Route = createFileRoute("/_public")({ component: PublicLayout }); + +function PublicLayout() { + return ( +
+ + + +
+ ); +} diff --git a/dashboard/src/styles.css b/dashboard/src/styles.css index 0bfba15e14..840433f2cf 100644 --- a/dashboard/src/styles.css +++ b/dashboard/src/styles.css @@ -5,10 +5,13 @@ @import "@fontsource-variable/geist-mono"; @import "../tokens.css"; -@custom-variant dark (&:is(.dark *)); +/* A `.light` subtree (the signed-out pages) stays light when the page is dark, and a `.dark` block inside + it is dark again. The added parts sit in :where(), so `dark:` keeps its old specificity. */ +@custom-variant dark (&:is(:is(.dark *):not(:where(.light *)), :where(.light .dark *))); @plugin "./tailwind-typography.js"; -:root { +:root, +.light { --background: hsl(0 0% 100%); --foreground: #111111; --card: hsl(0 0% 100%); @@ -67,6 +70,10 @@ --shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25); } +.light { + color-scheme: light; +} + .dark { --background: #0a0a0a; --foreground: #ededed;