feat(web): landing page sections and ui primitives

Hero, how-it-works, agent output, get-started, FAQ and footer, over a small set of
primitives — code blocks, copy buttons, section headings. Skip link and scroll-spy
nav are in here rather than deferred, which is the only way they ever get done.
This commit is contained in:
Nayan
2026-08-08 16:08:00 +05:30
parent 7335196fad
commit d216fe3d20
23 changed files with 2759 additions and 0 deletions
@@ -0,0 +1,62 @@
/**
* The proxy card's illustration: airship on one port, your app on the one you
* already had, and two real device frames inside it.
*
* It draws both halves of the claim in one picture. The chrome carries the
* proxy — `localhost:3001` is airship, and the pill beside it names the port it
* is standing in front of. The canvas carries the viewports: two frames at
* visibly different widths, each labelled with the number the app inside it
* would actually report.
*
* The widths are not decorative. 393 is the iPhone 15 logical width and 1280 a
* common desktop breakpoint, which is why the small frame's skeleton stacks and
* the wide one's sits in a row — the same layout responding to its own frame is
* the entire point of the sentence underneath.
*
* Decorative as a whole, so `aria-hidden` on the root: everything it depicts is
* stated in the card's own prose.
*/
export function ProxyFigure() {
return (
<div aria-hidden="true" className="fig fig-proxy">
<div className="fig-window">
<div className="fig-chrome">
<span className="fig-dots">
<i className="fig-dot fig-dot-red" />
<i className="fig-dot fig-dot-yellow" />
<i className="fig-dot fig-dot-green" />
</span>
<span className="fig-url">localhost:3001</span>
<span className="fig-proxy-pill">proxying :3000</span>
</div>
<div className="fig-canvas">
{/* Enough rows to reach the fade. The frames run past the bottom of
the window (see `.fig-window`'s mask in cards.css), so a skeleton
that stopped after three bars would leave the rest of the phone as
blank paper — the stack has to still be going when it dissolves. */}
<div className="fig-frame fig-frame-sm">
<span className="fig-frame-w">393</span>
<div className="fig-frame-body">
<span className="fig-bar fig-bar-full" />
<span className="fig-bar fig-bar-full" />
<span className="fig-bar fig-bar-half" />
<span className="fig-bar fig-bar-block" />
<span className="fig-bar fig-bar-full" />
<span className="fig-bar fig-bar-half" />
</div>
</div>
<div className="fig-frame fig-frame-lg">
<span className="fig-frame-w">1280</span>
<div className="fig-frame-body fig-frame-body-row">
<span className="fig-bar fig-bar-col" />
<span className="fig-bar fig-bar-col" />
<span className="fig-bar fig-bar-col" />
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,62 @@
import { MOCK_DIFF, MOCK_SELECTION } from "#/content/mock-page";
/**
* The source card's illustration: the chain from a click to a diff.
*
* Three beats, top to bottom, because that is the order the tool actually runs
* in — you pick an element, it resolves to a file and a line, the agent edits
* source. The middle chip is the one that carries the claim: without it this
* would just be another "AI writes code" picture.
*
* Every string is read from content/mock-page.ts rather than written here, so
* this figure and the hero animation cannot drift into quoting different files.
* It is the same selection and the same diff the demo above performs — the two
* are meant to be recognisably one story, not two examples of one.
*
* Note the file changes between beat two and beat three, and that is correct
* rather than a mistake: the element lives in `hero-section.tsx`, but the class
* it renders with is defined in `shell.css`, so that is what the agent edits. It
* is a small argument for the whole feature — resolving to source means landing
* on the line that decides the value, not the line that mentions it.
*/
export function SourceFigure() {
return (
<div aria-hidden="true" className="fig fig-source">
<div className="fig-pick">
<span className="fig-pick-badge">{MOCK_SELECTION.tag}</span>
<span className="fig-pick-el">Get started</span>
<i className="fig-handle fig-handle-tl" />
<i className="fig-handle fig-handle-tr" />
<i className="fig-handle fig-handle-bl" />
<i className="fig-handle fig-handle-br" />
</div>
<div className="fig-resolve">
<span className="fig-thread" />
<span className="fig-chip">
{MOCK_SELECTION.sourceFile}
<span className="fig-chip-line">:{MOCK_SELECTION.sourceLine}</span>
</span>
</div>
<div className="fig-diff">
<div className="fig-diff-head">
<span className="fig-diff-file">{MOCK_DIFF.file}</span>
<span className="fig-diff-stat">{MOCK_DIFF.stat}</span>
</div>
<div className="fig-diff-body">
{MOCK_DIFF.lines
.filter((line) => line.kind !== "ctx")
.map((line) => (
<div
className={`fig-diff-line fig-diff-${line.kind}`}
key={line.text}
>
{line.text.trim()}
</div>
))}
</div>
</div>
</div>
);
}
@@ -0,0 +1,49 @@
import { SectionHeading } from "#/components/ui/section-heading";
import {
AGENT_OUTPUT,
AGENT_OUTPUT_SECTION,
type OutputToken,
} from "#/content/agent-output";
const TOKEN_CLASS: Record<OutputToken["kind"], string> = {
dim: "output-dim",
heading: "output-heading",
hint: "output-hint",
new: "output-new",
old: "output-old",
plain: "",
prop: "output-prop",
};
export function AgentOutputSection() {
return (
<section className="section" id="output">
<SectionHeading
desc={AGENT_OUTPUT_SECTION.desc}
title={AGENT_OUTPUT_SECTION.heading}
/>
<div className="output-block">
<div className="output-chrome">{AGENT_OUTPUT_SECTION.chromeLabel}</div>
{/*
One <pre> holding the whole payload rather than a <div> per line: the
block is `white-space: pre` and the column alignment of the `→` is what
makes it read as a payload rather than as prose. Per-line elements would
survive that, but they would also let a future flex/gap change silently
break the alignment.
*/}
<pre className="output-body">
{AGENT_OUTPUT.map((line) => (
<span key={line.id}>
{line.tokens.map((token) => (
<span className={TOKEN_CLASS[token.kind]} key={token.id}>
{token.text}
</span>
))}
{"\n"}
</span>
))}
</pre>
</div>
</section>
);
}
@@ -0,0 +1,33 @@
import { useCallback, useState } from "react";
import { FaqItem } from "#/components/ui/faq-item";
import { SectionHeading } from "#/components/ui/section-heading";
import { FAQ, FAQ_SECTION } from "#/content/faq";
export function FaqSection() {
// One open at a time. `null` rather than an index so "nothing open" is not the
// same value as "the first one".
const [openId, setOpenId] = useState<string | null>(null);
// One handler for every row, rather than one closure per row per render.
const toggle = useCallback((id: string) => {
setOpenId((current) => (current === id ? null : id));
}, []);
return (
<section className="section" id="faq">
<SectionHeading desc={FAQ_SECTION.desc} title={FAQ_SECTION.heading} />
<div className="faq-list">
{FAQ.map((entry) => (
<FaqItem
answer={entry.answer}
id={entry.id}
isOpen={openId === entry.id}
key={entry.id}
onToggle={toggle}
question={entry.question}
/>
))}
</div>
</section>
);
}
@@ -0,0 +1,28 @@
import { CodeBlock } from "#/components/ui/code-block";
import { SectionHeading } from "#/components/ui/section-heading";
import { GET_STARTED, GET_STARTED_SECTION } from "#/content/get-started";
export function GetStartedSection() {
return (
<section className="section" id="get-started">
<SectionHeading
desc={GET_STARTED_SECTION.desc}
title={GET_STARTED_SECTION.heading}
/>
<ol className="install-steps">
{GET_STARTED.map((step) => (
<li className="install-step" key={step.id}>
<h3 className="install-step-title">{step.title}</h3>
<CodeBlock
code={step.code}
copyable={step.copyable}
label={step.title}
/>
{step.note ? <p className="install-note">{step.note}</p> : null}
</li>
))}
</ol>
<p className="install-compat">{GET_STARTED_SECTION.compat}</p>
</section>
);
}
@@ -0,0 +1,44 @@
import { InstallCopy } from "#/components/ui/install-copy";
import { AGENT_MARKS } from "#/content/agent-marks";
import { HERO } from "#/content/hero";
/**
* The hero's words. The animation that goes with them is `HeroStage`, which is
* rendered as a sibling in routes/index.tsx rather than from here — it is
* full-bleed and this section is a centred column, so they cannot share a box.
*/
export function HeroSection() {
return (
<section className="hero" id="overview">
<p className="hero-eyebrow">
{HERO.eyebrow}
{AGENT_MARKS.map((mark) => (
<svg
className="hero-eyebrow-mark"
key={mark.name}
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<title>{mark.name}</title>
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: AGENT_MARKS
is a module-level constant of literal SVG markup with no
interpolated input. Parsing it back into JSX would buy nothing
and would put path data that must stay byte-identical to
assets/local/*.svg at the mercy of the formatter. */}
<g dangerouslySetInnerHTML={{ __html: mark.body }} />
</svg>
))}
</p>
<h1 className="hero-heading">{HERO.heading}</h1>
<p className="hero-sub">{HERO.sub}</p>
<div className="cta-row desktop-only">
<a className="cta-primary" href={HERO.ctaHref}>
{HERO.ctaLabel}
</a>
<InstallCopy command={HERO.installCommand} />
</div>
</section>
);
}
@@ -0,0 +1,46 @@
import { ProxyFigure } from "#/components/figures/proxy-figure";
import { SourceFigure } from "#/components/figures/source-figure";
import { SectionHeading } from "#/components/ui/section-heading";
import { STEPS, STEPS_SECTION, type StepFigure } from "#/content/steps";
/**
* A lookup rather than a conditional, so adding a third card is a new entry in
* content/steps.ts plus a new figure — and never an edit to this component.
* `StepFigure` is derived from the content, so a key with no figure here is a
* type error rather than a blank card.
*/
const FIGURES: Record<StepFigure, () => React.JSX.Element> = {
proxy: ProxyFigure,
source: SourceFigure,
};
/**
* Two cards, each an illustration over the claim it illustrates.
*
* This is the layout that pushed every section from 640px to the header's own
* 1120px: at 640 two cards side by side are ~310px each, which is too narrow for
* a figure to say anything. The 640 measure still exists, but as a cap on prose
* inside a section rather than on the section — see `.section` in shell.css.
*/
export function HowItWorksSection() {
return (
<section className="section" id="how-it-works">
<SectionHeading desc={STEPS_SECTION.desc} title={STEPS_SECTION.heading} />
<div className="steps-grid">
{STEPS.map((step) => {
const Figure = FIGURES[step.figure];
return (
<article className="step-card" key={step.id}>
<div className="step-figure">
<Figure />
</div>
<h3 className="step-title">{step.title}</h3>
<p className="step-card-desc">{step.body}</p>
</article>
);
})}
</div>
</section>
);
}
@@ -0,0 +1,73 @@
import { ExternalIcon } from "#/components/ui/external-icon";
import { GLYPHS } from "#/content/editor-glyphs";
import { FOOTER, FOOTER_COLUMNS } from "#/content/footer";
import { SITE } from "#/content/site";
/**
* The footer: a brand column, the links that actually resolve, the wordmark as
* art, and a bottom bar.
*
* The oversized wordmark is `aria-hidden` and clipped to roughly the top two
* thirds of its letterforms. It is set in the page's own type at a size nothing
* else comes near, which is the whole trick — it reads as the brand rather than
* as a heading, and cropping it is what keeps it from being read as one. There
* is no image involved, so it costs nothing to ship and follows the tokens.
*/
export function SiteFooter() {
return (
<footer className="footer">
<div className="footer-top">
<div className="footer-brand">
<span className="footer-logo">
<svg
aria-hidden="true"
className="footer-brand-mark"
fill="none"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: the glyph
body is a module-level constant of literal SVG markup with no
interpolated input — same reasoning as the header wordmark. */}
<g dangerouslySetInnerHTML={{ __html: GLYPHS.logo.body }} />
</svg>
<span className="footer-brand-name">{SITE.name}</span>
</span>
<p className="footer-blurb">{FOOTER.blurb}</p>
</div>
{FOOTER_COLUMNS.map((column) => (
<nav
aria-labelledby={`footer-${column.id}`}
className="footer-col"
key={column.id}
>
<h2 className="footer-col-title" id={`footer-${column.id}`}>
{column.title}
</h2>
{column.links.map((link) => (
<a
className="footer-col-link"
href={link.href}
key={link.href}
{...("external" in link && link.external
? { rel: "noopener", target: "_blank" }
: {})}
>
{link.label}
{"external" in link && link.external ? <ExternalIcon /> : null}
</a>
))}
</nav>
))}
</div>
{/* Decorative, and the last thing on the page. The name is already
announced by the brand column above, and a screen reader meeting it
twice learns nothing the second time. */}
<div aria-hidden="true" className="footer-art">
<span className="footer-art-word">{SITE.name}</span>
</div>
</footer>
);
}
@@ -0,0 +1,36 @@
import { GLYPHS } from "#/content/editor-glyphs";
import { SITE } from "#/content/site";
/**
* The brand mark and the wordmark, linking back to the top.
*
* The mark's path is the same geometry as assets/logo.svg — the file that is the
* repo's geometry of record — inlined rather than loaded through an <img> so it
* inherits `currentColor` and follows the bar's text colour without a second
* asset.
*
* The wordmark is set at 500 and is the only word on the page at that weight
* outside a heading; everything else in the header is 400. That is what makes it
* read as a mark rather than as the first item of the navigation.
*/
export function LogoWordmark() {
return (
<a aria-label={`${SITE.name} — home`} className="header-logo" href="#top">
<svg
aria-hidden="true"
className="header-mark"
fill="none"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: the glyph body
is a module-level constant of literal SVG markup with no interpolated
input. Parsing it back into JSX elements would buy nothing and would
put the path data — which must stay byte-identical to assets/logo.svg
— at the mercy of the formatter. */}
<g dangerouslySetInnerHTML={{ __html: GLYPHS.logo.body }} />
</svg>
<span className="header-wordmark">{SITE.name}</span>
</a>
);
}
@@ -0,0 +1,53 @@
import { useCallback, useState } from "react";
import { LogoWordmark } from "#/components/shell/logo-wordmark";
import { TocNav } from "#/components/shell/toc-nav";
import { SECTION_IDS } from "#/content/nav";
import { cn } from "#/lib/cn";
import { useScrollSpy } from "#/lib/use-scroll-spy";
/**
* The sticky top bar: wordmark left, navigation and controls right.
*
* Both layouts — the inline bar and the collapsed mobile menu — are the same
* DOM, so the tab order and the scroll-spy do not have to know which one is on
* screen. Only CSS moves things, and below the mobile breakpoint `.toc` wraps
* onto its own row and collapses to zero height.
*
* One placement here is deliberate and is not just markup order: the hamburger
* comes after the nav in the DOM so it lands at the right edge of the bar. It is
* always rendered and `display: none` above the breakpoint, which is what
* correctly takes it out of the tab order at a width where it does nothing.
*/
export function SiteHeader() {
const [menuOpen, setMenuOpen] = useState(false);
const activeId = useScrollSpy(SECTION_IDS);
const toggleMenu = useCallback(() => setMenuOpen((open) => !open), []);
const closeMenu = useCallback(() => setMenuOpen(false), []);
return (
<header className={cn("site-header", menuOpen && "menu-open")}>
<div className="header-inner">
<LogoWordmark />
{/* Closing on navigate matters only in the collapsed layout, where the
menu overlays the content it just scrolled to. Harmless inline. */}
<TocNav activeId={activeId} onNavigate={closeMenu} />
<div className="header-actions">
<button
aria-controls="toc-nav"
aria-expanded={menuOpen}
aria-label={menuOpen ? "Close menu" : "Open menu"}
className="hamburger"
onClick={toggleMenu}
type="button"
>
<span aria-hidden="true" className="hamburger-line" />
<span aria-hidden="true" className="hamburger-line" />
</button>
</div>
</div>
</header>
);
}
@@ -0,0 +1,14 @@
/**
* The first thing in the tab order.
*
* Parked off-screen with `top: -100%` rather than `display: none`, because a
* hidden element is not focusable and a skip link that cannot be focused is
* worse than no skip link at all — it looks like the page has the affordance.
*/
export function SkipLink() {
return (
<a className="skip-link" href="#main-content">
Skip to content
</a>
);
}
+51
View File
@@ -0,0 +1,51 @@
import { GitHubIcon } from "#/components/ui/github-icon";
import { TOC_EXTERNAL_LINKS, TOC_LINKS } from "#/content/nav";
/**
* The table of contents.
*
* `aria-current="true"` rather than a class alone, so the active entry is
* announced and not merely coloured. The scroll-spy that sets it lives in the
* header, which owns the state — this component only renders.
*/
export function TocNav({
activeId,
onNavigate,
}: {
activeId: string;
onNavigate: () => void;
}) {
return (
<nav aria-label="On this page" className="toc" id="toc-nav">
<div className="toc-inner">
{TOC_LINKS.map((link) => (
<a
aria-current={activeId === link.id ? "true" : undefined}
className="toc-link"
href={`#${link.id}`}
key={link.id}
onClick={onNavigate}
>
{link.label}
</a>
))}
<div aria-hidden="true" className="toc-sep" />
{TOC_EXTERNAL_LINKS.map((link) => (
<a
aria-label={link.label}
className="toc-link"
href={link.href}
key={link.href}
onClick={onNavigate}
rel="noopener"
target="_blank"
>
<GitHubIcon />
</a>
))}
</div>
</nav>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { CopyButton } from "#/components/ui/copy-button";
/**
* A command, with everything after a `#` dimmed as a comment.
*
* The split is on the first `#` only, because these are shell one-liners where
* a `#` cannot appear before the comment. That is a real constraint on what can
* be put in `content/get-started.ts`, and it is the reason this is a split and
* not a tokenizer: a syntax highlighter for three lines of shell is a library
* this page does not need to ship.
*/
export function CodeBlock({
code,
copyable,
label,
}: {
code: string;
copyable: boolean;
label: string;
}) {
const hash = code.indexOf("#");
const command = hash === -1 ? code : code.slice(0, hash);
const comment = hash === -1 ? "" : code.slice(hash);
return (
<div className="code-block">
<pre className="code-line">
<code>
{command}
{comment ? <span className="code-comment">{comment}</span> : null}
</code>
</pre>
{copyable ? <CopyButton label={label} value={command.trim()} /> : null}
</div>
);
}
@@ -0,0 +1,89 @@
import { useCallback } from "react";
import { cn } from "#/lib/cn";
import { useCopy } from "#/lib/use-copy";
/**
* Copy-to-clipboard, with the copied state announced rather than only drawn.
*
* Both icons are rendered at once and cross-faded — swapping them would change
* the button's content box mid-transition and make it twitch. The `aria-live`
* region is what a screen reader gets, since a tick appearing is not an event
* anything else would announce.
*/
export function CopyButton({ label, value }: { label: string; value: string }) {
const { copied, copy } = useCopy();
const onClick = useCallback(() => copy(value), [copy, value]);
return (
<button
aria-label={copied ? `${label} copied` : `Copy ${label}`}
className="copy-btn"
onClick={onClick}
type="button"
>
<span
className={cn("copy-icon", copied ? "copy-icon-out" : "copy-icon-in")}
>
<CopyIcon />
</span>
<span
className={cn("copy-icon", copied ? "copy-icon-in" : "copy-icon-out")}
>
<CheckIcon />
</span>
<span className="sr-only" role="status">
{copied ? "Copied" : ""}
</span>
</button>
);
}
function CopyIcon() {
return (
<svg
aria-hidden="true"
fill="none"
height="14"
viewBox="0 0 16 16"
width="14"
xmlns="http://www.w3.org/2000/svg"
>
<rect
height="9"
rx="1.8"
stroke="currentColor"
strokeWidth="1.2"
width="9"
x="5.6"
y="5.6"
/>
<path
d="M10.4 3.4a1.8 1.8 0 0 0-1.8-1.8H3.4a1.8 1.8 0 0 0-1.8 1.8v5.2a1.8 1.8 0 0 0 1.8 1.8"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.2"
/>
</svg>
);
}
function CheckIcon() {
return (
<svg
aria-hidden="true"
fill="none"
height="14"
viewBox="0 0 16 16"
width="14"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3.2 8.4l3.1 3.1 6.5-6.9"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.4"
/>
</svg>
);
}
@@ -0,0 +1,22 @@
/** The 45° arrow that marks a link as leaving the site. */
export function ExternalIcon() {
return (
<svg
aria-hidden="true"
className="external-icon"
fill="none"
height="10"
viewBox="0 0 10 10"
width="10"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.5 7.5L7.5 2.5M7.5 2.5H3.5M7.5 2.5V6.5"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.2"
/>
</svg>
);
}
+81
View File
@@ -0,0 +1,81 @@
import { useCallback } from "react";
import { cn } from "#/lib/cn";
/**
* One accordion row.
*
* Open state is owned by the section so only one answer can be open at a time —
* an accordion where every row can be open is just a list with extra clicks.
* `onToggle` therefore takes the id rather than being a nullary closure: that
* lets the section pass one stable handler for every row instead of allocating a
* new one per row on every render.
*
* The answer stays in the DOM when closed, collapsed by a grid row rather than
* unmounted, which is what lets it animate to a height nobody measured. `hidden`
* would defeat that, so the state is carried by `aria-expanded` on the button and
* `aria-labelledby` pointing the region back at it.
*/
export function FaqItem({
answer,
id,
isOpen,
onToggle,
question,
}: {
answer: string;
id: string;
isOpen: boolean;
onToggle: (id: string) => void;
question: string;
}) {
const handleClick = useCallback(() => onToggle(id), [onToggle, id]);
return (
<div className={cn("faq-item", isOpen && "open")}>
<h3>
<button
aria-controls={`faq-answer-${id}`}
aria-expanded={isOpen}
className="faq-question"
id={`faq-question-${id}`}
onClick={handleClick}
type="button"
>
{question}
<Chevron />
</button>
</h3>
<section
aria-labelledby={`faq-question-${id}`}
className="faq-answer"
id={`faq-answer-${id}`}
>
<div>
<p>{answer}</p>
</div>
</section>
</div>
);
}
function Chevron() {
return (
<svg
aria-hidden="true"
className="faq-chevron"
fill="none"
height="12"
viewBox="0 0 12 12"
width="12"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 4.5L6 7.5L9 4.5"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.3"
/>
</svg>
);
}
@@ -0,0 +1,16 @@
/** The GitHub mark, for links pointing at the repository. */
export function GitHubIcon() {
return (
<svg
aria-hidden="true"
className="github-icon"
fill="currentColor"
height="16"
viewBox="0 0 16 16"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0016 8c0-4.42-3.58-8-8-8z" />
</svg>
);
}
@@ -0,0 +1,91 @@
import { useCallback } from "react";
import { cn } from "#/lib/cn";
import { useCopy } from "#/lib/use-copy";
/**
* The hero's secondary action: bare mono text you can click to copy.
*
* Deliberately not a button-shaped thing. The page has exactly one primary
* action, and giving this one a border or a fill would make the hero look like
* it is asking twice.
*/
export function InstallCopy({ command }: { command: string }) {
const { copied, copy } = useCopy();
const onClick = useCallback(() => copy(command), [copy, command]);
return (
<button
aria-label={copied ? "Command copied" : `Copy ${command}`}
className="hero-install"
onClick={onClick}
type="button"
>
<code className="hero-install-cmd">{command}</code>
<span className="hero-install-icon">
<span
className={cn("copy-icon", copied ? "copy-icon-out" : "copy-icon-in")}
>
<CopyGlyph />
</span>
<span
className={cn("copy-icon", copied ? "copy-icon-in" : "copy-icon-out")}
>
<CheckGlyph />
</span>
</span>
<span className="sr-only" role="status">
{copied ? "Copied" : ""}
</span>
</button>
);
}
function CopyGlyph() {
return (
<svg
aria-hidden="true"
fill="none"
height="13"
viewBox="0 0 16 16"
width="13"
xmlns="http://www.w3.org/2000/svg"
>
<rect
height="9"
rx="1.8"
stroke="currentColor"
strokeWidth="1.2"
width="9"
x="5.6"
y="5.6"
/>
<path
d="M10.4 3.4a1.8 1.8 0 0 0-1.8-1.8H3.4a1.8 1.8 0 0 0-1.8 1.8v5.2a1.8 1.8 0 0 0 1.8 1.8"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.2"
/>
</svg>
);
}
function CheckGlyph() {
return (
<svg
aria-hidden="true"
fill="none"
height="13"
viewBox="0 0 16 16"
width="13"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3.2 8.4l3.1 3.1 6.5-6.9"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.4"
/>
</svg>
);
}
@@ -0,0 +1,21 @@
/**
* A section's heading and its one line of framing.
*
* Always an `<h2>`: every section on this page is a peer of every other, under
* the single `<h1>` in the hero. Rendering the level as a prop would invite a
* hierarchy this page does not have.
*/
export function SectionHeading({
desc,
title,
}: {
desc?: string;
title: string;
}) {
return (
<>
<h2 className="section-heading">{title}</h2>
{desc ? <p className="section-desc">{desc}</p> : null}
</>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { useCallback, useEffect, useRef, useState } from "react";
const RESET_DELAY_MS = 1500;
/**
* Copy text to the clipboard and report success for long enough to see it.
*
* The timer is held in a ref and cleared on unmount so a component that
* disappears mid-flash — a code block inside a collapsing FAQ answer, say —
* cannot set state after it is gone.
*
* A rejected write (no permission, insecure origin, no clipboard API at all)
* leaves `copied` false rather than throwing. There is no useful recovery: the
* text is on screen and selectable, which is the fallback.
*/
export function useCopy(): { copied: boolean; copy: (text: string) => void } {
const [copied, setCopied] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(
() => () => {
if (timer.current) {
clearTimeout(timer.current);
}
},
[]
);
const copy = useCallback((text: string) => {
navigator.clipboard
?.writeText(text)
.then(() => {
setCopied(true);
if (timer.current) {
clearTimeout(timer.current);
}
timer.current = setTimeout(() => setCopied(false), RESET_DELAY_MS);
})
.catch(() => {
// Nothing to recover: the text is visible and selectable.
});
}, []);
return { copied, copy };
}
+49
View File
@@ -0,0 +1,49 @@
import { useEffect, useState } from "react";
/**
* The id of the section currently being read, for the table of contents.
*
* `rootMargin: "-20% 0px -60% 0px"` shrinks the observer's viewport to a band
* across the upper-middle of the screen. Without it, a tall section and a short
* one are both "intersecting" for most of a scroll and the indicator flickers
* between them; with it, exactly the section under that band is active, which is
* the one a reader is actually looking at.
*
* @param ids Section ids to watch, in document order.
*/
export function useScrollSpy(ids: readonly string[]): string {
const [active, setActive] = useState("");
useEffect(() => {
const sections = ids
.map((id) => document.getElementById(id))
.filter((el): el is HTMLElement => el !== null);
if (sections.length === 0) {
return;
}
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
setActive(entry.target.id);
}
}
},
{ rootMargin: "-20% 0px -60% 0px" }
);
for (const section of sections) {
observer.observe(section);
}
return () => {
observer.disconnect();
};
// `ids` is a module-level constant tuple at every call site; joining it keeps
// the effect from re-subscribing on every render without disabling the rule.
}, [ids]);
return active;
}
+496
View File
@@ -0,0 +1,496 @@
/*
* The "How it works" cards and the two figures they carry.
*
* Unlayered, like the rest of the page's CSS — see the comment block in app.css.
*
* Everything here resolves through `--pk-color-*`, so a token change reaches the
* figures without a second edit. The one exception is the picker blue in
* `.fig-source`, which is declared locally and explained where it appears.
*
* The figures are drawn in HTML and CSS rather than shipped as SVG. That is not
* a preference: they quote real colours from the token set and must follow it,
* and two of their strings (the file name and the diff) are read from
* content/mock-page.ts so they cannot drift from the hero. A flat asset would
* have to be re-exported every time either changed.
*/
/* ── the cards ─────────────────────────────────────────────────────────── */
/*
* The grid sizes itself from `.section`, which is 1120px — see shell.css. At the
* old 640px measure two cards were ~310px each, which is not enough for a figure
* to say anything; widening the sections is what made this layout possible, and
* is why every section moved rather than this one alone.
*/
.steps-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
margin-top: 28px;
}
.step-card {
display: flex;
flex-direction: column;
padding: 32px 32px 36px;
background: var(--pk-color-surface-input);
border: none;
border-radius: 18px;
}
/*
* A fixed height, so the two titles sit on the same baseline no matter how tall
* the drawing inside happens to be. The figures are different shapes — one is a
* window, one is a vertical chain — and letting them size the box would stagger
* the text beside them.
*/
.step-figure {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
height: 232px;
margin-bottom: 28px;
overflow: hidden;
}
.step-title {
margin: 0 0 8px;
font-size: 16px;
font-weight: 500;
line-height: 24px;
color: var(--pk-color-text-primary);
text-align: center;
text-wrap: balance;
}
.step-card-desc {
max-width: 42ch;
margin: 0 auto;
font-size: 14px;
line-height: 22px;
color: var(--pk-color-text-secondary);
text-align: center;
text-wrap: pretty;
}
/* ── shared figure scaffolding ─────────────────────────────────────────── */
.fig {
display: flex;
flex-direction: column;
width: 100%;
font-family: var(--pk-font-sans);
user-select: none;
}
/* ── figure: the proxy ─────────────────────────────────────────────────── */
/*
* Top-anchored and stretched to the full figure box, rather than a small window
* floating in the middle of it. The window is now taller than what is visible,
* and the bottom of it dissolves — see the mask on `.fig-window`.
*/
.fig-proxy {
align-items: center;
align-self: stretch;
justify-content: flex-start;
}
/*
* The window runs off the bottom of the figure and fades out instead of ending
* on an edge, so the drawing reads as a screenshot that continues past the card
* rather than a small object with empty space under it. The fade is also what
* carries the eye down into the title.
*
* `mask-image` rather than a gradient overlay: a mask makes the pixels actually
* transparent, so this stays correct if the card surface ever changes. An
* overlay would have to name the card's background colour, and would need
* re-stating every time that colour moved.
*
* It fades the box-shadow with it, which is the point — a 1px ring surviving
* past the fade is what makes this look like erasure rather than depth.
*/
.fig-window {
display: flex;
flex: 1 1 auto;
flex-direction: column;
width: 100%;
max-width: 400px;
min-height: 0;
overflow: hidden;
background: var(--pk-color-surface-panel);
border-radius: var(--pk-radius-xl);
box-shadow:
0 0 0 1px var(--pk-color-border-subtle),
0 8px 24px -6px rgb(0 0 0 / 0.14);
mask-image: linear-gradient(to bottom, #000 56%, transparent 100%);
}
.fig-chrome {
display: flex;
gap: 10px;
align-items: center;
height: 32px;
padding: 0 12px;
background: var(--pk-color-surface-chrome);
border-bottom: 1px solid var(--pk-color-border-faint);
}
.fig-dots {
display: flex;
flex-shrink: 0;
gap: 5px;
}
.fig-dot {
width: 7px;
height: 7px;
border-radius: 50%;
}
.fig-dot-red {
background: #f87171;
}
.fig-dot-yellow {
background: #fbbf24;
}
.fig-dot-green {
background: #4ade80;
}
.fig-url {
font-family: var(--pk-font-mono);
font-size: 10px;
color: var(--pk-color-text-muted);
}
/* Names the port airship is standing in front of. This pill is the entire
"sits in front of" claim — without it the window is just a browser. */
.fig-proxy-pill {
padding: 2px 7px;
margin-left: auto;
font-family: var(--pk-font-mono);
font-size: 9px;
color: var(--pk-color-text-tertiary);
white-space: nowrap;
background: var(--pk-color-surface-input);
border-radius: var(--pk-radius-pill);
}
/* Takes the whole window below the chrome, and has no bottom padding: the
frames are meant to run past the bottom edge and be cut by the fade, so a
gutter under them would only make the window look like it ends. */
.fig-canvas {
display: flex;
flex: 1 1 auto;
gap: 14px;
align-items: stretch;
min-height: 0;
padding: 16px 14px 0;
background: var(--pk-color-surface-page);
}
/* Full height rather than a fixed one, so the frames end wherever the fade puts
them instead of at a number that has to be re-tuned per breakpoint. */
.fig-frame {
position: relative;
padding: 8px;
background: var(--pk-color-surface-panel);
border: 1px solid var(--pk-color-border-subtle);
border-radius: 5px;
}
/* 393 and 1280 are the real numbers — an iPhone 15's logical width and a common
desktop breakpoint — so the two frames are visibly a phone and a desktop
rather than two rectangles. */
.fig-frame-sm {
flex: 0 0 74px;
}
.fig-frame-lg {
flex: 1 1 auto;
min-width: 0;
}
.fig-frame-w {
position: absolute;
top: -16px;
left: 0;
font-family: var(--pk-font-mono);
font-size: 9px;
color: var(--pk-color-text-tertiary);
}
.fig-frame-body {
display: flex;
flex-direction: column;
gap: 6px;
height: 100%;
}
/* The payoff of the pair: the same content stacks in the narrow frame and sits
in a row in the wide one, which is what "media queries fire" looks like. */
.fig-frame-body-row {
flex-direction: row;
gap: 8px;
}
.fig-bar {
background: var(--pk-color-border-default);
border-radius: 3px;
}
.fig-bar-full {
height: 8px;
}
.fig-bar-half {
width: 60%;
height: 8px;
}
/* The one non-text row in the phone's skeleton — a card or image between the
lines, so the stack does not read as six identical dashes. */
.fig-bar-block {
height: 26px;
margin: 2px 0;
}
.fig-bar-col {
flex: 1;
height: 100%;
}
/* ── figure: source ────────────────────────────────────────────────────── */
/*
* Three beats stacked in the order the tool runs: the element you picked, the
* file and line it resolved to, the edit that landed.
*
* `--fig-primary` is the picker blue, and it is written literally rather than
* read from a token because `--ap-primary` is scoped to `.ap-mock` in
* editor-mock.css — those are the editor's tokens, deliberately not the page's.
* This is the one place the page quotes one, and it is the same value.
*/
.fig-source {
--fig-primary: #0d99ff;
gap: 4px;
align-items: center;
justify-content: center;
/* Room for `.fig-pick-badge`, which is absolute and translated a full line
ABOVE the element it labels — without this it hangs outside the figure's
own box and is clipped by `.step-figure`'s overflow. */
padding-top: 18px;
}
.fig-pick {
position: relative;
padding: 9px 18px;
font-size: 12px;
color: var(--pk-color-cta-text);
outline: 2px solid var(--fig-primary);
outline-offset: 3px;
background: var(--pk-color-cta-bg);
border-radius: var(--pk-radius-pill);
}
/* The identity badge, above the box's top-left corner — same placement and same
10px mono as `.ap-box-label` in the hero. */
.fig-pick-badge {
position: absolute;
top: -8px;
left: -2px;
padding: 2px 5px;
font-family: var(--pk-font-mono);
font-size: 10px;
line-height: 1.4;
color: #fff;
white-space: nowrap;
background: var(--fig-primary);
border-radius: 3px;
transform: translateY(-100%);
}
.fig-handle {
position: absolute;
width: 7px;
height: 7px;
background: #fff;
border: 1px solid var(--fig-primary);
border-radius: 1px;
}
.fig-handle-tl {
top: -8px;
left: -8px;
}
.fig-handle-tr {
top: -8px;
right: -8px;
}
.fig-handle-bl {
bottom: -8px;
left: -8px;
}
.fig-handle-br {
right: -8px;
bottom: -8px;
}
.fig-resolve {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 14px;
}
.fig-thread {
width: 1px;
height: 14px;
background: var(--pk-color-border-default);
}
.fig-chip {
padding: 4px 9px;
margin-top: 6px;
font-family: var(--pk-font-mono);
font-size: 10px;
color: var(--pk-color-text-muted);
background: var(--pk-color-surface-panel);
border-radius: var(--pk-radius-sm);
box-shadow: 0 0 0 1px var(--pk-color-border-subtle);
}
.fig-chip-line {
color: var(--pk-color-text-tertiary);
}
.fig-diff {
width: 100%;
max-width: 260px;
margin-top: 16px;
overflow: hidden;
background: var(--pk-color-surface-panel);
border-radius: var(--pk-radius-lg);
box-shadow:
0 0 0 1px var(--pk-color-border-subtle),
0 6px 18px -6px rgb(0 0 0 / 0.12);
}
.fig-diff-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
background: var(--pk-color-surface-chrome);
border-bottom: 1px solid var(--pk-color-border-faint);
}
.fig-diff-file {
font-family: var(--pk-font-mono);
font-size: 10px;
color: var(--pk-color-text-muted);
}
.fig-diff-stat {
font-family: var(--pk-font-mono);
font-size: 9px;
color: var(--pk-color-text-tertiary);
}
.fig-diff-body {
padding: 6px 0;
}
.fig-diff-line {
padding: 1px 10px;
overflow: hidden;
text-overflow: ellipsis;
font-family: var(--pk-font-mono);
font-size: 10px;
line-height: 17px;
white-space: nowrap;
}
.fig-diff-del {
color: var(--pk-color-syntax-old);
background: color-mix(in srgb, var(--pk-color-syntax-old) 8%, transparent);
}
.fig-diff-add {
color: var(--pk-color-syntax-new);
background: color-mix(in srgb, var(--pk-color-syntax-new) 8%, transparent);
}
/* ── responsive ────────────────────────────────────────────────────────── */
/*
* The cards stack at the same width the nav collapses. Below it the grid's two
* columns are narrower than the 400px window the proxy figure wants, and a
* figure that has to shrink to fit stops being readable before the text does.
*/
@media (max-width: 768px) {
.steps-grid {
grid-template-columns: 1fr;
gap: 16px;
}
.step-card {
padding: 24px 24px 28px;
}
.step-figure {
height: 216px;
margin-bottom: 22px;
}
.step-title {
font-size: 15px;
line-height: 22px;
}
}
/*
* At phone widths the figures shrink rather than the box that holds them.
*
* `.step-figure` is a fixed height so both titles share a baseline, which means
* the box cannot simply be cut down to fit a smaller card — anything taller than
* it is silently clipped by its own `overflow: hidden`. So the source figure's
* drawing comes down first (a tighter chain, a narrower diff) and the box
* follows it, with a few pixels to spare.
*
* The proxy figure needs nothing here any more: its window fills whatever height
* the box has and the fade lands at the bottom of it either way.
*/
@media (max-width: 640px) {
.step-figure {
height: 200px;
}
.step-card-desc {
font-size: 13px;
line-height: 20px;
}
.fig-canvas {
padding: 14px 12px 0;
}
.fig-resolve {
padding-top: 10px;
}
.fig-thread {
height: 10px;
}
.fig-diff {
max-width: 240px;
margin-top: 12px;
}
.fig-diff-line {
line-height: 15px;
}
}
File diff suppressed because it is too large Load Diff