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