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 ( + + ); +} 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 ( + + ); +} diff --git a/apps/web/src/components/sections/agent-output-section.tsx b/apps/web/src/components/sections/agent-output-section.tsx new file mode 100644 index 0000000..3d7d9f1 --- /dev/null +++ b/apps/web/src/components/sections/agent-output-section.tsx @@ -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 = { + dim: "output-dim", + heading: "output-heading", + hint: "output-hint", + new: "output-new", + old: "output-old", + plain: "", + prop: "output-prop", +}; + +export function AgentOutputSection() { + return ( +
+ +
+
{AGENT_OUTPUT_SECTION.chromeLabel}
+ {/* + One
 holding the whole payload rather than a 
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. + */} +
+          {AGENT_OUTPUT.map((line) => (
+            
+              {line.tokens.map((token) => (
+                
+                  {token.text}
+                
+              ))}
+              {"\n"}
+            
+          ))}
+        
+
+
+ ); +} 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) => ( +
  1. +

    {step.title}

    + + {step.note ?

    {step.note}

    : null} +
  2. + ))} +
+

{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 ( +
+

+ {HERO.eyebrow} + {AGENT_MARKS.map((mark) => ( + + {mark.name} + {/* 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. */} + + + ))} +

+

{HERO.heading}

+

{HERO.sub}

+ + +
+ ); +} 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 ( +
+ + +
+ {STEPS.map((step) => { + const Figure = FIGURES[step.figure]; + return ( +
+
+
+
+

{step.title}

+

{step.body}

+
+ ); + })} +
+
+ ); +} 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 ( +
+
+
+ + + {SITE.name} + +

{FOOTER.blurb}

+
+ + {FOOTER_COLUMNS.map((column) => ( + + ))} +
+ + {/* 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. */} + +
+ ); +} 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; + } +}