diff --git a/app/globals.css b/app/globals.css index b403ebd..61b1c5a 100644 --- a/app/globals.css +++ b/app/globals.css @@ -495,7 +495,7 @@ a.cta-primary:focus-visible { } } -/* --- fleet status panel (§05) ----------------------------------------- +/* --- fleet status panel (§06) ----------------------------------------- Sits on the paper Contact section as an inverted panel. Same "tool panel" vocabulary as .terminal — square corners, dashed border, hard offset shadow — with an ops-dashboard layout instead of a CLI log: a @@ -1281,7 +1281,7 @@ a.cta-primary:focus-visible { } } -/* --- faq: question list + answer card (§04) --------------------------- +/* --- faq: question list + answer card (§05) --------------------------- One
, two columns. Questions (dt) stack on the left; every answer (dd) occupies the whole right column and only the selected one is visible. The selected row's ordinal turns whisper; the same number is ghosted diff --git a/app/llms-full.txt/route.test.ts b/app/llms-full.txt/route.test.ts index 5a8beed..408acca 100644 --- a/app/llms-full.txt/route.test.ts +++ b/app/llms-full.txt/route.test.ts @@ -42,6 +42,7 @@ const HOMEPAGE_SUBSECTIONS = [ "### Intro", "### Side by side", "### How it works", + "### Try it", "### FAQ", "### Contact", ]; diff --git a/app/llms-full.txt/route.ts b/app/llms-full.txt/route.ts index 35e77d7..c36e665 100644 --- a/app/llms-full.txt/route.ts +++ b/app/llms-full.txt/route.ts @@ -13,10 +13,14 @@ import { SITE_DESCRIPTION, STACK, statusBlock, + TRY_HEADLINE, + TRY_LEAD, + TRY_NOTES, } from "@/lib/copy"; import { FAQ_ITEMS } from "@/lib/faq"; import { getLegalDoc, LEGAL_SLUGS, legalUrl } from "@/lib/legal"; import { DOCS_ORIGIN, EMAIL, links, SITE_URL } from "@/lib/links"; +import { MODELS, formatSize, pullCommand } from "@/lib/models"; // The whole origin as one plain-text document, so an agent grounding on deCDN // fetches this instead of stripping HTML from the homepage, the blog index, @@ -149,6 +153,13 @@ const compareTable = [ }), ].join("\n"); +const tryWindows = `On Windows, in PowerShell: \`${pullCommand("b3:", "windows")}\``; + +const tryModels = MODELS.map( + (m) => + `- ${m.name} (${formatSize(m.bytes)}, ${m.license}): \`${pullCommand(m.hash)}\``, +).join("\n"); + const methodSteps = METHOD_STEPS.map( (step) => `#### ${step.n} ${assertHeadingText(step.word, `method ${step.n} word`)}\n\n${step.body}`, @@ -219,6 +230,18 @@ Stack: ${STACK.join(" · ")} ${methodSteps} +### Try it + +${TRY_HEADLINE} + +${TRY_LEAD} + +${tryModels} + +${tryWindows} + +${TRY_NOTES.join(" ")} + ### FAQ ${faq} diff --git a/app/page.tsx b/app/page.tsx index 73be9a6..2915d6e 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -3,6 +3,7 @@ import { Contact } from "@/components/site/Contact"; import { Faq } from "@/components/site/Faq"; import { Hero } from "@/components/site/Hero"; import { Method } from "@/components/site/Method"; +import { Try } from "@/components/site/Try"; import { JsonLd } from "@/lib/jsonld"; import { faqPageNode } from "@/lib/schema"; @@ -16,6 +17,7 @@ export default function Home() { + diff --git a/components/site/Chrome.tsx b/components/site/Chrome.tsx index 37ab18a..4d6a230 100644 --- a/components/site/Chrome.tsx +++ b/components/site/Chrome.tsx @@ -12,13 +12,21 @@ import { import { MobileMenu } from "@/components/site/MobileMenu"; import { resolveActiveSection } from "@/components/site/chrome-active"; -const SECTION_IDS = ["intro", "compare", "method", "faq", "contact"] as const; +const SECTION_IDS = [ + "intro", + "compare", + "method", + "try", + "faq", + "contact", +] as const; // Hash anchors are written `/#section`. Required because this nav also // renders on /blog/*, where a bare `#section` resolves against the // current URL (e.g. /blog/foo/#section) instead of the home page. const NAV = [ { id: "compare", label: "compare" }, { id: "method", label: "method" }, + { id: "try", label: "try it" }, { id: "faq", label: "faq" }, { id: "contact", label: "contact" }, ] as const; diff --git a/components/site/Contact.tsx b/components/site/Contact.tsx index 641e41d..ee62ed6 100644 --- a/components/site/Contact.tsx +++ b/components/site/Contact.tsx @@ -74,7 +74,7 @@ export function Contact() { return ( diff --git a/components/site/Faq.tsx b/components/site/Faq.tsx index 8fd62ef..9fe3631 100644 --- a/components/site/Faq.tsx +++ b/components/site/Faq.tsx @@ -3,9 +3,9 @@ import { FAQ_ITEMS } from "@/lib/faq"; import { Frame } from "@/components/ui/Frame"; import { SectionHeader } from "@/components/ui/SectionHeader"; -// Shared with the row ordinals ("04.1" …) so the numbering can't drift from +// Shared with the row ordinals ("05.1" …) so the numbering can't drift from // the section header. -const SECTION = "04"; +const SECTION = "05"; export function Faq() { return ( diff --git a/components/site/Hero.tsx b/components/site/Hero.tsx index 7589c60..bf6ec9f 100644 --- a/components/site/Hero.tsx +++ b/components/site/Hero.tsx @@ -31,16 +31,11 @@ export function Hero() { {highlightBrand(HERO_LEAD)}

- {/* One primary action; docs and source sit beside it as quieter - text links so the eye lands on the litepaper first. */} + {/* One primary action; the litepaper and source sit beside it as + quieter text links so the eye lands on the try-it section. */}
- - read the litepaper + + try it out → @@ -48,9 +43,11 @@ export function Hero() {
- read the docs + litepaper → diff --git a/components/site/MobileMenu.tsx b/components/site/MobileMenu.tsx index 6c9a732..e52a6bf 100644 --- a/components/site/MobileMenu.tsx +++ b/components/site/MobileMenu.tsx @@ -11,7 +11,7 @@ import { createPortal } from "react-dom"; import { links } from "@/lib/links"; import { HOME_SECTION_ID, scrollToAnchor } from "@/lib/scroll"; -type SectionId = "intro" | "compare" | "method" | "faq" | "contact"; +type SectionId = "intro" | "compare" | "method" | "try" | "faq" | "contact"; type Props = { activeSection: SectionId; @@ -22,6 +22,7 @@ type Props = { const SECTIONS: readonly { id: SectionId; label: string }[] = [ { id: "compare", label: "compare" }, { id: "method", label: "method" }, + { id: "try", label: "try it" }, { id: "faq", label: "faq" }, { id: "contact", label: "contact" }, ] as const; diff --git a/components/site/Try.tsx b/components/site/Try.tsx new file mode 100644 index 0000000..a16646c --- /dev/null +++ b/components/site/Try.tsx @@ -0,0 +1,34 @@ +import { TRY_HEADLINE, TRY_LEAD } from "@/lib/copy"; +import { highlightBrand } from "@/components/ui/brand"; +import { Frame } from "@/components/ui/Frame"; +import { SectionHeader } from "@/components/ui/SectionHeader"; +import { TryIt } from "@/components/site/TryIt"; + +export function Try() { + return ( + + + +
+
+

+ {TRY_HEADLINE} +

+

+ {highlightBrand(TRY_LEAD)} +

+
+ + +
+ + ); +} diff --git a/components/site/TryIt.tsx b/components/site/TryIt.tsx new file mode 100644 index 0000000..46c946d --- /dev/null +++ b/components/site/TryIt.tsx @@ -0,0 +1,192 @@ +"use client"; + +import { useRef, useState, useSyncExternalStore } from "react"; +import { Figure } from "@/components/ui/Figure"; +import { TRY_NOTES } from "@/lib/copy"; +import { + MODELS, + PLATFORMS, + type Platform, + detectPlatform, + formatSize, + pullCommand, +} from "@/lib/models"; + +const COPIED_MS = 2000; + +// The visitor's OS never changes while the page is open, so there is nothing +// to subscribe to. +const subscribeNever = () => () => {}; + +function clientPlatform(): Platform { + const nav = navigator as Navigator & { + userAgentData?: { platform?: string }; + }; + return detectPlatform(nav.userAgentData?.platform || navigator.userAgent); +} + +// The static export renders the POSIX line for everyone; a Windows visitor +// gets PowerShell once the page hydrates. +const serverPlatform = (): Platform => "unix"; + +/** + * The interactive half of the #try section: pick a seeded model, copy the one + * line that pulls it. Unlike HeroTerminal, this panel is real and not + * aria-hidden: the command is the product's actual install-and-download line. + */ +export function TryIt() { + const [id, setId] = useState(MODELS[0]?.id ?? ""); + const detected = useSyncExternalStore( + subscribeNever, + clientPlatform, + serverPlatform, + ); + const [chosen, setPlatform] = useState(null); + const platform = chosen ?? detected; + const [copied, setCopied] = useState(false); + const codeRef = useRef(null); + + const model = MODELS.find((m) => m.id === id) ?? MODELS[0]; + const shell = PLATFORMS.find((p) => p.id === platform) ?? PLATFORMS[0]; + if (!model || !shell) return null; + const command = pullCommand(model.hash, platform); + + function flashCopied() { + setCopied(true); + window.setTimeout(() => setCopied(false), COPIED_MS); + } + + // Selects the command, then tries the legacy copy path. Where even that is + // blocked, the command is left selected for the visitor to copy by hand. + function selectAndCopy() { + const code = codeRef.current; + const selection = window.getSelection(); + if (!code || !selection) return; + const range = document.createRange(); + range.selectNodeContents(code); + selection.removeAllRanges(); + selection.addRange(range); + if (document.execCommand("copy")) flashCopied(); + } + + async function copy() { + try { + await navigator.clipboard.writeText(command); + flashCopied(); + } catch { + selectAndCopy(); + } + } + + return ( +
+ + +
+
+ + + + {shell.shell} + + {PLATFORMS.map((p) => ( + + ))} + +
+ +
+          {shell.prompt} 
+          {command}
+        
+ +
+ + testnet · sponsored · no wallet + + +
+ +
    + {TRY_NOTES.map((note) => ( +
  • + # {note} +
  • + ))} +
+
+
+ ); +} diff --git a/lib/copy.ts b/lib/copy.ts index 270bec7..2536bc4 100644 --- a/lib/copy.ts +++ b/lib/copy.ts @@ -53,6 +53,18 @@ export const HERO_FIGURES = [ { label: "gas overhead", value: "<1%" }, ] as const satisfies readonly FigureCopy[]; +/** The #try section between How it works and the FAQ. */ +export const TRY_HEADLINE = "try it: pull a real model."; + +export const TRY_LEAD = + "pick a model we seeded on the testnet and paste the line into a terminal on macos or linux, or into powershell on windows. it installs deCDN, opens one captcha in your browser, then pulls the model from the network and checks every byte against its blake3 hash. the testnet sponsor pays for the transfer, so there is no wallet, no token, and no sign-up."; + +/** The short notes under the command. */ +export const TRY_NOTES = [ + "files land in the current directory. add -o to change it.", + "interrupted? run the same line again to resume.", +] as const; + export const COMPARE_HEADLINE = [ "information scaled.", "supply didn't.", diff --git a/lib/links.ts b/lib/links.ts index 4b97526..9d140b8 100644 --- a/lib/links.ts +++ b/lib/links.ts @@ -8,6 +8,9 @@ export const links = { litepaper: "/decdn_litepaper.pdf", presskit: "/presskit/decdn-presskit.zip", docs: "https://docs.decdn.org/overview/introduction", + // Served by sponsord; each installs decdn + decdn-sponsored. + installer: "https://up.decdn.org/decdn.sh", + installerPs1: "https://up.decdn.org/decdn.ps1", blog: "/blog/", contact: `mailto:${EMAIL}`, privacy: "/legal/privacy/", diff --git a/lib/models.test.ts b/lib/models.test.ts new file mode 100644 index 0000000..fca8ec8 --- /dev/null +++ b/lib/models.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { + HASH_RE, + MODELS, + detectPlatform, + formatSize, + pullCommand, +} from "@/lib/models"; + +describe("launch catalogue", () => { + it("offers at least one model", () => { + expect(MODELS.length).toBeGreaterThan(0); + }); + + it("carries a well-formed b3 hash for every model", () => { + for (const model of MODELS) { + expect(model.hash, model.id).toMatch(HASH_RE); + } + }); + + it("has unique ids and hashes", () => { + expect(new Set(MODELS.map((m) => m.id)).size).toBe(MODELS.length); + expect(new Set(MODELS.map((m) => m.hash)).size).toBe(MODELS.length); + }); +}); + +describe("pullCommand", () => { + it("installs and pulls by hash in one line", () => { + const hash = `b3:${"ab".repeat(32)}`; + expect(pullCommand(hash)).toBe( + `curl -fsSL https://up.decdn.org/decdn.sh | sh -s -- pull ${hash}`, + ); + }); + + it("installs and pulls from PowerShell on Windows", () => { + const hash = `b3:${"ab".repeat(32)}`; + expect(pullCommand(hash, "windows")).toBe( + `irm https://up.decdn.org/decdn.ps1 | iex; decdn-sponsored pull ${hash}`, + ); + }); +}); + +describe("detectPlatform", () => { + it("picks PowerShell only for Windows", () => { + expect(detectPlatform("Windows")).toBe("windows"); + expect(detectPlatform("Win32")).toBe("windows"); + expect( + detectPlatform("Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/130"), + ).toBe("windows"); + expect(detectPlatform("macOS")).toBe("unix"); + expect(detectPlatform("MacIntel")).toBe("unix"); + expect(detectPlatform("Linux x86_64")).toBe("unix"); + expect(detectPlatform("Darwin")).toBe("unix"); + }); +}); + +describe("formatSize", () => { + it("renders decimal gigabytes to one place", () => { + expect(formatSize(28_995_469_846)).toBe("29.0 GB"); + expect(formatSize(550_000_000)).toBe("0.6 GB"); + }); +}); diff --git a/lib/models.ts b/lib/models.ts new file mode 100644 index 0000000..c256f79 --- /dev/null +++ b/lib/models.ts @@ -0,0 +1,62 @@ +// The seeded launch catalogue, read from public/models.json at build time. +// +// public/models.json is written by the seeding runbook and is the single +// source for which models the site offers: a model appears here only once its +// verified BLAKE3 hash lands in that file. The file also ships as a static +// asset at /models.json for anyone scripting against the catalogue. +// +// The command carries the hash, never the model's name: the hash is what +// `decdn-sponsored` fetches and what every byte is verified against, so the +// line a visitor copies names exactly the bytes they get. + +import catalogue from "@/public/models.json"; +import { links } from "@/lib/links"; + +export type Model = { + id: string; + name: string; + repo: string; + license: string; + /** `b3:` + 64 lowercase hex characters. */ + hash: string; + bytes: number; +}; + +export const MODELS: readonly Model[] = catalogue.models; + +export const HASH_RE = /^b3:[0-9a-f]{64}$/; + +/** The shell a visitor pastes the command into. */ +export type Platform = "unix" | "windows"; + +export const PLATFORMS: readonly { + id: Platform; + label: string; + shell: string; + prompt: string; +}[] = [ + { id: "unix", label: "macos / linux", shell: "terminal", prompt: "$" }, + { id: "windows", label: "windows", shell: "powershell", prompt: "PS>" }, +]; + +/** The one line that installs `decdn` + `decdn-sponsored` and pulls `hash` + * into the current directory: a POSIX shell pipe on macOS and Linux, a + * PowerShell one on Windows. */ +export function pullCommand(hash: string, platform: Platform = "unix"): string { + return platform === "windows" + ? `irm ${links.installerPs1} | iex; decdn-sponsored pull ${hash}` + : `curl -fsSL ${links.installer} | sh -s -- pull ${hash}`; +} + +/** Windows visitors get the PowerShell line; everyone else the POSIX one. */ +export function detectPlatform(platformOrUserAgent: string): Platform { + return /win/i.test(platformOrUserAgent) && + !/darwin/i.test(platformOrUserAgent) + ? "windows" + : "unix"; +} + +/** Decimal gigabytes, one place: 28995469846 → "29.0 GB". */ +export function formatSize(bytes: number): string { + return `${(bytes / 1e9).toFixed(1)} GB`; +}