From 1f51d862a2f3b1761c0f670a0f394b7323c16071 Mon Sep 17 00:00:00 2001 From: Nick Potts Date: Wed, 30 Sep 2026 13:16:25 +0800 Subject: [PATCH 1/2] Lander: keep the platform, drop the bill, for the #1225 launch The public page at / replaces the stub with a page for a solo developer who already pays a hosted platform. It follows the rhythm of Porter's homepage: the dashboard on a server, the root prompt handed to `ployz up`, the stack it deploys, preview environments and reviewed changes, what Ployz runs, pricing, a founder quote, a comparison and seventeen honest answers. Database moves and backups carry Soon tags. The pictures are built from the product's own Card, status colours and bottom bar, with wires in the canvas's edge shapes. The page is always light: `.light` rescopes the tokens, and the dark variant skips a `.light` subtree without changing its specificity. The glossary lets the public site say "preview environment". It launches with #1225: its CLI lines are checked against the shipped CLI before merge. --- dashboard/CONTEXT.md | 2 +- dashboard/src/components/icons/ployz-logo.tsx | 11 - dashboard/src/components/marketing/kit.tsx | 300 +++++++++++ .../src/components/marketing/landing.css | 386 +++++++++++++ dashboard/src/components/marketing/scenes.tsx | 377 +++++++++++++ dashboard/src/routes/_public/index.tsx | 505 +++++++++++++++++- dashboard/src/styles.css | 11 +- 7 files changed, 1562 insertions(+), 30 deletions(-) create mode 100644 dashboard/src/components/marketing/kit.tsx create mode 100644 dashboard/src/components/marketing/landing.css create mode 100644 dashboard/src/components/marketing/scenes.tsx 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/index.tsx b/dashboard/src/routes/_public/index.tsx index 33881d4b23..6a41685387 100644 --- a/dashboard/src/routes/_public/index.tsx +++ b/dashboard/src/routes/_public/index.tsx @@ -1,8 +1,21 @@ import { getAuthSession } from '#/auth/auth' import { Link, 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 { 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 +27,484 @@ 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. +function Lander() { + return ( +
+ +
+
+ + + + + + + + + + +
+
+
+ ) +} + +function Wordmark({ className }: { className?: string }) { + return ployz +} + +/** Deploy, the page's one action. `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 Header() { + return ( +
+
+ + + + +
+ + Sign in + + + Deploy + +
+
+
+ ) +} + +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. + +
+ +
+
+ ) +} + +function Footer() { + return ( +
+
+ +
+ Keep the platform. Drop the bill. + + Sign in + +
+
+
) } diff --git a/dashboard/src/styles.css b/dashboard/src/styles.css index 0bfba15e14..52211d9964 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 public lander) 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; From 8389f37144d79b4e0272e6f09c444605113e827e Mon Sep 17 00:00:00 2001 From: Nick Potts Date: Wed, 30 Sep 2026 13:31:19 +0800 Subject: [PATCH 2/2] Lander: sign in over the page, and give /auth the lander's frame Deploy and Sign in open sign-in in a dialog over the page again, as they did before #1063; /auth stays for direct links and before the page is interactive. /auth no longer stands alone: the _public layout gives both signed-out pages the same header, footer and light scope, and the header's links reach the lander's sections from either page. On /auth the frame drops its own sign-in links, and the sign-in panel loses its placeholder tile. --- .../routes/_public/-components/LoginPanel.tsx | 5 +- .../_public/-components/PublicChrome.tsx | 94 ++++++++++++++++++ dashboard/src/routes/_public/auth.tsx | 5 +- dashboard/src/routes/_public/index.tsx | 96 ++++--------------- dashboard/src/routes/_public/route.tsx | 18 +++- dashboard/src/styles.css | 2 +- 6 files changed, 135 insertions(+), 85 deletions(-) create mode 100644 dashboard/src/routes/_public/-components/PublicChrome.tsx 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 6a41685387..9446c8dd76 100644 --- a/dashboard/src/routes/_public/index.tsx +++ b/dashboard/src/routes/_public/index.tsx @@ -1,7 +1,8 @@ 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 { LoginDialog } from '#/routes/_public/-components/PublicChrome' import { WireDefs } from '#/components/marketing/kit' import { DeployTicker, @@ -39,40 +40,32 @@ export const Route = createFileRoute('/_public/')({ // 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. +// 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 ( -
+
-
-
- - - - - - - - - - -
-
-
+ + + + + + + + + + + ) } -function Wordmark({ className }: { className?: string }) { - return ployz -} - -/** Deploy, the page's one action. `intent` is the product's solid pink Deploy, used only in the hero and the closer. */ +/** + * 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 - - ) + return Deploy } /** A small mono label, like the tags on a rack unit. */ @@ -89,37 +82,6 @@ function Caption({ children, className }: { children: ReactNode; className?: str return

{children}

} -function Header() { - return ( -
-
- - - - -
- - Sign in - - - Deploy - -
-
-
- ) -} - function Section({ id, className, children }: { id?: string; className?: string; children: ReactNode }) { return (
@@ -492,19 +454,3 @@ function Closer() {
) } - -function Footer() { - return ( -
-
- -
- Keep the platform. Drop the bill. - - Sign in - -
-
-
- ) -} 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 52211d9964..840433f2cf 100644 --- a/dashboard/src/styles.css +++ b/dashboard/src/styles.css @@ -5,7 +5,7 @@ @import "@fontsource-variable/geist-mono"; @import "../tokens.css"; -/* A `.light` subtree (the public lander) stays light when the page is dark, and a `.dark` block inside +/* 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";