diff --git a/apps/web/src/components/figures/proxy-figure.tsx b/apps/web/src/components/figures/proxy-figure.tsx
new file mode 100644
index 0000000..dcb0668
--- /dev/null
+++ b/apps/web/src/components/figures/proxy-figure.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+ localhost:3001
+ proxying :3000
+
+
+
+ {/* 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. */}
+
+ 393
+
+
+
+
+
+
+
+
+
+
+
+ 1280
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/figures/source-figure.tsx b/apps/web/src/components/figures/source-figure.tsx
new file mode 100644
index 0000000..63217d3
--- /dev/null
+++ b/apps/web/src/components/figures/source-figure.tsx
@@ -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 (
+
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.
+ */}
+
+
+ );
+}
diff --git a/apps/web/src/components/sections/faq-section.tsx b/apps/web/src/components/sections/faq-section.tsx
new file mode 100644
index 0000000..8492905
--- /dev/null
+++ b/apps/web/src/components/sections/faq-section.tsx
@@ -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(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 (
+
+
+
+ {FAQ.map((entry) => (
+
+ ))}
+
+
+ );
+}
diff --git a/apps/web/src/components/sections/get-started-section.tsx b/apps/web/src/components/sections/get-started-section.tsx
new file mode 100644
index 0000000..99fdd8b
--- /dev/null
+++ b/apps/web/src/components/sections/get-started-section.tsx
@@ -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 (
+
+
+
+ {GET_STARTED.map((step) => (
+
+
{step.title}
+
+ {step.note ?
{step.note}
: null}
+
+ ))}
+
+
{GET_STARTED_SECTION.compat}
+
+ );
+}
diff --git a/apps/web/src/components/sections/hero-section.tsx b/apps/web/src/components/sections/hero-section.tsx
new file mode 100644
index 0000000..b888292
--- /dev/null
+++ b/apps/web/src/components/sections/hero-section.tsx
@@ -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 (
+
+
+
+ );
+}
diff --git a/apps/web/src/components/sections/how-it-works-section.tsx b/apps/web/src/components/sections/how-it-works-section.tsx
new file mode 100644
index 0000000..8815093
--- /dev/null
+++ b/apps/web/src/components/sections/how-it-works-section.tsx
@@ -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 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 (
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/sections/site-footer.tsx b/apps/web/src/components/sections/site-footer.tsx
new file mode 100644
index 0000000..e9a1b09
--- /dev/null
+++ b/apps/web/src/components/sections/site-footer.tsx
@@ -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 (
+
+ );
+}
diff --git a/apps/web/src/components/shell/logo-wordmark.tsx b/apps/web/src/components/shell/logo-wordmark.tsx
new file mode 100644
index 0000000..4db8ba1
--- /dev/null
+++ b/apps/web/src/components/shell/logo-wordmark.tsx
@@ -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 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 (
+
+
+ {SITE.name}
+
+ );
+}
diff --git a/apps/web/src/components/shell/site-header.tsx b/apps/web/src/components/shell/site-header.tsx
new file mode 100644
index 0000000..33bce58
--- /dev/null
+++ b/apps/web/src/components/shell/site-header.tsx
@@ -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 (
+
+
+
+
+ {/* Closing on navigate matters only in the collapsed layout, where the
+ menu overlays the content it just scrolled to. Harmless inline. */}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/shell/skip-link.tsx b/apps/web/src/components/shell/skip-link.tsx
new file mode 100644
index 0000000..92b02dd
--- /dev/null
+++ b/apps/web/src/components/shell/skip-link.tsx
@@ -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 (
+
+ Skip to content
+
+ );
+}
diff --git a/apps/web/src/components/shell/toc-nav.tsx b/apps/web/src/components/shell/toc-nav.tsx
new file mode 100644
index 0000000..4a191db
--- /dev/null
+++ b/apps/web/src/components/shell/toc-nav.tsx
@@ -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 (
+
+ );
+}
diff --git a/apps/web/src/components/ui/code-block.tsx b/apps/web/src/components/ui/code-block.tsx
new file mode 100644
index 0000000..655a375
--- /dev/null
+++ b/apps/web/src/components/ui/code-block.tsx
@@ -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 (
+
+
+
+ {command}
+ {comment ? {comment} : null}
+
+
+ {copyable ? : null}
+
+ );
+}
diff --git a/apps/web/src/components/ui/copy-button.tsx b/apps/web/src/components/ui/copy-button.tsx
new file mode 100644
index 0000000..d671d79
--- /dev/null
+++ b/apps/web/src/components/ui/copy-button.tsx
@@ -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 (
+
+ );
+}
+
+function CopyIcon() {
+ return (
+
+ );
+}
+
+function CheckIcon() {
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/ui/external-icon.tsx b/apps/web/src/components/ui/external-icon.tsx
new file mode 100644
index 0000000..6766b05
--- /dev/null
+++ b/apps/web/src/components/ui/external-icon.tsx
@@ -0,0 +1,22 @@
+/** The 45° arrow that marks a link as leaving the site. */
+export function ExternalIcon() {
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/ui/faq-item.tsx b/apps/web/src/components/ui/faq-item.tsx
new file mode 100644
index 0000000..7423163
--- /dev/null
+++ b/apps/web/src/components/ui/faq-item.tsx
@@ -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 (
+
+
+
+
+
+
+
{answer}
+
+
+
+ );
+}
+
+function Chevron() {
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/ui/github-icon.tsx b/apps/web/src/components/ui/github-icon.tsx
new file mode 100644
index 0000000..1551a52
--- /dev/null
+++ b/apps/web/src/components/ui/github-icon.tsx
@@ -0,0 +1,16 @@
+/** The GitHub mark, for links pointing at the repository. */
+export function GitHubIcon() {
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/ui/install-copy.tsx b/apps/web/src/components/ui/install-copy.tsx
new file mode 100644
index 0000000..7d8764b
--- /dev/null
+++ b/apps/web/src/components/ui/install-copy.tsx
@@ -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 (
+
+ );
+}
+
+function CopyGlyph() {
+ return (
+
+ );
+}
+
+function CheckGlyph() {
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/ui/section-heading.tsx b/apps/web/src/components/ui/section-heading.tsx
new file mode 100644
index 0000000..df06462
--- /dev/null
+++ b/apps/web/src/components/ui/section-heading.tsx
@@ -0,0 +1,21 @@
+/**
+ * A section's heading and its one line of framing.
+ *
+ * Always an `
`: every section on this page is a peer of every other, under
+ * the single `
` 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 (
+ <>
+
{title}
+ {desc ?
{desc}
: null}
+ >
+ );
+}
diff --git a/apps/web/src/lib/use-copy.ts b/apps/web/src/lib/use-copy.ts
new file mode 100644
index 0000000..daca708
--- /dev/null
+++ b/apps/web/src/lib/use-copy.ts
@@ -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 | 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 };
+}
diff --git a/apps/web/src/lib/use-scroll-spy.ts b/apps/web/src/lib/use-scroll-spy.ts
new file mode 100644
index 0000000..0a26689
--- /dev/null
+++ b/apps/web/src/lib/use-scroll-spy.ts
@@ -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;
+}
diff --git a/apps/web/src/styles/cards.css b/apps/web/src/styles/cards.css
new file mode 100644
index 0000000..00c1662
--- /dev/null
+++ b/apps/web/src/styles/cards.css
@@ -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;
+ }
+}
diff --git a/apps/web/src/styles/shell.css b/apps/web/src/styles/shell.css
new file mode 100644
index 0000000..c1a3117
--- /dev/null
+++ b/apps/web/src/styles/shell.css
@@ -0,0 +1,1262 @@
+/*
+ * The page shell: top bar, reading column, type scale, and every section that
+ * is not the hero.
+ *
+ * Unlayered on purpose — see the comment block in app.css. A Tailwind utility
+ * cannot override anything in this file, which is what a transcribed layout
+ * needs.
+ *
+ * Colours resolve through `--pk-color-*` rather than being written literally, so
+ * DESIGN.md stays the only place a value is decided. Geometry is literal px,
+ * because it was measured.
+ */
+
+/* ── skip link ─────────────────────────────────────────────────────────── */
+
+/* Parked off-screen rather than `display: none`, which would take it out of the
+ tab order and defeat the point of having it. */
+.skip-link {
+ position: absolute;
+ top: -100%;
+ left: 16px;
+ z-index: 1000;
+ padding: 8px 16px;
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--pk-color-cta-text);
+ text-decoration: none;
+ background: var(--pk-color-cta-bg);
+ border-radius: 0 0 8px 8px;
+ transition: top 150ms ease;
+}
+
+.skip-link:focus {
+ top: 0;
+}
+
+/* ── layout ────────────────────────────────────────────────────────────── */
+
+/*
+ * Clears the sticky bar on every anchor jump.
+ *
+ * Unconditional, unlike the 56px this replaces: the bar used to be a fixed rail
+ * above 1240px and only became sticky below it, so the offset was only needed
+ * there. It is sticky at every width now, and a section that lands underneath it
+ * is the failure this prevents.
+ *
+ * The bar measures ~60px (a 32px control plus 14px of padding either side), so
+ * 72 leaves a little air above the heading rather than butting it against the
+ * chrome. It is the only offset in play — `.section` deliberately carries no
+ * `scroll-margin-top`, so this one number is the whole answer.
+ */
+/*
+ * No rubber-band. macOS lets you drag the whole document past its own end and
+ * springs it back, which on this page means dragging the wallpaper band or the
+ * clipped wordmark away from the edge they were composed to sit against.
+ *
+ * `none` rather than `contain`: `contain` only stops scroll chaining to a
+ * parent, and the bounce is the part being removed here. It applies to touch as
+ * well as trackpads, so it also turns off pull-to-refresh on Android — the page
+ * has nothing to refresh to, but that is the trade.
+ */
+html {
+ overscroll-behavior: none;
+ scroll-padding-top: 72px;
+}
+
+.layout {
+ display: flex;
+ flex-direction: column;
+ min-height: 100vh;
+ background: var(--pk-color-surface-shell);
+}
+
+/* Holds the three bands the page is made of: the hero's words, the full-bleed
+ wallpaper, and the reading column. Only the middle one touches the edges. */
+.main {
+ display: flex;
+ flex-direction: column;
+}
+
+/*
+ * No bottom padding, deliberately.
+ *
+ * The sections are held apart by `gap`, so padding-bottom here was never
+ * spacing between anything — it was only trailing room after the last child.
+ * That child is the footer, whose last element is a wordmark clipped so it runs
+ * off the bottom of the page, and 64px of air under it undid exactly that.
+ */
+.content {
+ display: flex;
+ flex-direction: column;
+ gap: 64px;
+ align-items: center;
+ justify-content: flex-start;
+ min-width: 0;
+ padding: 64px 48px 0;
+}
+
+/*
+ * Every section is 1120px — the header's width, so section headings line up with
+ * the wordmark rather than with a narrower column inside it.
+ *
+ * The reading measure did not go away, it moved down a level: it is now the cap
+ * on the PROSE inside a section (the selector list below), not on the section
+ * itself. Sections hold things that want the room — a two-up card grid,
+ * a terminal block, a row of code — and things that want a line length, and
+ * those two wants stopped being the same number the moment the first card grid
+ * appeared. Keeping them fused meant either cramped cards or unreadable prose.
+ */
+.section {
+ align-self: center;
+ width: 100%;
+ max-width: 1120px;
+}
+
+/*
+ * The reading measure, applied to prose that would otherwise run the full 1120.
+ *
+ * Left-aligned rather than centred, so a capped paragraph still starts on the
+ * same x as the heading above it. A section reads as one left edge with a ragged
+ * right, not as a column floating in a wide box.
+ */
+.section-desc,
+.install-note,
+.install-compat,
+.faq-answer > div > p {
+ max-width: 720px;
+}
+
+/*
+ * The hero's words only — the animation is a sibling band, not a child.
+ *
+ * This is the one section that sets its own horizontal padding, because it sits
+ * outside `.content` so that the full-bleed band can follow it without having to
+ * escape a padded parent.
+ */
+.hero {
+ display: flex;
+ flex-shrink: 0;
+ flex-direction: column;
+ align-items: center;
+ justify-content: flex-start;
+ width: 100%;
+ padding: 88px 48px 56px;
+}
+
+/* ── header ────────────────────────────────────────────────────────────── */
+
+/*
+ * Translucent, and the blur is the point: the bar spends most of its scroll over
+ * the full-bleed wallpaper band, and a solid slab crossing a photograph reads as
+ * a lid. Frosted, it reads as glass over the page.
+ *
+ * `saturate` alongside the blur is not decoration — blurring alone washes colour
+ * out, and the wallpaper under this bar is the most saturated thing on the page.
+ * Pushing saturation back up keeps what shows through recognisably the image
+ * rather than a grey fog.
+ */
+.site-header {
+ position: sticky;
+ top: 0;
+ z-index: 100;
+ background: color-mix(
+ in srgb,
+ var(--pk-color-surface-shell) 72%,
+ transparent
+ );
+ -webkit-backdrop-filter: blur(14px) saturate(180%);
+ backdrop-filter: blur(14px) saturate(180%);
+}
+
+/*
+ * Without backdrop-filter the bar is just 72% opaque, and 13px secondary text
+ * over a moving wallpaper at that opacity is not readable. Fall back to solid.
+ */
+@supports not (
+ (backdrop-filter: blur(1px)) or
+ (-webkit-backdrop-filter: blur(1px))
+ ) {
+ .site-header {
+ background: var(--pk-color-surface-shell);
+ }
+}
+
+/*
+ * Wider than the 720px reading column on purpose. A bar that stopped at the
+ * column would read as a card floating on the page rather than as the edge of
+ * it; the wordmark and the nav want to sit near the corners.
+ */
+.header-inner {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ align-items: center;
+ width: 100%;
+ max-width: 1120px;
+ padding: 14px 48px;
+ margin: 0 auto;
+}
+
+/* `auto` on the right is what pushes the nav and the controls to the far edge;
+ everything after this element is right-aligned by consequence. */
+.header-logo {
+ display: flex;
+ gap: 7px;
+ align-items: center;
+ margin: 0 auto 0 0;
+ color: var(--pk-color-text-primary);
+ text-decoration: none;
+}
+
+.header-mark {
+ flex-shrink: 0;
+ width: 22px;
+ height: 22px;
+ color: var(--pk-color-text-primary);
+}
+
+/* The only 500-weight word in the bar, and the only place the product is
+ named in type rather than in prose. */
+.header-wordmark {
+ font-size: 15px;
+ font-weight: 500;
+ letter-spacing: -0.3px;
+}
+
+/* The always-visible half of the bar: whatever lives here stays reachable at
+ every width, including behind the collapsed menu. */
+.header-actions {
+ display: flex;
+ gap: 2px;
+ align-items: center;
+ margin-left: 12px;
+}
+
+.hamburger {
+ display: none;
+ flex-direction: column;
+ gap: 6px;
+ padding: 8px 4px;
+ cursor: pointer;
+ background: none;
+ border: none;
+}
+
+.hamburger-line {
+ display: block;
+ width: 16px;
+ height: 1.5px;
+ background: var(--pk-color-text-secondary);
+ border-radius: 1px;
+ transform-origin: center;
+ transition: transform 200ms cubic-bezier(0.645, 0.045, 0.355, 1);
+}
+
+/* 3.75px is half the 6px gap plus half the 1.5px rule — the offset that lands
+ both lines exactly on the centre before they rotate. */
+.menu-open .hamburger-line:first-child {
+ transform: translateY(3.75px) rotate(45deg);
+}
+
+.menu-open .hamburger-line:last-child {
+ transform: translateY(-3.75px) rotate(-45deg);
+}
+
+.toc {
+ display: flex;
+ flex: 0 0 auto;
+ align-items: center;
+}
+
+.toc-inner {
+ display: flex;
+ flex: 0 0 auto;
+ flex-direction: row;
+ align-items: center;
+ width: auto;
+}
+
+.toc-link {
+ display: flex;
+ align-items: center;
+ padding: 7px 12px;
+ font-size: 13px;
+ line-height: 16px;
+ color: var(--pk-color-text-secondary);
+ white-space: nowrap;
+ text-decoration: none;
+ border-radius: 8px;
+ transition: color 150ms ease;
+}
+
+.toc-link:hover,
+.toc-link:focus-visible {
+ color: var(--pk-color-text-primary);
+}
+
+.toc-link:focus-visible {
+ outline-offset: -2px;
+ border-radius: 6px;
+}
+
+.toc-link[aria-current="true"] {
+ color: var(--pk-color-text-primary);
+}
+
+/* A vertical rule between the page's own sections and the links that leave it.
+ Becomes a horizontal one again in the collapsed menu, where the nav is a
+ column. */
+.toc-sep {
+ width: 1px;
+ height: 14px;
+ margin: 0 10px;
+ background: var(--pk-color-border-faint);
+}
+
+.external-icon {
+ margin-left: 3px;
+ vertical-align: -1px;
+ opacity: 0.6;
+}
+
+/* ── typography ────────────────────────────────────────────────────────── */
+
+/* The small line above the heading. Secondary colour and 13px, so it reads as a
+ label on the hero rather than as the first line of it. */
+.hero-eyebrow {
+ align-self: center;
+ width: 100%;
+ max-width: 640px;
+ margin: 0 0 14px;
+ font-size: 13px;
+ line-height: 20px;
+ color: var(--pk-color-text-secondary);
+ text-align: center;
+}
+
+/* The agent marks that finish the eyebrow's sentence.
+
+ 16px against 13px text: the marks carry the set's optical inset, so their
+ artwork fills only the middle ~11px of the box — which lands on the text's cap
+ height. A 13px box would read as smaller than the words, not level with them.
+
+ Inline rather than flex so the line stays one run of text: the marks sit in
+ the sentence, and `text-align: center` on the parent keeps centring it as a
+ whole. The -3px lift centres the box on the cap without pushing the glyph past
+ the 20px line box, which `vertical-align: middle` would.
+
+ `display` is set explicitly because Tailwind's preflight resets `svg` to
+ `display: block`. Left at that the three marks each take their own line and
+ the eyebrow becomes four lines tall. */
+.hero-eyebrow-mark {
+ display: inline-block;
+ width: 16px;
+ height: 16px;
+ vertical-align: -3px;
+}
+
+.hero-eyebrow-mark:first-of-type {
+ margin-left: 6px;
+}
+
+.hero-eyebrow-mark + .hero-eyebrow-mark {
+ margin-left: 3px;
+}
+
+.hero-heading {
+ align-self: center;
+ width: 100%;
+ max-width: 640px;
+ margin: 0 0 12px;
+ font-size: 32px;
+ font-weight: 500;
+ line-height: 40px;
+ color: var(--pk-color-text-primary);
+ text-align: center;
+ letter-spacing: -0.45px;
+ text-wrap: balance;
+}
+
+/* Narrower than the 640px column the rest of the page uses: this is three
+ sentences of centred prose, and at 640 the ragged edges on both sides start
+ to read as a shape rather than as text. */
+.hero-sub {
+ align-self: center;
+ width: 100%;
+ max-width: 560px;
+ margin: 0 0 28px;
+ font-size: 14px;
+ line-height: 22px;
+ color: var(--pk-color-text-secondary);
+ text-align: center;
+ text-wrap: pretty;
+}
+
+.section-heading {
+ margin: 0 0 16px;
+ font-size: 18px;
+ font-weight: 500;
+ line-height: 28px;
+ color: var(--pk-color-text-primary);
+}
+
+.section-desc {
+ margin: 8px 0 0;
+ font-size: 14px;
+ line-height: 22px;
+ color: var(--pk-color-text-secondary);
+ text-wrap: pretty;
+}
+
+/* ── hero actions ──────────────────────────────────────────────────────── */
+
+.cta-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 20px;
+ align-items: center;
+ align-self: center;
+ justify-content: center;
+ width: 100%;
+ max-width: 640px;
+}
+
+.cta-primary {
+ display: inline-flex;
+ align-items: center;
+ padding: 11px 22px;
+ font-family: inherit;
+ font-size: 13px;
+ color: var(--pk-color-cta-text);
+ text-decoration: none;
+ cursor: pointer;
+ background: var(--pk-color-cta-bg);
+ border: none;
+ border-radius: var(--pk-radius-pill);
+ transition:
+ background 150ms ease,
+ transform 150ms ease;
+}
+
+.cta-primary:hover,
+.cta-primary:focus-visible {
+ background: var(--pk-color-cta-hover);
+}
+
+.cta-primary:active {
+ transform: scale(0.97);
+ transition:
+ background 150ms ease,
+ transform 100ms ease-out;
+}
+
+.cta-primary:focus-visible {
+ outline-offset: 3px;
+}
+
+/* The secondary action is bare text, not a button: it is a thing to copy, and
+ giving it a border would make the page look like it has two primary actions. */
+.hero-install {
+ display: flex;
+ gap: 6px;
+ align-items: center;
+ padding: 0;
+ font-family: inherit;
+ cursor: pointer;
+ background: none;
+ border: none;
+ transition: transform 150ms ease;
+}
+
+.hero-install:active {
+ transform: scale(0.97);
+ transition: transform 100ms ease-out;
+}
+
+.hero-install-cmd {
+ font-family: var(--pk-font-mono);
+ font-size: 11px;
+ line-height: 18px;
+ color: var(--pk-color-text-secondary);
+ transition: color 150ms ease;
+}
+
+.hero-install-icon {
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 14px;
+ height: 14px;
+ color: var(--pk-color-text-secondary);
+ transition: color 150ms ease;
+}
+
+.hero-install:hover .hero-install-cmd,
+.hero-install:focus-visible .hero-install-cmd,
+.hero-install:hover .hero-install-icon,
+.hero-install:focus-visible .hero-install-icon {
+ color: var(--pk-color-text-primary);
+}
+
+/* "How it works" is the one section that is not a stack of prose. Its cards and
+ the two figures they carry live in cards.css. */
+
+/* ── agent context block ───────────────────────────────────────────────── */
+
+.output-block {
+ margin-top: 32px;
+ overflow: hidden;
+ border: 1px solid var(--pk-color-border-faint);
+ border-radius: var(--pk-radius-xl);
+}
+
+.output-chrome {
+ padding: 10px 16px;
+ font-size: 11px;
+ color: var(--pk-color-text-muted);
+ background: var(--pk-color-surface-chrome);
+}
+
+/* `white-space: pre` and a scroller, not wrapping: the alignment of the `→`
+ column is the whole reason this reads as a payload rather than as prose. */
+.output-body {
+ padding: 20px 24px;
+ overflow-x: auto;
+ font-family: var(--pk-font-mono);
+ font-size: 12.5px;
+ line-height: 1.8;
+ color: var(--pk-color-text-muted);
+ white-space: pre;
+ background: var(--pk-color-surface-panel);
+}
+
+.output-heading {
+ font-weight: 500;
+ color: var(--pk-color-text-primary);
+}
+
+/*
+ * One step down from the payload body, and still 4.8:1 on white.
+ * `text-tertiary` measures 2.5:1 and is reserved for the aria-hidden hero
+ * illustration — see DESIGN.md § Character.
+ */
+.output-dim {
+ color: var(--pk-color-text-secondary);
+}
+
+.output-prop {
+ color: var(--pk-color-syntax-prop);
+}
+
+.output-old {
+ color: var(--pk-color-syntax-old);
+}
+
+.output-new {
+ color: var(--pk-color-syntax-new);
+}
+
+.output-hint {
+ font-style: italic;
+ color: var(--pk-color-syntax-new);
+}
+
+/* ── code blocks ───────────────────────────────────────────────────────── */
+
+.code-block {
+ position: relative;
+ padding: 20px 24px;
+ margin-top: 16px;
+ overflow-x: auto;
+ background: var(--pk-color-surface-panel);
+ border: 1px solid var(--pk-color-border-faint);
+ border-radius: var(--pk-radius-xl);
+}
+
+.code-line {
+ font-family: var(--pk-font-mono);
+ font-size: 13px;
+ line-height: 1.8;
+ color: var(--pk-color-text-muted);
+ white-space: pre;
+}
+
+.code-comment {
+ color: var(--pk-color-text-secondary);
+}
+
+.copy-btn {
+ position: relative;
+ display: flex;
+ flex-shrink: 0;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ color: var(--pk-color-text-secondary);
+ cursor: pointer;
+ background: transparent;
+ border: none;
+ border-radius: var(--pk-radius-md);
+ transition:
+ background 150ms ease,
+ color 150ms ease,
+ transform 150ms ease;
+}
+
+.copy-btn:hover,
+.copy-btn:focus-visible {
+ color: var(--pk-color-text-muted);
+ background: var(--pk-color-border-default);
+}
+
+.copy-btn:active {
+ transform: scale(0.92);
+ transition:
+ background 150ms ease,
+ color 150ms ease,
+ transform 100ms ease-out;
+}
+
+/* Hidden until the block is hovered — but always revealed on focus, or it would
+ be unreachable by keyboard. */
+.code-block .copy-btn {
+ position: absolute;
+ top: 10px;
+ right: 10px;
+ opacity: 0;
+ transition:
+ opacity 150ms ease,
+ background 150ms ease,
+ color 150ms ease;
+}
+
+.code-block:hover .copy-btn,
+.code-block .copy-btn:focus-visible {
+ opacity: 1;
+}
+
+/* Both icons are stacked and cross-faded rather than swapped, so the button
+ never changes size mid-transition. */
+.copy-icon {
+ position: absolute;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition:
+ opacity 150ms cubic-bezier(0.25, 0.46, 0.45, 0.94),
+ transform 150ms cubic-bezier(0.25, 0.46, 0.45, 0.94);
+}
+
+.copy-icon-in {
+ opacity: 1;
+ transform: scale(1);
+}
+
+.copy-icon-out {
+ opacity: 0;
+ transform: scale(0.75);
+}
+
+/* ── get started ───────────────────────────────────────────────────────── */
+
+.install-steps {
+ display: flex;
+ flex-direction: column;
+ gap: 32px;
+ margin-top: 32px;
+}
+
+.install-step {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.install-step-title {
+ margin: 0;
+ font-size: 14px;
+ font-weight: 400;
+ line-height: 22px;
+ color: var(--pk-color-text-primary);
+}
+
+.install-note {
+ margin: 8px 0 0;
+ font-size: 13px;
+ line-height: 22px;
+ color: var(--pk-color-text-secondary);
+ text-wrap: pretty;
+}
+
+.install-compat {
+ margin: 8px 0 0;
+ font-size: 13px;
+ line-height: 22px;
+ color: var(--pk-color-text-secondary);
+ text-wrap: pretty;
+}
+
+.install-note code,
+.faq-answer code {
+ padding: 2px 6px;
+ font-family: var(--pk-font-mono);
+ font-size: 12px;
+ background: var(--pk-color-surface-input);
+ border: 1px solid var(--pk-color-border-default);
+ border-radius: var(--pk-radius-sm);
+}
+
+/* ── FAQ ───────────────────────────────────────────────────────────────── */
+
+.faq-list {
+ margin-top: 20px;
+}
+
+.faq-item + .faq-item {
+ border-top: 1px solid var(--pk-color-border-subtle);
+}
+
+.faq-question {
+ display: flex;
+ gap: 16px;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+ padding: 16px 0;
+ font-family: inherit;
+ font-size: 14px;
+ font-weight: 400;
+ line-height: 22px;
+ color: var(--pk-color-text-secondary);
+ text-align: left;
+ cursor: pointer;
+ background: none;
+ border: none;
+}
+
+.faq-question:hover,
+.faq-question:focus-visible {
+ color: var(--pk-color-text-primary);
+}
+
+.faq-question:focus-visible {
+ outline-offset: -2px;
+ border-radius: var(--pk-radius-xs);
+}
+
+.faq-chevron {
+ flex-shrink: 0;
+ color: var(--pk-color-text-secondary);
+ transition: transform 300ms var(--pk-motion-ease-reveal);
+}
+
+.faq-item.open .faq-chevron {
+ transform: rotate(180deg);
+}
+
+/*
+ * `grid-template-rows: 0fr → 1fr` is the only way to animate to an unknown
+ * content height without measuring it in JS. The inner div must carry
+ * `overflow: hidden` and `min-height: 0` or the row will not actually collapse.
+ */
+.faq-answer {
+ display: grid;
+ grid-template-rows: 0fr;
+ transition: grid-template-rows 300ms var(--pk-motion-ease-reveal);
+}
+
+.faq-item.open .faq-answer {
+ grid-template-rows: 1fr;
+}
+
+/* Asymmetric on purpose: the answer fades out fast so the row can start
+ collapsing, and fades in late so it is not smeared across the expansion. */
+.faq-answer > div {
+ min-height: 0;
+ overflow: hidden;
+ opacity: 0;
+ filter: blur(2px);
+ transition:
+ opacity 100ms ease-out,
+ filter 100ms ease-out;
+}
+
+.faq-item.open .faq-answer > div {
+ opacity: 1;
+ filter: blur(0);
+ transition:
+ opacity 200ms ease-out 80ms,
+ filter 200ms ease-out 80ms;
+}
+
+.faq-answer > div > p {
+ padding-bottom: 16px;
+ margin: 0;
+ font-size: 14px;
+ line-height: 22px;
+ color: var(--pk-color-text-secondary);
+ text-wrap: pretty;
+}
+
+/* ── footer ──────────────────────────────────────────────────────────── */
+
+.footer {
+ align-self: center;
+ width: 100%;
+ max-width: 1120px;
+ padding-top: 56px;
+ border-top: 1px solid var(--pk-color-border-faint);
+}
+
+/*
+ * Two ends rather than a grid of columns.
+ *
+ * It was `grid-template-columns: 2fr 1fr 1fr` when there were two link columns.
+ * With one left a fixed track count either strands it in the middle of the row
+ * or leaves an empty column, so the row is now "brand at one edge, links at the
+ * other" and does not care how many columns there are.
+ */
+.footer-top {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 36px 48px;
+ justify-content: space-between;
+}
+
+.footer-brand {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ max-width: 300px;
+}
+
+.footer-logo {
+ display: flex;
+ gap: 7px;
+ align-items: center;
+ color: var(--pk-color-text-primary);
+}
+
+.footer-brand-mark {
+ flex-shrink: 0;
+ width: 20px;
+ height: 20px;
+}
+
+.footer-brand-name {
+ font-size: 14px;
+ font-weight: 500;
+ letter-spacing: -0.3px;
+}
+
+.footer-blurb {
+ margin: 0;
+ font-size: 13px;
+ line-height: 20px;
+ color: var(--pk-color-text-secondary);
+ text-wrap: pretty;
+}
+
+.footer-col {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ align-items: flex-start;
+}
+
+/* Uppercase micro-type, the one place on the page it appears. It has to label a
+ column without competing with the links under it, and going smaller in the
+ same case would just read as more links. */
+.footer-col-title {
+ margin: 0 0 2px;
+ font-size: 11px;
+ font-weight: 500;
+ line-height: 16px;
+ color: var(--pk-color-text-primary);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+}
+
+/* `inline-flex`, not the default inline: the external links carry a trailing
+ arrow, and as an inline box that arrow wraps onto a line of its own the moment
+ the label fills the column. */
+.footer-col-link {
+ display: inline-flex;
+ align-items: center;
+ font-size: 13px;
+ line-height: 20px;
+ color: var(--pk-color-text-secondary);
+ text-decoration: none;
+ transition: color 150ms ease;
+}
+
+.footer-col-link:hover,
+.footer-col-link:focus-visible {
+ color: var(--pk-color-text-primary);
+}
+
+/*
+ * The wordmark as art.
+ *
+ * Clipped rather than shrunk. The box is shorter than the line it contains, so
+ * the glyphs run off the bottom and only their top two thirds survive — which is
+ * what stops a 200px word from reading as a heading. `line-height: 0.75` pulls
+ * the letterforms up inside their own line box first, so the crop lands on the
+ * letters rather than on the leading under them.
+ *
+ * `surface-input` and not a text colour: this is a tint on the page, a shade off
+ * the background, and it must never approach reading contrast.
+ */
+.footer-art {
+ height: 0.5em;
+ margin: 56px 0 0;
+ overflow: hidden;
+ font-size: clamp(96px, 20vw, 268px);
+ line-height: 0.75;
+ text-align: center;
+ user-select: none;
+}
+
+.footer-art-word {
+ font-weight: 500;
+ color: var(--pk-color-surface-input);
+ letter-spacing: -0.045em;
+}
+
+/* ── the rail becomes a top bar ────────────────────────────────────────── */
+
+/* ── the column tightens ───────────────────────────────────────────────── */
+
+@media (max-width: 1240px) {
+ .header-inner {
+ padding: 14px 24px;
+ }
+
+ .content {
+ gap: 48px;
+ padding: 48px 24px 0;
+ }
+
+ .hero {
+ padding: 56px 24px 40px;
+ }
+
+ .hero-heading {
+ font-size: 24px;
+ line-height: 30px;
+ }
+
+ .section-heading {
+ font-size: 16px;
+ line-height: 24px;
+ }
+}
+
+/* ── the bar collapses ─────────────────────────────────────────────────── */
+
+/*
+ * Below this width the five inline links stop fitting beside the wordmark, so
+ * the nav folds onto a second row and collapses to zero height behind the
+ * hamburger. The wordmark and the hamburger itself stay on the first row and
+ * stay visible — a bar control you have to open a menu to reach is not a bar
+ * control.
+ */
+@media (max-width: 768px) {
+ .hamburger {
+ display: flex;
+ }
+
+ /*
+ * Explicit order, because DOM order is wrong here. `.toc` is between the logo
+ * and the controls in the markup — correct for tab order inline — but a
+ * full-basis flex item wraps everything after it, so left alone it would push
+ * the controls onto a third row of their own.
+ */
+ .header-actions {
+ order: 2;
+ }
+
+ .toc {
+ display: grid;
+ flex-basis: 100%;
+ grid-template-rows: 0fr;
+ justify-content: flex-start;
+ order: 3;
+ overflow: hidden;
+ /* Collapse waits for the items to finish fading out: the reverse stagger
+ spreads over ~90ms and each item fades for 100ms. */
+ transition: grid-template-rows 160ms var(--pk-motion-ease-reveal) 100ms;
+ }
+
+ .site-header.menu-open .toc {
+ grid-template-rows: 1fr;
+ transition: grid-template-rows 250ms var(--pk-motion-ease-reveal);
+ }
+
+ /*
+ * `visibility` is what keeps the closed menu out of the tab order. Clipping it
+ * to zero height hides it from the eye but not from the keyboard, and tabbing
+ * into links nobody can see is worse than not having them. Delayed out by the
+ * full collapse duration (160ms + 100ms) so it lands after the animation
+ * rather than cutting it short, and restored with no delay on the way in.
+ */
+ .toc-inner {
+ visibility: hidden;
+ flex-direction: column;
+ align-items: flex-start;
+ align-self: stretch;
+ width: auto;
+ min-height: 0;
+ overflow: hidden;
+ transition: visibility 0s 260ms;
+ }
+
+ /*
+ * The menu's breathing room, as child margins rather than padding on
+ * `.toc-inner`.
+ *
+ * That distinction is load-bearing, not stylistic. Padding on a grid item
+ * cannot be compressed below its own size, even at `height: 0` with
+ * `box-sizing: border-box` — so `padding-top: 12px` here left the CLOSED menu
+ * as a 12px strip of dead space under the bar. That made the sticky header
+ * 72px on a phone, which cancelled the 72px of anchor clearance at the top of
+ * this file exactly: every jump landed flush against the chrome. Margins on
+ * the children sit inside the clipped box and go to nothing.
+ */
+ .toc-inner > :first-child {
+ margin-top: 12px;
+ }
+
+ .toc-inner > :last-child {
+ margin-bottom: 4px;
+ }
+
+ .site-header.menu-open .toc-inner {
+ visibility: visible;
+ transition: visibility 0s;
+ }
+
+ .toc-sep {
+ width: 32px;
+ height: 1px;
+ margin: 8px 0 8px 24px;
+ }
+
+ /*
+ * The stagger below is keyed to the number of children in `.toc-inner`, which
+ * is TOC_CHILD_COUNT in content/nav.ts — currently 5 (3 sections, a rule, and
+ * 1 external link). Rules run to :nth-child(8), so there is room to add links
+ * before this needs touching; past that a new link is simply un-animated,
+ * which is a quiet enough failure to be worth this comment.
+ */
+ .toc-link {
+ align-self: flex-start;
+ padding: 8px 0;
+ margin-left: 24px;
+ opacity: 0;
+ filter: blur(3px);
+ transition: opacity 100ms ease-out;
+ }
+
+ /* Exit: last item leaves first. */
+ .toc-inner > :nth-child(1) {
+ transition-delay: 90ms;
+ }
+ .toc-inner > :nth-child(2) {
+ transition-delay: 75ms;
+ }
+ .toc-inner > :nth-child(3) {
+ transition-delay: 60ms;
+ }
+ .toc-inner > :nth-child(4) {
+ transition-delay: 45ms;
+ }
+ .toc-inner > :nth-child(5) {
+ transition-delay: 30ms;
+ }
+ .toc-inner > :nth-child(6) {
+ transition-delay: 15ms;
+ }
+ .toc-inner > :nth-child(7) {
+ transition-delay: 0ms;
+ }
+ .toc-inner > :nth-child(8) {
+ transition-delay: 0ms;
+ }
+
+ .site-header.menu-open .toc-link {
+ opacity: 1;
+ filter: blur(0);
+ transition:
+ opacity 200ms ease-out,
+ filter 200ms ease-out;
+ }
+
+ /* Entry: first item arrives first, 30ms apart. */
+ .site-header.menu-open .toc-inner > :nth-child(1) {
+ transition-delay: 30ms;
+ }
+ .site-header.menu-open .toc-inner > :nth-child(2) {
+ transition-delay: 60ms;
+ }
+ .site-header.menu-open .toc-inner > :nth-child(3) {
+ transition-delay: 90ms;
+ }
+ .site-header.menu-open .toc-inner > :nth-child(4) {
+ transition-delay: 120ms;
+ }
+ .site-header.menu-open .toc-inner > :nth-child(5) {
+ transition-delay: 150ms;
+ }
+ .site-header.menu-open .toc-inner > :nth-child(6) {
+ transition-delay: 180ms;
+ }
+ .site-header.menu-open .toc-inner > :nth-child(7) {
+ transition-delay: 210ms;
+ }
+ .site-header.menu-open .toc-inner > :nth-child(8) {
+ transition-delay: 240ms;
+ }
+
+ .desktop-only {
+ display: none;
+ }
+
+ /* Brand and links stack rather than sitting at opposite edges: `space-between`
+ across a narrow row would strand the one link column against the right edge
+ with the blurb wrapping to four words beside it. */
+ .footer-top {
+ flex-direction: column;
+ gap: 32px;
+ }
+
+ .footer-brand {
+ max-width: 420px;
+ }
+
+ .footer-art {
+ margin: 44px 0 0;
+ }
+}
+
+@media (max-width: 640px) {
+ .header-inner {
+ padding: 12px 16px;
+ }
+
+ .hero-install {
+ display: none;
+ }
+
+ .content {
+ gap: 40px;
+ padding: 40px 16px 0;
+ }
+
+ .hero {
+ padding: 40px 16px 32px;
+ }
+
+ .hero-heading {
+ font-size: 22px;
+ line-height: 28px;
+ }
+
+ .hero-sub {
+ margin-bottom: 24px;
+ font-size: 13px;
+ line-height: 20px;
+ }
+
+ .section-heading {
+ font-size: 15px;
+ line-height: 22px;
+ }
+
+ .section-desc {
+ font-size: 13px;
+ line-height: 20px;
+ }
+
+ .code-block {
+ padding: 14px 16px;
+ }
+
+ .code-line {
+ font-size: 12px;
+ }
+
+ .output-body {
+ padding: 14px 16px;
+ }
+
+ .cta-primary {
+ padding: 10px 18px;
+ font-size: 12px;
+ }
+
+ .install-steps {
+ gap: 24px;
+ }
+
+ .faq-question {
+ padding: 14px 0;
+ font-size: 13px;
+ }
+
+ .faq-answer > div > p {
+ padding-bottom: 12px;
+ font-size: 13px;
+ line-height: 20px;
+ }
+
+ .footer {
+ padding-top: 36px;
+ }
+}
+
+/* ── reduced motion ────────────────────────────────────────────────────── */
+
+@media (prefers-reduced-motion: reduce) {
+ html {
+ scroll-behavior: auto;
+ }
+
+ /*
+ * The FAQ keeps its grid-row animation off but must still expand, so the
+ * answer's opacity and blur are forced to their open values rather than left
+ * at the closed defaults — otherwise disabling the transition would leave the
+ * text permanently invisible.
+ */
+ .faq-answer,
+ .faq-chevron,
+ .hamburger-line,
+ .toc,
+ .cta-primary,
+ .footer-col-link,
+ .copy-btn,
+ .copy-icon,
+ .hero-install-cmd,
+ .hero-install-icon,
+ .toc-link,
+ .skip-link {
+ transition: none;
+ }
+
+ .faq-answer > div {
+ opacity: 1;
+ filter: none;
+ transition: none;
+ }
+}
+
+/* Smooth scrolling is opt-in for everyone else. The offset that clears the
+ sticky bar is not — it lives unconditionally at the top of this file. */
+@media (prefers-reduced-motion: no-preference) {
+ html {
+ scroll-behavior: smooth;
+ }
+}