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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion dashboard/CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 0 additions & 11 deletions dashboard/src/components/icons/ployz-logo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,3 @@ export function PloyzMark({
</svg>
)
}

export function PloyzLogo({ className }: { className?: string }) {
return (
<span className={cn('inline-flex items-center gap-2', className)}>
<PloyzMark className="size-7" />
<span className="font-heading text-lg font-bold tracking-tight">
ployz
</span>
</span>
)
}
300 changes: 300 additions & 0 deletions dashboard/src/components/marketing/kit.tsx
Original file line number Diff line number Diff line change
@@ -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<Tone, readonly [string, string]>;

export function StatusDot({ tone = "quiet" }: { tone?: Tone }) {
const [outer, inner] = DOT[tone];
return (
<span className={cn("flex size-3 shrink-0 items-center justify-center rounded-full", outer)}>
<span className={cn("size-1.5 rounded-full", inner)} />
</span>
);
}

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 (
<Card size="node" state={state} data-node={id} className={cn("h-36 w-full max-w-72 justify-between", className)}>
<CardHeader>
<div className="flex items-start gap-3">
<Avatar>
<AvatarFallback>{icon}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1 overflow-hidden">
<CardTitle>{name}</CardTitle>
<CardDescription>{sub}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex items-center gap-3">
<StatusDot tone={tone} />
<span className="min-w-0 flex-1 truncate text-muted-foreground">{status}</span>
</div>
</CardContent>
</Card>
);
}

// ---- 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<SVGSVGElement>(null);
const [paths, setPaths] = useState<string[]>([]);

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<HTMLElement>(`[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 (
<svg ref={svg} aria-hidden className="pointer-events-none absolute inset-0 z-0 size-full overflow-visible">
{wires.map((wire, i) => {
const d = paths[i];
if (!d) return null;
const { seconds, packets } = wire;
return (
<g key={`${wire.from}-${wire.to}`} className={wire.className}>
<path d={d} className="lp-wire" markerEnd={wire.across || wire.arrow === false ? undefined : "url(#lp-arrow)"} />
{Array.from({ length: packets }, (_, n) => (
<Packet key={n} path={d} seconds={seconds} delay={(n / packets) * seconds} reverse={!wire.across} />
))}
</g>
);
})}
</svg>
);
}

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 (
<circle className="lp-packet" r={4.5}>
<animateMotion {...timing} path={path} keyPoints={reverse ? "1;0" : "0;1"} keyTimes="0;1" calcMode="linear" />
<animate {...timing} attributeName="opacity" values="0;1;1;0" keyTimes="0;0.12;0.88;1" />
</circle>
);
}

/** The arrowhead every wire on the page points with; render once. */
export function WireDefs() {
return (
<svg aria-hidden className="absolute size-0">
<defs>
<marker id="lp-arrow" viewBox="0 0 10 10" refX="5" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" className="lp-arrow" />
</marker>
</defs>
</svg>
);
}

// ---- 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<T extends HTMLElement>(): [RefObject<T | null>, boolean | undefined] {
const ref = useRef<T>(null);
const [seen, setSeen] = useState<boolean>();
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<F extends { ms: number }>(frames: readonly [F, ...F[]], active: boolean, rest: F): F {
const [index, setIndex] = useState<number>();
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 (
<div className="lp-face">
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex flex-wrap items-baseline gap-x-2.5">
<b>{name}</b>
<span className="lp-muted">{spec}</span>
</div>
<span className="lp-muted text-xs">{ip}</span>
</div>
<div className="flex flex-col items-end gap-1.5">
<div className="lp-leds">
<i />
<i />
<i />
</div>
<div className="lp-act">
{Array.from({ length: 8 }, (_, i) => (
<i key={i} style={{ animationDelay: `${(-i * 0.29).toFixed(2)}s` }} />
))}
</div>
</div>
</div>
);
}

export function Terminal({ title, light, className, children }: { title: string; light?: boolean; className?: string; children: ReactNode }) {
return (
<div className={cn("lp-term", !light && "dark", className)}>
<div className="lp-term-bar">
<i />
<i />
<i />
<span>{title}</span>
</div>
<div className="lp-term-body">{children}</div>
</div>
);
}

/** 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 (
<p className={cn("lp-line", className)} style={{ animationDelay: revealDelay(order) }}>
{prompt ? <span className="lp-prompt">{prompt} </span> : null}
{children}
</p>
);
}

export function BrowserWindow({ url, children }: { url: string; children: ReactNode }) {
return (
<div className="lp-browser">
<div className="lp-browser-bar">
<i />
<i />
<i />
<span className="lp-browser-url">
<LockIcon className="size-3 shrink-0 text-success" />
<span className="truncate">{url}</span>
</span>
</div>
{children}
</div>
);
}
Loading
Loading