From 3f9f4d25d25a759375143a7d54addfd91cbf99f7 Mon Sep 17 00:00:00 2001 From: Mathieu Chambaud Date: Fri, 6 Nov 2026 19:22:22 +0100 Subject: [PATCH] feat(landing): rebuild the home page from the Home v3 design Follow the Lumyx Home v3 layout section by section: hero with a live incident window, scroll-lit statement, detect/explain/recover row, tabbed product showcase, status checklist, packet band, quickstart terminal and FAQ. Colours map to @lumyx/ui tokens so both themes work. Claims follow the sfu-core README: no client SDK, the RTCP collector is planned, no Lumyx Cloud. Remove the islands the old page used. --- .../src/components/site/config-panel.tsx | 105 --- apps/landing/src/components/site/copy.tsx | 12 +- .../src/components/site/hero-console.tsx | 120 ---- .../src/components/site/incident-window.tsx | 189 +++++ .../src/components/site/mobile-nav.tsx | 85 +++ .../src/components/site/product-tabs.tsx | 163 +++++ .../src/components/site/room-topology.tsx | 151 ---- .../src/components/site/start-terminal.tsx | 72 +- apps/landing/src/lib/highlight.ts | 55 -- apps/landing/src/lib/home-data.ts | 323 ++++----- apps/landing/src/lib/peer-sim.ts | 156 ----- apps/landing/src/lib/tone.ts | 34 + apps/landing/src/lib/ui.ts | 22 + apps/landing/src/pages/index.astro | 652 +++++++++++------- 14 files changed, 1066 insertions(+), 1073 deletions(-) delete mode 100644 apps/landing/src/components/site/config-panel.tsx delete mode 100644 apps/landing/src/components/site/hero-console.tsx create mode 100644 apps/landing/src/components/site/incident-window.tsx create mode 100644 apps/landing/src/components/site/mobile-nav.tsx create mode 100644 apps/landing/src/components/site/product-tabs.tsx delete mode 100644 apps/landing/src/components/site/room-topology.tsx delete mode 100644 apps/landing/src/lib/highlight.ts delete mode 100644 apps/landing/src/lib/peer-sim.ts create mode 100644 apps/landing/src/lib/tone.ts create mode 100644 apps/landing/src/lib/ui.ts diff --git a/apps/landing/src/components/site/config-panel.tsx b/apps/landing/src/components/site/config-panel.tsx deleted file mode 100644 index de3c697..0000000 --- a/apps/landing/src/components/site/config-panel.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import * as React from "react"; -import { cx } from "@/lib/cx"; -import { SNIPPETS, STEPS, type SnippetKey } from "@/lib/home-data"; -import { tokenize } from "@/lib/highlight"; -import { CopyButton } from "./copy"; - -const TABS = Object.keys(SNIPPETS) as SnippetKey[]; - -/** Three steps on the left drive the tabbed code panel on the right. */ -export function ConfigPanel({ children }: { children?: React.ReactNode }) { - const [tab, setTab] = React.useState("toml"); - const [hl, setHl] = React.useState<[number, number] | null>(null); - const [step, setStep] = React.useState(0); - const snip = SNIPPETS[tab]; - const range = hl ?? snip.hl; - - return ( -
-
- {children} -
- {STEPS.map((st, i) => { - const on = step === i; - return ( - - ); - })} -
-
- - {/* Code always reads on a dark ground, whatever the page theme. */} -
-
- {TABS.map((k) => { - const on = tab === k; - return ( - - ); - })} - - -
-
- {snip.lines.map((line, i) => { - const on = i + 1 >= range[0] && i + 1 <= range[1]; - return ( -
- {i + 1} - - {tokenize(line, snip.lang).map((t, k) => ( - - {t.v} - - ))} - -
- ); - })} -
-
{snip.note}
-
-
- ); -} diff --git a/apps/landing/src/components/site/copy.tsx b/apps/landing/src/components/site/copy.tsx index 7fd3e97..f31fac8 100644 --- a/apps/landing/src/components/site/copy.tsx +++ b/apps/landing/src/components/site/copy.tsx @@ -14,7 +14,7 @@ export function useCopy() { return { copied, copy }; } -/** Small outlined "Copy" button used in panel headers. */ +/** Small "Copy" button used in panel headers. */ export function CopyButton({ text, className }: { text: string; className?: string }) { const { copied, copy } = useCopy(); return ( @@ -23,7 +23,7 @@ export function CopyButton({ text, className }: { text: string; className?: stri onClick={() => copy(text)} aria-label={copied ? "Copied" : "Copy to clipboard"} className={cx( - "h-7 flex-none rounded-[6px] border border-stroke bg-transparent px-2.5 text-12 text-muted transition-transform hover:text-strong active:scale-[0.98]", + "flex-none cursor-pointer rounded-xs border border-stroke bg-transparent px-3 py-1 text-12 text-muted transition-colors hover:text-strong active:scale-[0.98]", className, )} > @@ -32,18 +32,18 @@ export function CopyButton({ text, className }: { text: string; className?: stri ); } -/** The one-line command in the hero: the whole row is the copy target. */ +/** The one-line command in the hero, styled as a secondary button: the whole button copies. */ export function CopyCommand({ command }: { command: string }) { const { copied, copy } = useCopy(); return ( diff --git a/apps/landing/src/components/site/hero-console.tsx b/apps/landing/src/components/site/hero-console.tsx deleted file mode 100644 index 99eb92a..0000000 --- a/apps/landing/src/components/site/hero-console.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { cx } from "@/lib/cx"; -import { STATUS_TEXT, linePath, severity, statusOf, useRoom } from "@/lib/peer-sim"; - -const ROW_H = 40; - -/* Columns: peer_id · score · rtt · loss · [jitter] · status. Jitter and the score bar appear - once the console itself is wide enough (container query), not the viewport. */ -const COLS = - "grid-cols-[72px_38px_minmax(0,1fr)_minmax(0,1fr)_78px] @xl:grid-cols-[100px_minmax(0,1fr)_64px_70px_76px_92px]"; - -/** The hero's live room: peers re-sort by quality score every 500 ms. Demo data. */ -export function HeroConsole() { - const room = useRoom(); - const order = [...room.peers].sort((a, b) => a.score - b.score).map((p) => p.id); - - return ( -
-
- - webinar-us - eu-west-3 · 6 peers - - demo data -
- -
- peer_id - score - rtt_ms - loss - jitter_ms - status -
- -
- {room.peers.map((p) => { - const st = statusOf(p); - const bad = st === "bad"; - return ( -
- {p.id} - - - {p.score} - - - - - - - {Math.round(p.rtt)} - - - {p.loss.toFixed(2)}% - - - {Math.round(p.jit)} - - - - {bad ? "degraded" : "live"} - -
- ); - })} -
- -
-
- - - jitter_ms - - - - packet_loss_ratio - - - room avg · 30s · 500 ms -
- - - - - -
- -30s - jitter_ms > 30 - now -
-
-
- ); -} diff --git a/apps/landing/src/components/site/incident-window.tsx b/apps/landing/src/components/site/incident-window.tsx new file mode 100644 index 0000000..f0a3eec --- /dev/null +++ b/apps/landing/src/components/site/incident-window.tsx @@ -0,0 +1,189 @@ +import * as React from "react"; +import { cx } from "@/lib/cx"; +import { DOT, INK } from "@/lib/tone"; +import type { Tone } from "@/lib/home-data"; + +/** Hero product window: one incident, with a packet-loss trace that keeps moving. Demo data. */ + +const N = 40; +const SEED = Array.from({ length: N }, (_, i) => 0.7 + 0.4 * Math.abs(Math.sin(i * 0.8))); +const lossAt = (t: number) => { + const s = (Math.sin(t / 8) + 1) / 2; + return 1 + 7 * s + 0.6 * Math.sin(t * 1.9); +}; + +function useLoss() { + const [loss, setLoss] = React.useState(SEED); + React.useEffect(() => { + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; + let t = 0; + const iv = setInterval(() => { + t += 1; + setLoss((l) => [...l.slice(1), lossAt(t)]); + }, 600); + return () => clearInterval(iv); + }, []); + return loss; +} + +const RAIL = [ + "M40 72h176M40 128h176M40 184h176", + "M128 32v192M32 128h192", + "M48 208V96M128 208V48M208 208v-80", + "M128 40a88 88 0 1 0 0 176a88 88 0 1 0 0-176M128 88v40l24 24", + "M72 104l56 56 56-56", +]; + +const INCIDENTS: { t: string; m: string; tone: Tone; on?: boolean }[] = [ + { t: "Amara froze", m: "webinar-us · 2.4s · now", tone: "bad", on: true }, + { t: "Kenji high jitter", m: "webinar-us · 41 ms · 6 min ago", tone: "warn" }, + { t: "TURN fallback", m: "standup-eu · 1 peer · 18 min ago", tone: "warn" }, + { t: "Ravi reconnected", m: "standup-eu · resolved", tone: "ok" }, +]; + +const ACTIVITY: { t: string; at: string; d: string; tone: Tone; code?: string }[] = [ + { t: "ICE fell back to TURN", at: "14:06:02", d: "Direct path failed. Packets now take the relay.", tone: "warn" }, + { t: "Packet loss above 2%", at: "14:06:29", d: "Sustained for 12s on the video track.", tone: "bad", code: "packet_loss_ratio = 0.079" }, + { t: "Freeze detected", at: "14:06:41", d: "Five peers stopped receiving frames from Amara.", tone: "bad", code: "freeze_ratio = 0.18" }, + { t: "Keyframe requested", at: "14:06:43", d: "PLI sent to Amara to recover the stream.", tone: "accent" }, +]; + +const PEERS = ["A", "L", "N", "K", "I", "R"]; + +export function IncidentWindow() { + const loss = useLoss(); + const cur = loss[loss.length - 1]; + const rtt = Math.round(130 + cur * 18); + const path = loss + .map((v, i) => `${i ? "L" : "M"}${((i * 400) / (N - 1)).toFixed(1)} ${(54 - Math.min(1, v / 9) * 52).toFixed(1)}`) + .join(" "); + + const props: { k: string; v: string; tone?: Tone }[] = [ + { k: "Room", v: "webinar-us · eu-west-3" }, + { k: "Peer", v: "Amara · ff104b2c" }, + { k: "Path", v: "TURN relay", tone: "bad" }, + { k: "Packet loss", v: `${cur.toFixed(1)}% vs 2%`, tone: cur > 2 ? "bad" : "ok" }, + { k: "Round trip", v: `${rtt} ms vs 200`, tone: rtt > 200 ? "bad" : undefined }, + { k: "Impacted peers", v: "5 of 6" }, + ]; + + return ( +
+
+ {/* icon rail */} + + + {/* incident list */} +
+
+ Incidentswebinar-us +
+ {INCIDENTS.map((ic) => ( +
+ + + {ic.t} + + {ic.m} +
+ ))} +
+ + {/* detail */} +
+
+ #42 + Amara’s video froze + Open + + +
+ +
+
+ +
+

Amara’s video froze

+

Six peers saw her tile stop for 2.4s while audio kept going.

+
+
+ {props.map((p) => ( +
+
{p.k}
+
{p.v}
+
+ ))} +
+
+ + Packet loss · last 30s + {cur.toFixed(1)}% + + +
+
+ +
+ +
    + {ACTIVITY.map((a, i) => ( +
  1. + + + {i < ACTIVITY.length - 1 && } + +
    + + {a.t} {a.at} + + {a.d} + {a.code && ( + {a.code} + )} +
    +
  2. + ))} +
+
+
+
+
+
+ ); +} diff --git a/apps/landing/src/components/site/mobile-nav.tsx b/apps/landing/src/components/site/mobile-nav.tsx new file mode 100644 index 0000000..d7022dc --- /dev/null +++ b/apps/landing/src/components/site/mobile-nav.tsx @@ -0,0 +1,85 @@ +import * as React from "react"; +import { createPortal } from "react-dom"; +import { cx } from "@/lib/cx"; + +const noop = () => () => {}; + +const EASE = "transition-all duration-700 ease-[cubic-bezier(0.32,0.72,0,1)]"; + +/** Burger and full-screen link overlay, shown below 960px. */ +export function MobileNav({ + links, +}: { + links: { href: string; label: string }[]; +}) { + const [open, setOpen] = React.useState(false); + // false on the server and during hydration, true once mounted: the portal needs document.body. + const mounted = React.useSyncExternalStore(noop, () => true, () => false); + + React.useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false); + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open]); + + return ( +
+ + {mounted && + createPortal( + , + document.body, + )} +
+ ); +} diff --git a/apps/landing/src/components/site/product-tabs.tsx b/apps/landing/src/components/site/product-tabs.tsx new file mode 100644 index 0000000..1609fcd --- /dev/null +++ b/apps/landing/src/components/site/product-tabs.tsx @@ -0,0 +1,163 @@ +import * as React from "react"; +import { cx } from "@/lib/cx"; +import { DOT, FILL, INK, STROKE } from "@/lib/tone"; +import { PEER_CARDS, ROOMS, TABS, TOML, TOPO, type Tone } from "@/lib/home-data"; + +/** "Inside the forwarder": four views of one deployment, behind a vertical tab list. Demo data. */ + +const STATUS_TONE: Record<(typeof ROOMS)[number]["s"], Tone> = { Healthy: "ok", Watch: "warn", Degraded: "bad" }; +const PILL: Record = { + ok: "bg-ok-tint text-ok", + warn: "bg-warn-tint text-warn", + bad: "bg-danger-tint text-danger", + accent: "bg-accent-tint text-accent-text", + idle: "bg-idle-tint text-idle", +}; +const lossTone = (l: string): Tone => (parseFloat(l) > 2 ? "bad" : parseFloat(l) > 1 ? "warn" : "ok"); + +function Rooms() { + const cols = "grid grid-cols-[2fr_1fr_1fr_1fr_1fr] items-center px-6 py-4"; + return ( +
+ + + + {["room", "peers", "loss", "rtt", "status"].map((h) => ( + + ))} + + + + {ROOMS.map((r) => ( + + + + + + + + ))} + +
{h}
{r.n}{r.p}{r.l}{r.r} + {r.s} +
+
+ ); +} + +function Peers() { + return ( +
+ {PEER_CARDS.map((p) => ( +
+
+ {p.n[0]} + {p.n} + + +
+
+ loss {p.l} + rtt {p.r} + jitter {p.j} + {p.b} +
+
+ ))} +
+ ); +} + +function Topology() { + return ( + + {TOPO.map((t) => + t.relay ? ( + + ) : ( + + ), + )} + {TOPO.map((t) => ( + + + + + {t.id} + + + ))} + + sfu-core + + turn relay + + ); +} + +function Config() { + return ( +
+ {TOML.map((l, i) => ( +
+ {l || " "} +
+ ))} +
+ ); +} + +const PANELS = [Rooms, Peers, Topology, Config]; + +export function ProductTabs() { + const [tab, setTab] = React.useState(0); + const refs = React.useRef<(HTMLButtonElement | null)[]>([]); + const Panel = PANELS[tab]; + + const onKey = (e: React.KeyboardEvent) => { + const step = { ArrowDown: 1, ArrowRight: 1, ArrowUp: -1, ArrowLeft: -1 }[e.key]; + if (!step) return; + e.preventDefault(); + const next = (tab + step + TABS.length) % TABS.length; + setTab(next); + refs.current[next]?.focus(); + }; + + return ( +
+
+ {TABS.map((t, i) => { + const on = tab === i; + return ( + + ); + })} +
+
+
+ +
+
+
+ ); +} diff --git a/apps/landing/src/components/site/room-topology.tsx b/apps/landing/src/components/site/room-topology.tsx deleted file mode 100644 index fdf3d81..0000000 --- a/apps/landing/src/components/site/room-topology.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import * as React from "react"; -import { cx } from "@/lib/cx"; -import { PEERS, STATUS_SOLID, STATUS_TEXT, severity, statusOf, useRoom } from "@/lib/peer-sim"; - -/* The canvas is drawn in a 1000×520 coordinate space. The SVG scales through its viewBox; - the HTML nodes sit on the same grid through percentages, so both stay aligned. */ -const W = 1000; -const H = 520; -const SFU = { x: 500, y: 260 }; -const TURN = { x: 320, y: 150 }; -const pct = (x: number, y: number) => ({ left: `${(x / W) * 100}%`, top: `${(y / H) * 100}%` }); - -/** Edge width encodes the peer's nominal bitrate. */ -const EDGE_WIDTH: Record = { - a3f91c02: 5.6, c27ad930: 4.1, "7e01d4aa": 5, "5b9c3e10": 2.7, "91da0f67": 6, -}; - -export function RoomTopology() { - const room = useRoom(); - const [selected, setSelected] = React.useState("ff104b2c"); - const byId = Object.fromEntries(room.peers.map((p) => [p.id, p])); - - const base = PEERS.find((b) => b.id === selected) ?? PEERS[0]; - const sp = byId[base.id]; - const sst = statusOf(sp); - const relay = base.ice === "relay"; - const details = [ - { k: "path", v: relay ? "TURN relay" : "direct", c: relay ? STATUS_TEXT.bad : "var(--text-strong)" }, - { k: "ice_candidate_type", v: base.ice, c: "var(--text-strong)" }, - { k: "rtt_ms", v: Math.round(sp.rtt), c: severity(sp.rtt, 150, 200) }, - { k: "packet_loss_ratio", v: (sp.loss / 100).toFixed(3), c: severity(sp.loss, 1, 2) }, - { k: "jitter_ms", v: Math.round(sp.jit), c: severity(sp.jit, 20, 30) }, - { k: "bitrate_kbps", v: Math.round(sp.br).toLocaleString("en-US"), c: "var(--text-strong)" }, - { k: "score", v: sp.score, c: STATUS_TEXT[sst] }, - ]; - const note = relay - ? "Fell back to a TURN relay after ICE failed on the direct path. Every packet takes the long way." - : sst === "ok" - ? "Direct path, within every threshold." - : "Direct path, drifting toward a threshold."; - - return ( -
-
-
- - {PEERS.filter((b) => b.ice !== "relay").map((b) => ( - - ))} - {PEERS.filter((b) => b.ice === "relay").map((b) => ( - - ))} - {/* Travelling pulses: what the SFU is forwarding right now. Their speed follows the - peer's RTT. Rendered only once live, so reduced motion gets a still graph. */} - {room.live && - PEERS.flatMap((b) => { - const dur = 0.9 + b.rtt / 110; - return [0, 0.5].map((o) => ( - - - - )); - })} - - -
- sfu-core - eu-west-3 -
- -
- turn · relay -
- - {PEERS.map((b) => { - const p = byId[b.id]; - const st = statusOf(p); - const isSel = b.id === selected; - return ( - - ); - })} -
-
- - -
- ); -} diff --git a/apps/landing/src/components/site/start-terminal.tsx b/apps/landing/src/components/site/start-terminal.tsx index faa253d..152aaa5 100644 --- a/apps/landing/src/components/site/start-terminal.tsx +++ b/apps/landing/src/components/site/start-terminal.tsx @@ -5,49 +5,45 @@ import { CopyButton } from "./copy"; const KEYS = Object.keys(START) as StartKey[]; -/** The closing terminal: three ways to start, one copy button for the active one. */ +/** Quickstart terminal: docker or cargo, one copy button for the active one. Always dark. */ export function StartTerminal() { const [active, setActive] = React.useState("docker"); const lines = START[active].lines; - const copyText = lines.filter((l) => !l.dim).map((l) => l.href ?? l.v).join("\n"); + const copyText = lines.filter((l) => !l.dim).map((l) => l.v).join("\n"); return ( -
-
- {KEYS.map((k) => { - const on = active === k; - return ( - - ); - })} - - -
-
- {lines.map((l, i) => ( -
- {l.p} - {l.href ? ( - - {l.v} - - ) : ( - {l.v} - )} -
- ))} +
+
+
+ {KEYS.map((k) => { + const on = active === k; + return ( + + ); + })} + + +
+
+ {lines.map((l, i) => ( +
+ {l.p} + {l.v} +
+ ))} +
); diff --git a/apps/landing/src/lib/highlight.ts b/apps/landing/src/lib/highlight.ts deleted file mode 100644 index 99be4c9..0000000 --- a/apps/landing/src/lib/highlight.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { CodeLang } from "@/lib/home-data"; - -/** - * A deliberately small tokenizer — enough to colour the four snippet languages on the home page - * without pulling a highlighter into the bundle. Colours are design-system CSS variables. - */ -export type Token = { v: string; c: string }; - -const C = { - key: "var(--indigo-400)", - str: "var(--coral-400)", - num: "var(--green-400)", - kw: "var(--amber-400)", - dim: "var(--n-500)", - txt: "var(--n-200)", - sec: "var(--accent-text)", -}; - -const GENERIC = - /("(?:[^"\\]|\\.)*")(\s*:)?|(\s#.*$|\/\/.*$)|\b(\d+(?:\.\d+)?)\b|\b(import|from|const|await|new|true|false)\b|([{}[\](),:;=])/g; - -function generic(s: string): Token[] { - const out: Token[] = []; - let i = 0; - let m: RegExpExecArray | null; - GENERIC.lastIndex = 0; - while ((m = GENERIC.exec(s))) { - if (m.index > i) out.push({ v: s.slice(i, m.index), c: C.txt }); - if (m[1]) { - out.push({ v: m[1], c: m[2] ? C.key : C.str }); - if (m[2]) out.push({ v: m[2], c: C.dim }); - } else if (m[3]) out.push({ v: m[3], c: C.dim }); - else if (m[4]) out.push({ v: m[4], c: C.num }); - else if (m[5]) out.push({ v: m[5], c: C.kw }); - else out.push({ v: m[6], c: C.dim }); - i = GENERIC.lastIndex; - } - if (i < s.length) out.push({ v: s.slice(i), c: C.txt }); - return out; -} - -export function tokenize(line: string, lang: CodeLang): Token[] { - if (!line.trim()) return [{ v: " ", c: C.txt }]; - if (/^\s*(#|\/\/)/.test(line)) return [{ v: line, c: C.dim }]; - if (lang === "toml") { - if (/^\s*\[.*\]\s*$/.test(line)) return [{ v: line, c: C.sec }]; - const m = line.match(/^(\s*)([\w.-]+)(\s*=\s*)(.*)$/); - if (m) return [{ v: m[1], c: C.txt }, { v: m[2], c: C.key }, { v: m[3], c: C.dim }, ...generic(m[4])]; - } - if (lang === "prom") { - const m = line.match(/^([a-z_]+)(\{[^}]*\})?(\s+)(.*)$/); - if (m) return [{ v: m[1], c: C.key }, { v: m[2] ?? "", c: C.str }, { v: m[3], c: C.txt }, { v: m[4], c: C.num }]; - } - return generic(line); -} diff --git a/apps/landing/src/lib/home-data.ts b/apps/landing/src/lib/home-data.ts index f29ece6..d543d9b 100644 --- a/apps/landing/src/lib/home-data.ts +++ b/apps/landing/src/lib/home-data.ts @@ -1,193 +1,157 @@ /** - * Content of the home page (design: "Lumyx Home", Claude Design). + * Content of the home page (design: "Lumyx Home v3", Claude Design). * Every "works today" claim mirrors the Project status table of the sfu-core README — - * keep the two in step. + * keep the two in step. Room, peer and incident figures are demo data and are labelled as such. */ import { DOCS_URL, REPO } from "./site-data"; export const DOCKER_RUN = "docker run -p 3000:3000 ghcr.io/lumyx-dev/sfu-core:latest"; +export const QUICKSTART = `${DOCS_URL}/quickstart`; export const NAV = [ + { href: "/#top", label: "Overview" }, { href: "/#product", label: "Product" }, - { href: "/#topology", label: "Observability" }, + { href: "/#status", label: "Status" }, + { href: "/#start", label: "Quickstart" }, + { href: "/#faq", label: "FAQ" }, { href: DOCS_URL, label: "Docs" }, - { href: REPO, label: "GitHub" }, ]; -export const METRIC_NAMES = [ - "packet_loss_ratio", "rtt_ms", "jitter_ms", "nack_ratio", "freeze_ratio", "bitrate_kbps", - "forwarded_bytes_total", "ice_candidate_type", "jitter_buffer_delay_ms", "pli_count", - "srtp_decrypt_errors", "rooms_active", +export const STACK = ["Rust", "WebRTC", "RTCP", "WebSocket", "Docker", "MIT"]; + +/** The scroll-lit statement. `true` marks a highlighted phrase; plain runs are split per word. */ +export const STATEMENT: [string, boolean][] = [ + ["Lumyx forwards", false], + ["every packet,", true], + ["reads", false], + ["every RTCP report,", true], + ["and tells", false], + ["you which peer broke", true], + ["and why. No exporter, no Grafana, no guessing.", false], ]; -export type Pain = { - n: string; +export type Tone = "ok" | "warn" | "bad" | "accent" | "idle"; + +export type Pillar = { title: string; body: string; - src: string; - kv: { k: string; v: string; faint?: boolean }[]; + planned: boolean; + ui: { t: string; v: string; tone: Tone; flagged?: boolean }[]; }; -export const PAINS: Pain[] = [ +/** Detect and explain need the RTCP metrics collector, which the README lists as planned. */ +export const PILLARS: Pillar[] = [ { - n: "01", - title: "Self-hosting shouldn’t mean three services and a Redis.", - body: "Signaling, forwarding and the dashboard run in one process. One command starts it; there is nothing else to deploy or keep in sync.", - src: "docker run", - kv: [ - { k: "processes", v: "1" }, - { k: "external dependencies", v: "none" }, - { k: "config to start", v: "0 lines" }, - { k: "image", v: "ghcr.io/lumyx-dev/sfu-core" }, - { k: "license", v: "MIT" }, + title: "Detect", + body: "Freezes, loss spikes and TURN fallbacks show up the moment the forwarder sees them, before a user writes in.", + planned: true, + ui: [ + { t: "Amara", v: "frozen", tone: "bad", flagged: true }, + { t: "Kenji", v: "live", tone: "ok" }, + { t: "Noor", v: "live", tone: "ok" }, ], }, { - n: "02", - title: "Stop bolting Grafana onto your SFU.", - body: "The forwarder already sees every RTCP report. Lumyx keeps the six numbers that explain a bad call, per peer and per room, and pages Slack or PagerDuty when one crosses its threshold.", - src: "per peer · per room · 500 ms", - kv: [ - { k: "jitter_ms", v: "> 30" }, - { k: "packet_loss_ratio", v: "> 0.02" }, - { k: "rtt_ms", v: "> 200" }, - { k: "nack_ratio", v: "> 0.05" }, - { k: "freeze_ratio", v: "> 0.01" }, - { k: "bitrate_kbps", v: "< 100" }, + title: "Explain", + body: "Six numbers per peer, compared to your thresholds, so the cause is written next to the symptom.", + planned: true, + ui: [ + { t: "packet loss", v: "7.9% / 2%", tone: "bad" }, + { t: "jitter", v: "44 ms / 30", tone: "bad" }, + { t: "round trip", v: "212 / 250", tone: "ok" }, ], }, { - n: "03", - title: "Written in Rust, not Go with CGO.", - body: "The hot path is Rust end to end. No garbage collector pausing forwarding mid-frame, no FFI boundary to cross for SRTP.", - src: "media path", - kv: [ - { k: "media path", v: "Rust" }, - { k: "GC pauses", v: "none" }, - { k: "CGO / FFI boundary", v: "none" }, - { k: "measured throughput", v: "benchmark pending", faint: true }, + title: "Recover", + body: "Request a keyframe, drop a simulcast layer or fall back to audio, and log what it did.", + planned: true, + ui: [ + { t: "PLI sent", v: "14:06:43", tone: "accent" }, + { t: "Layer 720p → 360p", v: "planned", tone: "idle" }, + { t: "Audio only", v: "planned", tone: "idle" }, ], }, ]; -export const TOPO_LEGEND = [ - { kind: "line", label: "Direct path" }, - { kind: "dashed", label: "TURN relay" }, - { kind: "warn", label: "Approaching" }, - { kind: "bad", label: "Breached" }, +export const TABS = [ + { label: "Rooms", desc: "Every live room and its worst peer" }, + { label: "Peers", desc: "Six numbers per participant" }, + { label: "Topology", desc: "The path each packet takes" }, + { label: "Config", desc: "Thresholds in one TOML file" }, ] as const; -export type CodeLang = "toml" | "ts" | "prom" | "json"; +export const ROOMS: { n: string; p: number; l: string; r: string; s: "Healthy" | "Watch" | "Degraded" }[] = [ + { n: "webinar-us", p: 6, l: "7.9%", r: "284 ms", s: "Degraded" }, + { n: "standup-eu", p: 4, l: "0.4%", r: "61 ms", s: "Healthy" }, + { n: "support-call-1187", p: 2, l: "0.1%", r: "38 ms", s: "Healthy" }, + { n: "demo-room", p: 9, l: "1.6%", r: "173 ms", s: "Watch" }, + { n: "design-review", p: 3, l: "0.3%", r: "52 ms", s: "Healthy" }, + { n: "all-hands", p: 14, l: "0.7%", r: "88 ms", s: "Healthy" }, +]; -export type Snippet = { - label: string; - lang: CodeLang; - /** 1-based, inclusive line range banded with the accent tint. */ - hl: [number, number]; - note: string; - lines: string[]; -}; +export const PEER_CARDS: { n: string; l: string; r: string; j: string; b: string; tone: Tone }[] = [ + { n: "Amara", l: "7.9%", r: "284 ms", j: "44 ms", b: "0.2 Mbps", tone: "bad" }, + { n: "Léo", l: "0.6%", r: "71 ms", j: "9 ms", b: "1.6 Mbps", tone: "ok" }, + { n: "Noor", l: "0.3%", r: "52 ms", j: "6 ms", b: "2.1 Mbps", tone: "ok" }, + { n: "Kenji", l: "1.4%", r: "118 ms", j: "41 ms", b: "0.9 Mbps", tone: "warn" }, + { n: "Inès", l: "0.1%", r: "44 ms", j: "5 ms", b: "2.6 Mbps", tone: "ok" }, + { n: "Ravi", l: "0.2%", r: "38 ms", j: "4 ms", b: "2.4 Mbps", tone: "ok" }, +]; -export const SNIPPETS: Record<"toml" | "ts" | "prom" | "json", Snippet> = { - toml: { - label: "lumyx.toml", - lang: "toml", - hl: [7, 12], - note: "Draft config shape — today the SFU reads environment variables. Thresholds, alert destinations and remediation are on the roadmap.", - lines: [ - "# lumyx.toml — read at startup", - "[server]", - 'listen = "0.0.0.0:3000"', - 'region = "eu-west-3"', - "", - "[thresholds]", - "packet_loss_ratio = 0.02", - "rtt_ms = 200", - "jitter_ms = 30", - "nack_ratio = 0.05", - "freeze_ratio = 0.01", - 'debounce = "30s"', - "", - "[alerts.slack]", - 'webhook = "env:SLACK_WEBHOOK_URL"', - 'min_severity = "warning"', - "", - "[alerts.pagerduty]", - 'routing_key = "env:PD_ROUTING_KEY"', - 'min_severity = "critical"', - "", - "[remediation]", - "audio_only_fallback = true", - "simulcast_drop_layer = true", - ], - }, - ts: { - label: "signaling.ts", - lang: "ts", - hl: [5, 5], - note: "WebSocket signaling works today. Plain JSON envelopes and RTCPeerConnection — no client SDK to install.", - lines: [ - "// No SDK: plain JSON over a WebSocket.", - 'const ws = new WebSocket("wss://sfu.example.com/ws")', - "const pc = new RTCPeerConnection()", - "", - 'ws.send(JSON.stringify({ type: "join", room_id: "webinar-us", peer_id: "a3f91c02" }))', - "", - "const offer = await pc.createOffer()", - "await pc.setLocalDescription(offer)", - 'ws.send(JSON.stringify({ type: "sfu_offer", sdp: offer.sdp }))', - ], +export const TOPO: { id: string; x: number; y: number; w: number; relay?: boolean; tone: Tone; labelBelow: boolean }[] = [ + { id: "ff104b2c", x: 110, y: 80, w: 2, relay: true, tone: "bad", labelBelow: true }, + { id: "a3f91c02", x: 120, y: 330, w: 6, tone: "ok", labelBelow: false }, + { id: "c27ad930", x: 690, y: 80, w: 4, tone: "ok", labelBelow: true }, + { id: "7e01d4aa", x: 720, y: 240, w: 5, tone: "ok", labelBelow: true }, + { id: "5b9c3e10", x: 610, y: 370, w: 3, tone: "warn", labelBelow: false }, + { id: "91da0f67", x: 330, y: 380, w: 6, tone: "ok", labelBelow: false }, +]; + +/** Draft config shape — today the SFU reads environment variables. */ +export const TOML = [ + "# lumyx.toml", + "[server]", + 'listen = "0.0.0.0:3000"', + 'region = "eu-west-3"', + "", + "[thresholds]", + "packet_loss_ratio = 0.02", + "rtt_ms = 200", + "jitter_ms = 30", + "freeze_ratio = 0.01", + 'debounce = "30s"', +]; + +/** `true` = ships in the current alpha, `false` = planned. Mirrors the README. */ +export const CHECKS: { t: string; items: [string, boolean][] }[] = [ + { + t: "Media", + items: [["Selective forwarding", true], ["ICE, DTLS, SRTP", true], ["RTP forwarding", true], ["Simulcast layers", false], ["TURN fallback", false]], }, - prom: { - label: "GET /metrics", - lang: "prom", - hl: [3, 5], - note: "Prometheus exposition format. Planned — the dashboard reads the same series.", - lines: [ - "# HELP lumyx_peer_rtt_ms Round-trip time per peer", - "# TYPE lumyx_peer_rtt_ms gauge", - 'lumyx_peer_rtt_ms{room="webinar-us",peer="ff104b2c"} 284', - 'lumyx_peer_packet_loss_ratio{room="webinar-us",peer="ff104b2c"} 0.079', - 'lumyx_peer_jitter_ms{room="webinar-us",peer="ff104b2c"} 42', - 'lumyx_room_forwarded_bytes_total{room="webinar-us"} 1384920113', - ], + { + t: "Observability", + items: [["RTCP metrics collector", false], ["Six metrics per peer", false], ["Room topology", false], ["Prometheus endpoint", false], ["Session replay", false]], }, - json: { - label: "webhook.json", - lang: "json", - hl: [5, 8], - note: "Payload POSTed to your webhook when a threshold is crossed. Draft shape.", - lines: [ - "{", - ' "event": "threshold.breached",', - ' "room": "webinar-us",', - ' "peer": "ff104b2c",', - ' "metric": "packet_loss_ratio",', - ' "value": 0.079,', - ' "threshold": 0.02,', - ' "duration_s": 192,', - ' "remediation": {', - ' "action": "audio_only_fallback",', - ' "applied_at": "14:06:41Z"', - " }", - "}", - ], + { + t: "Operations", + items: [["Single process", true], ["Docker image", true], ["WebSocket signaling", true], ["Room manager", true], ["Alerts to Slack and PagerDuty", false]], }, -}; - -export type SnippetKey = keyof typeof SNIPPETS; +]; -export const STEPS: { n: string; title: string; body: string; tab: SnippetKey; hl: [number, number]; planned?: boolean }[] = [ - { n: "01", title: "Set thresholds", body: "Six numbers per environment. Crossing one for longer than the debounce opens an alert.", tab: "toml", hl: [6, 12] }, - { n: "02", title: "Pick destinations", body: "Slack for warnings, PagerDuty for critical, a webhook for anything else.", tab: "toml", hl: [14, 20], planned: true }, - { n: "03", title: "Let it remediate", body: "Force audio-only for a degrading peer, or drop its top simulcast layer. The alert says it did.", tab: "json", hl: [9, 12], planned: true }, +export const FACTS = [ + { v: "Rust", k: "end to end in the media path" }, + { v: "0", k: "garbage collector pauses" }, + { v: "1", k: "process to deploy" }, + { v: "MIT", k: "licensed, no account needed" }, ]; -export type StartLine = { p: string; v: string; dim?: boolean; href?: string }; +export const QS_STEPS = ["Run the container", "Open a WebSocket and join a room", "Forward media between peers"]; + +export type StartLine = { p: string; v: string; dim?: boolean }; export const START: Record<"docker" | "cargo", { label: string; lines: StartLine[] }> = { docker: { - label: "docker run", + label: "docker", lines: [ { p: "$", v: DOCKER_RUN }, { p: "#", v: "then open http://localhost:3000", dim: true }, @@ -206,37 +170,32 @@ export const START: Record<"docker" | "cargo", { label: string; lines: StartLine export type StartKey = keyof typeof START; -export const NOS = [ - { t: "No GC pauses", d: "Rust in the media path." }, - { t: "No exporter to write", d: "Metrics come from the forwarder." }, - { t: "No Redis", d: "Room state lives in the process." }, - { t: "No SDK to install", d: "Plain JSON over a WebSocket." }, -]; - -/** `true` = ships in the current alpha, `false` = planned. */ -export const FEATURE_GROUPS: { name: string; items: [string, boolean][] }[] = [ - { name: "Media", items: [["Selective forwarding", true], ["ICE", true], ["DTLS", true], ["SRTP", true], ["RTP forwarding", true], ["Simulcast layers", false], ["TURN fallback", false]] }, - { name: "Observability", items: [["RTCP metrics collector", false], ["Six metrics per peer", false], ["Per-room rollups", false], ["Room topology", false], ["Prometheus /metrics", false], ["Session replay", false]] }, - { name: "Alerting", items: [["Thresholds per environment", false], ["Debounce", false], ["Slack", false], ["PagerDuty", false], ["Webhooks", false], ["Audio-only fallback", false]] }, - { name: "Operations", items: [["Single process", true], ["Docker image", true], ["WebSocket signaling", true], ["Room manager", true]] }, -]; - -export const WORKS = [ - { t: "ICE, DTLS, SRTP", d: "end to end" }, - { t: "RTP forwarding", d: "selective" }, - { t: "Room manager", d: "join · leave · tracks" }, - { t: "WebSocket signaling", d: "offer · answer · ice" }, - { t: "Test suite", d: "147 tests" }, - { t: "Docker image", d: "sfu-core:latest" }, -]; - -export const PLANNED = [ - { t: "Quality metrics", d: "jitter · loss · RTT · NACK" }, - { t: "Thresholds and alerting", d: "Slack · PagerDuty · webhook" }, - { t: "Automatic remediation", d: "audio-only · layer drop" }, - { t: "Built-in dashboard", d: "topology · replay" }, - { t: "Simulcast", d: "3 layers" }, - { t: "Published benchmark", d: "pending" }, +export const FAQ: { q: string; a: string }[] = [ + { + q: "Is Lumyx ready for production?", + a: "Not yet. It is an alpha: ICE, DTLS, SRTP and selective RTP forwarding work end to end. Quality metrics, alerting, the dashboard and simulcast are on the roadmap.", + }, + { + q: "Do I need an SDK?", + a: "No. Clients speak plain JSON over a WebSocket and use the browser’s RTCPeerConnection. There is nothing to install.", + }, + { + q: "How is it different from LiveKit?", + a: "Both forward media. Lumyx is built to keep per peer call quality inside the forwarder, so loss, jitter and round trip time come without an exporter and a Grafana stack.", + }, + { + q: "Which metrics will it collect?", + a: "Packet loss ratio, jitter, round trip time, NACK ratio, freeze ratio and bitrate, per peer and per room, every 500 ms. The collector is planned.", + }, + { + q: "Where does call data go?", + a: "Nowhere. Lumyx runs on your server and keeps room state in process. Nothing is sent to us.", + }, + { q: "What does it cost?", a: "Nothing. The code is MIT licensed and runs on your own server." }, + { + q: "How can I help?", + a: "Run it against your own calls and tell us what broke or what is missing. Issues and pull requests are welcome.", + }, ]; /** Every entry resolves to a page that exists. */ @@ -244,15 +203,15 @@ export const FOOTER_COLS = [ { t: "Product", links: [ - { label: "Observability", href: "/#product" }, - { label: "Room topology", href: "/#topology" }, + { label: "Overview", href: "/#top" }, { label: "Project status", href: "/#status" }, + { label: "FAQ", href: "/#faq" }, ], }, { t: "Developers", links: [ - { label: "Quickstart", href: `${DOCS_URL}/quickstart` }, + { label: "Quickstart", href: QUICKSTART }, { label: "Docs", href: DOCS_URL }, { label: "Metrics reference", href: `${DOCS_URL}/metrics-reference` }, { label: "Configuration", href: `${DOCS_URL}/config` }, diff --git a/apps/landing/src/lib/peer-sim.ts b/apps/landing/src/lib/peer-sim.ts deleted file mode 100644 index d25290c..0000000 --- a/apps/landing/src/lib/peer-sim.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { useSyncExternalStore } from "react"; - -/** - * Simulated room shared by the hero console and the topology section. Both islands read the - * same store, so a peer degrading in the table degrades on the graph at the same tick. - * The metric names and thresholds are real; the sampled values are invented (demo data). - */ - -export const HISTORY = 60; -export const TICK_MS = 500; - -export type Ice = "host" | "srflx" | "relay"; - -type BasePeer = { - id: string; - /** Position on the 1000×520 topology canvas. */ - x: number; - y: number; - /** Nominal bitrate, kbps. */ - br: number; - ice: Ice; - /** Edge from the SFU (500,260) to the peer, in canvas coordinates. */ - path: string; - /** How much the peer drifts (0 = steady), and its phase. */ - amp: number; - ph: number; - rtt: number; - loss: number; - jit: number; -}; - -export const PEERS: BasePeer[] = [ - { id: "ff104b2c", x: 150, y: 110, br: 380, ice: "relay", path: "M500 260 L320 150 L150 110", amp: 1, ph: 0, rtt: 284, loss: 7.9, jit: 42 }, - { id: "a3f91c02", x: 180, y: 410, br: 2400, ice: "host", path: "M500 260 L180 410", amp: 0, ph: 0, rtt: 38, loss: 0.2, jit: 4 }, - { id: "c27ad930", x: 820, y: 120, br: 1600, ice: "srflx", path: "M500 260 L820 120", amp: 0.7, ph: Math.PI, rtt: 71, loss: 0.6, jit: 9 }, - { id: "7e01d4aa", x: 860, y: 300, br: 2100, ice: "host", path: "M500 260 L860 300", amp: 0, ph: 0, rtt: 52, loss: 0.3, jit: 6 }, - { id: "5b9c3e10", x: 720, y: 450, br: 900, ice: "srflx", path: "M500 260 L720 450", amp: 0.45, ph: Math.PI / 2, rtt: 118, loss: 1.4, jit: 18 }, - { id: "91da0f67", x: 380, y: 460, br: 2600, ice: "host", path: "M500 260 L380 460", amp: 0, ph: 0, rtt: 44, loss: 0.1, jit: 5 }, -]; - -export type PeerSample = { id: string; rtt: number; loss: number; jit: number; br: number; score: number }; - -const noise = (v: number, k: number) => v * (1 + (Math.random() - 0.5) * k); - -function sample(t: number, still: boolean): PeerSample[] { - const n = still ? 0 : 1; - return PEERS.map((b) => { - let rtt: number, loss: number, jit: number; - if (b.ice === "relay") { - const s = still ? 1 : (Math.sin(t / 12) + 1) / 2; - rtt = 130 + 160 * s; - loss = 0.8 + 7.4 * s; - jit = 14 + 30 * s; - } else { - const bad = b.amp && !still ? (b.amp * (Math.sin(t / 12 + b.ph) + 1)) / 2 : 0; - rtt = b.rtt + 200 * bad; - loss = b.loss + 5 * bad; - jit = b.jit + 26 * bad; - } - const r = noise(rtt, 0.08 * n); - const l = Math.max(0.05, noise(loss, 0.18 * n)); - const j = noise(jit, 0.15 * n); - const br = noise(b.br * (1 - Math.min(0.5, l / 20)), 0.06 * n); - const score = Math.max(4, Math.min(99, Math.round(100 - l * 6 - Math.max(0, r - 80) / 4 - Math.max(0, j - 10) / 1.5))); - return { id: b.id, rtt: r, loss: l, jit: j, br, score }; - }); -} - -const avg = (s: PeerSample[], k: "jit" | "loss") => s.reduce((a, p) => a + p[k], 0) / s.length; - -export type RoomSnapshot = { - t: number; - peers: PeerSample[]; - /** Room-average series, oldest first. */ - jit: number[]; - loss: number[]; - /** False on the server, before mount, and under prefers-reduced-motion. */ - live: boolean; -}; - -function history(still: boolean): Pick { - const jit: number[] = []; - const loss: number[] = []; - for (let i = -HISTORY; i < 0; i++) { - const s = sample(i, still); - jit.push(avg(s, "jit")); - loss.push(avg(s, "loss")); - } - return { jit, loss }; -} - -/** Deterministic: identical on the server and at hydration. */ -const STILL: RoomSnapshot = { t: 0, peers: sample(0, true), ...history(true), live: false }; - -let snapshot = STILL; -const listeners = new Set<() => void>(); -let timer: ReturnType | undefined; - -const emit = () => listeners.forEach((l) => l()); - -function tick() { - const t = snapshot.t + 1; - const peers = sample(t, false); - snapshot = { - t, - peers, - jit: [...snapshot.jit.slice(1), avg(peers, "jit")], - loss: [...snapshot.loss.slice(1), avg(peers, "loss")], - live: true, - }; - emit(); -} - -function start() { - if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return; - if (!snapshot.live) { - snapshot = { t: 0, peers: sample(0, false), ...history(false), live: true }; - emit(); - } - timer = setInterval(tick, TICK_MS); -} - -function subscribe(listener: () => void) { - listeners.add(listener); - if (listeners.size === 1) start(); - return () => { - listeners.delete(listener); - if (listeners.size === 0) { - clearInterval(timer); - timer = undefined; - } - }; -} - -export function useRoom(): RoomSnapshot { - return useSyncExternalStore(subscribe, () => snapshot, () => STILL); -} - -/* ── Presentation helpers ─────────────────────────────────────────────── */ - -export type Status = "ok" | "warn" | "bad"; - -export const statusOf = (p: PeerSample): Status => (p.score < 60 ? "bad" : p.score < 80 ? "warn" : "ok"); - -export const STATUS_TEXT: Record = { ok: "var(--ok)", warn: "var(--warn)", bad: "var(--danger)" }; -export const STATUS_SOLID: Record = { ok: "var(--ok-solid)", warn: "var(--warn-solid)", bad: "var(--danger-solid)" }; - -/** Colour of a value against its warning and breach thresholds. */ -export const severity = (v: number, warn: number, breach: number) => - v > breach ? "var(--danger)" : v > warn ? "var(--warn)" : "var(--text-body)"; - -/** SVG path for a series on a 760×120 chart. */ -export const linePath = (vals: number[], max: number) => - vals - .map((v, i) => `${i ? "L" : "M"}${((i * 760) / (HISTORY - 1)).toFixed(1)} ${(116 - Math.min(1, v / max) * 108).toFixed(1)}`) - .join(" "); diff --git a/apps/landing/src/lib/tone.ts b/apps/landing/src/lib/tone.ts new file mode 100644 index 0000000..81a30cd --- /dev/null +++ b/apps/landing/src/lib/tone.ts @@ -0,0 +1,34 @@ +import type { Tone } from "./home-data"; + +/** Status tone → design-system utilities. One place, so dots, text and pills agree. */ +export const DOT: Record = { + ok: "bg-ok-solid", + warn: "bg-warn-solid", + bad: "bg-danger-solid", + accent: "bg-accent", + idle: "bg-stroke", +}; + +export const INK: Record = { + ok: "text-ok", + warn: "text-warn", + bad: "text-danger", + accent: "text-accent-text", + idle: "text-faint", +}; + +export const STROKE: Record = { + ok: "stroke-ok-solid", + warn: "stroke-warn-solid", + bad: "stroke-danger-solid", + accent: "stroke-accent", + idle: "stroke-stroke", +}; + +export const FILL: Record = { + ok: "fill-ok-solid", + warn: "fill-warn-solid", + bad: "fill-danger-solid", + accent: "fill-accent", + idle: "fill-stroke", +}; diff --git a/apps/landing/src/lib/ui.ts b/apps/landing/src/lib/ui.ts new file mode 100644 index 0000000..cd7bb62 --- /dev/null +++ b/apps/landing/src/lib/ui.ts @@ -0,0 +1,22 @@ +/** + * Shared class strings for the home page (design "Lumyx Home v3"), used by the Astro page and + * its islands alike. Plain strings, no @lumyx/ui import, so islands can use them too (see cx.ts). + */ +const EASE = "transition-all duration-500 ease-[cubic-bezier(0.32,0.72,0,1)]"; + +const BTN_BASE = `inline-flex items-center gap-2 whitespace-nowrap rounded-sm border font-semibold no-underline hover:no-underline hover:-translate-y-0.5 active:scale-[0.98] ${EASE}`; + +export const BTN_DARK = `${BTN_BASE} border-transparent bg-strong text-page hover:bg-body hover:text-page`; +export const BTN_LIGHT = `${BTN_BASE} border-stroke bg-card text-strong hover:border-faint hover:text-strong`; +export const BTN_MD = "px-3 py-2 text-16 leading-6"; +export const BTN_SM = "rounded-xs px-3 py-1 text-14 leading-5"; + +/** Black-to-grey ink, the v3 headline treatment. Follows the theme through the tokens. */ +export const GRAD = "bg-linear-to-r from-strong to-muted bg-clip-text text-transparent"; + +export const H2 = `m-0 text-[30px] leading-none tracking-[-0.04em] text-balance min-[640px]:text-[48px] ${GRAD}`; + +export const LINK = "text-muted no-underline transition-colors duration-500 hover:text-strong hover:no-underline"; + +export const SECTION = "mx-auto w-full max-w-[1200px]"; +export const PAD = "px-4 py-16 min-[640px]:px-6 min-[640px]:py-24"; diff --git a/apps/landing/src/pages/index.astro b/apps/landing/src/pages/index.astro index 94d138e..7b2b889 100644 --- a/apps/landing/src/pages/index.astro +++ b/apps/landing/src/pages/index.astro @@ -1,44 +1,60 @@ --- import Base from '@/layouts/base.astro'; -import { LumyxMark, buttonVariants, cn } from '@lumyx/ui'; +import { LumyxMark, cn } from '@lumyx/ui'; import { ThemeToggle } from '@/components/site/theme-toggle'; import { CopyCommand } from '@/components/site/copy'; -import { HeroConsole } from '@/components/site/hero-console'; -import { RoomTopology } from '@/components/site/room-topology'; -import { ConfigPanel } from '@/components/site/config-panel'; +import { MobileNav } from '@/components/site/mobile-nav'; +import { IncidentWindow } from '@/components/site/incident-window'; +import { ProductTabs } from '@/components/site/product-tabs'; import { StartTerminal } from '@/components/site/start-terminal'; -import { DOCS_URL, REPO, VERSION } from '@/lib/site-data'; +import { REPO, VERSION } from '@/lib/site-data'; import { + CHECKS, DOCKER_RUN, - FEATURE_GROUPS, + FACTS, + FAQ, FOOTER_COLS, - METRIC_NAMES, NAV, - NOS, - PAINS, - PLANNED, - TOPO_LEGEND, - WORKS, + PILLARS, + QS_STEPS, + QUICKSTART, + STACK, + STATEMENT, } from '@/lib/home-data'; +import { DOT, INK } from '@/lib/tone'; +import { BTN_DARK, BTN_LIGHT, BTN_MD, BTN_SM, GRAD, H2, LINK, PAD, SECTION } from '@/lib/ui'; import { organizationJsonLd, softwareApplicationJsonLd } from '@/lib/schema'; /** - * The home page — design "Lumyx Home" (Claude Design). - * Static layout in Astro; the live console, topology, config panel and terminal are islands. - * The two simulated islands share one store (src/lib/peer-sim.ts), so they tick together. + * The home page — design "Lumyx Home v3" (Claude Design). + * Static layout in Astro; the mobile menu, incident window, product tabs and terminal are islands. + * Colours come from the @lumyx/ui tokens, so both themes work. */ -const WRAP = 'mx-auto box-border w-full max-w-[1440px] px-[clamp(20px,5vw,64px)]'; -const SECTION_GAP = 'pt-[clamp(88px,10vw,144px)]'; -const H2 = 'm-0 text-[clamp(30px,3.2vw,42px)] font-semibold leading-[1.08] tracking-[-0.035em] text-strong text-balance'; -const EYEBROW = 'sl-num text-13 text-accent-text'; -const BTN = 'no-underline hover:no-underline active:scale-[0.98]'; -const QUICKSTART = `${DOCS_URL}/quickstart`; +const H1 = `m-0 text-[36px] leading-none tracking-[-0.045em] text-balance min-[640px]:text-[48px] min-[1100px]:text-[60px] ${GRAD}`; +const LEAD = 'm-0 text-[18px] leading-7 text-muted text-pretty'; +const CELL = 'border-hairline min-[960px]:border-r min-[960px]:last:border-r-0 max-[959px]:border-b max-[959px]:last:border-b-0'; + +// The statement is lit word by word on scroll; highlighted phrases stay whole. +type Word = { w: string; key: boolean }; +const WORDS = STATEMENT.flatMap(([t, key]): Word[] => + key ? [{ w: t, key }] : t.split(' ').map((w) => ({ w, key })), +); + +// One deterministic strip of packets; the lost ones are drawn full height in red. +const rnd = (i: number) => { + const x = Math.sin(i * 12.9898) * 43758.5453; + return x - Math.floor(x); +}; +const PACKETS = Array.from({ length: 200 }, (_, i) => { + const lost = rnd(i) > 0.965; + return { lost, h: lost ? 64 : Math.round(20 + rnd(i + 7) * 36) }; +}); ---