feat(web): the hero stage and its airship mock

The hero is a mock of the editor rather than a screenshot: the same inspector
sections, dock, composer and picker chrome, rebuilt in React so it scales, scrubs
with scroll and stays legible at any width.

A screenshot would have been less code and would have been wrong within a week.
This commit is contained in:
Nayan
2026-08-08 15:24:00 +05:30
parent e5bd2b2c18
commit 7335196fad
23 changed files with 4661 additions and 0 deletions
@@ -0,0 +1,33 @@
import { MOCK_TOOL_CALLS } from "#/content/mock-page";
/**
* The agent's tool calls, streaming.
*
* Rendered in Claude Code's own transcript grammar — a filled marker and a bold
* tool name for the call, an indented `⎿` elbow for its result — because that is
* exactly what airship's real overlay renders. Someone who has used the tool
* should recognise this before they read a word of it.
*
* Every row is present in the DOM from the start and revealed by the timeline;
* nothing is mounted or unmounted, so the transcript's height never jumps
* mid-stream.
*/
export function AgentTimeline() {
return (
<div className="ap-tl">
{MOCK_TOOL_CALLS.map((call, index) => (
<div key={call.tool + call.args}>
<div className={`ap-tl-head ap-tl-head-${index + 1}`}>
<span className="ap-tl-marker">&#9679;</span>
<span className="ap-tl-tool">{call.tool}</span>
<span className="ap-tl-args">{call.args}</span>
</div>
<div className={`ap-tl-res ap-tl-res-${index + 1}`}>
<span className="ap-tl-elbow">&#8971;</span>
<span>{call.result}</span>
</div>
</div>
))}
</div>
);
}
@@ -0,0 +1,70 @@
import { EditorGlyph } from "#/components/hero/airship-mock/editor-glyph";
/**
* The floating bottom bar.
*
* Compact and understated on purpose: a small radius, a hairline border and a
* ring rather than a pill with a drop shadow. It is the one piece of chrome that
* is always on screen, and the version of it that announces itself gets tiring
* within a minute.
*
* Left to right: undo/redo, the tool group, inspect, the Edit|View toggle, and
* the pending-tweak counter — which is the only thing on the bar that moves.
*/
export function BottomBar() {
return (
<div className="ap-bar">
<span className="ap-tool-group">
<span className="ap-tool">
<EditorGlyph name="undo" size={20} />
</span>
</span>
<span className="ap-bar-sep" />
<span className="ap-tool-group">
<span className="ap-tool ap-tool-on">
<EditorGlyph name="cursor" size={20} />
</span>
<span className="ap-tool">
<EditorGlyph name="hand" size={20} />
</span>
<span className="ap-tool">
<EditorGlyph name="text" size={20} />
</span>
</span>
<span className="ap-bar-sep" />
<span className="ap-tool-group">
<span className="ap-tool">
<EditorGlyph name="inspect" size={20} />
</span>
</span>
<span className="ap-bar-sep" />
<span className="ap-seg-group">
<span className="ap-seg ap-seg-on">
<EditorGlyph name="edit" size={14} />
Edit
</span>
<span className="ap-seg">
<EditorGlyph name="view" size={14} />
View
</span>
</span>
{/*
Collapsed to zero width until the first tweak lands, so the bar has no
empty slot waiting to be filled — it simply grows.
*/}
<span className="ap-tweak-badge-wrap">
<span className="ap-tweak-badge">
<span className="ap-tweak-count-1">1</span>
<span className="ap-tweak-count-2">2</span>
</span>
</span>
</div>
);
}
@@ -0,0 +1,40 @@
import { EditorGlyph } from "#/components/hero/airship-mock/editor-glyph";
import { MOCK_PROMPT, MOCK_SELECTION } from "#/content/mock-page";
/**
* Where you say what you want.
*
* One bordered box holding the selection chip, the prompt and the send button —
* which is the shape of airship's real composer, and the shape of the claim: the
* chip says *what*, the sentence says *what about it*, and that is the entire
* interface.
*
* The prompt is real text clipped from the right by the timeline rather than a
* string assembled by a timer. That keeps the typing honest under the pause
* button and under `prefers-reduced-motion`, and means it costs nothing when the
* hero is scrolled out of view.
*/
export function ComposerField() {
return (
<div className="ap-composer">
<div className="ap-field">
<div className="ap-chips">
<span className="ap-sel-chip">
{MOCK_SELECTION.tag}
<span className="ap-chip-x">&#215;</span>
</span>
</div>
<div className="ap-input">
<span className="ap-placeholder">Describe the change…</span>
<span className="ap-typed">{MOCK_PROMPT}</span>
<span className="ap-caret" />
</div>
<span className="ap-send">
<EditorGlyph name="chevronUp" size={16} />
</span>
</div>
</div>
);
}
@@ -0,0 +1,214 @@
import { AgentTimeline } from "#/components/hero/airship-mock/agent-timeline";
import { ComposerField } from "#/components/hero/airship-mock/composer-field";
import { DiffCard } from "#/components/hero/airship-mock/diff-card";
import { EditorGlyph } from "#/components/hero/airship-mock/editor-glyph";
import {
NumField,
PaintRow,
Segmented,
Select,
SwapValue,
} from "#/components/hero/airship-mock/inspector-controls";
import { InspectorSection } from "#/components/hero/airship-mock/inspector-section";
import { MOCK_PROMPT, MOCK_SELECTION, MOCK_STATUS } from "#/content/mock-page";
import { TWEAKS } from "#/content/timeline";
/*
* The nine align cells, in the real panel's order: horizontal, vertical, then
* distribute-and-tidy.
*/
const ALIGN_GROUPS = [
["alignLeft", "alignCenterH", "alignRight"],
["alignTop", "alignCenterV", "alignBottom"],
["distributeH", "distributeV", "tidy"],
] as const;
const TABS = [
{ glyph: "logo", label: "Agent" },
{ glyph: "design", label: "Edit" },
{ glyph: "code", label: "CSS" },
{ glyph: "layers", label: "DOM" },
] as const;
/**
* One dock, four tabs.
*
* ── A deliberate departure from the real editor ──────────────────────────────
* airship actually runs TWO docks: chat on the left at 340px, inspector on the
* right at 360px. This mock folds the chat in as an *Agent* tab instead.
*
* That is a simplification for the hero specifically, and it is worth being
* honest about. Two 200px panels flanking a 285px strip of page reads as clutter
* at this render scale, and it splits attention exactly where the page is trying
* to make one point. One panel with the agent in front says the same thing more
* quietly — and the Edit / CSS / DOM tabs beside it still show that a full
* inspector is part of the product.
*
* Everything INSIDE the tabs is transcribed 1:1 as usual. It is the arrangement
* that is editorial, not the components.
*/
export function DesignDock() {
return (
<div className="ap-dock ap-dock-right">
<div className="ap-head">
<span className="ap-brand">
<EditorGlyph name="logo" size={20} />
<span className="ap-brand-name">Airship</span>
</span>
<span className="ap-head-actions">
<span className="ap-iconbtn">
<EditorGlyph name="plus" size={16} />
</span>
<span className="ap-iconbtn">
<EditorGlyph name="panelRight" size={16} />
</span>
</span>
</div>
{/*
Empty and filled states stack in one relative box and cross-fade, so
neither ever reflows the dock.
*/}
<div className="ap-insp-stage">
<div className="ap-empty ap-insp-empty">
<EditorGlyph className="ap-empty-art" name="logo" size={48} />
<span className="ap-empty-title">Ask airship to change anything</span>
<span className="ap-empty-body">
Pick an element first to scope it.
</span>
</div>
<div className="ap-insp ap-insp-filled">
<div className="ap-insp-tabs">
{TABS.map((tab) => (
<span
className={`ap-insp-tab ap-tab-${tab.label.toLowerCase()}`}
key={tab.label}
>
<EditorGlyph name={tab.glyph} size={18} />
{tab.label}
</span>
))}
</div>
{/* Both tab bodies stacked; the timeline decides which is in front. */}
<div className="ap-tab-stage">
<div className="ap-tab-body ap-tab-body-agent">
<div className="ap-transcript">
<div className="ap-msg-user">{MOCK_PROMPT}</div>
<div className="ap-msg-assistant">
<AgentTimeline />
<div className="ap-turn-status">
<span className="ap-dot" />
{/* Both labels in one grid cell, so the row does not
re-wrap when "Applying…" becomes "Done". */}
<span className="ap-status-label">
<span className="ap-status-working">
{MOCK_STATUS.working}
</span>
<span className="ap-status-done">{MOCK_STATUS.done}</span>
</span>
</div>
<DiffCard />
</div>
</div>
<ComposerField />
</div>
<div className="ap-tab-body ap-tab-body-edit">
<div className="ap-insp-scroll">
<InspectorSection className="ap-insp-row" label="Source">
<span className="ap-src">
{MOCK_SELECTION.sourceFile}:{MOCK_SELECTION.sourceLine}
</span>
</InspectorSection>
<div className="ap-align-row ap-insp-row">
{ALIGN_GROUPS.map((group) => (
<span className="ap-align-grp" key={group[0]}>
{group.map((glyph) => (
<span className="ap-align-btn" key={glyph}>
<EditorGlyph name={glyph} size={20} />
</span>
))}
</span>
))}
</div>
<InspectorSection className="ap-insp-row" label="Size">
<div className="ap-grid">
<NumField letter="W" value="122" />
<NumField letter="H" value="45" />
</div>
</InspectorSection>
<InspectorSection className="ap-insp-row" label="Auto layout" />
<InspectorSection className="ap-insp-row" label="Text" />
<InspectorSection className="ap-insp-row" label="Appearance">
<div className="ap-grid">
<NumField glyph="opacity" suffix="%" value="100" />
<NumField
className="ap-field-radius"
glyph="radius"
value={
<SwapValue
from={TWEAKS.radius.from}
name="radius"
to={TWEAKS.radius.to}
/>
}
/>
</div>
<div className="ap-row">
<span className="ap-row-label">Blend</span>
<Select value="Normal" />
</div>
</InspectorSection>
<InspectorSection className="ap-insp-row" label="Fill">
<div className="ap-rows">
<div className="ap-rows-row">
<PaintRow
alpha="100"
hex={
<SwapValue
from={TWEAKS.fill.from}
name="fill"
to={TWEAKS.fill.to}
/>
}
hexClassName="ap-field-fill"
swatchClassName="ap-swatch-fill"
/>
<span className="ap-row-icon">
<EditorGlyph name="minus" size={16} />
</span>
</div>
</div>
</InspectorSection>
<InspectorSection className="ap-insp-row" label="Stroke">
<div className="ap-row">
<span className="ap-row-label">Position</span>
<Segmented
activeIndex={1}
options={["In", "Center", "Out"]}
/>
</div>
</InspectorSection>
<InspectorSection className="ap-insp-row" label="Effects" />
</div>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,29 @@
import { MOCK_DIFF } from "#/content/mock-page";
/**
* The diff the agent produced, before you accept it.
*
* Collapsed to `max-height: 0` rather than unmounted, so it can grow open on the
* timeline. The header carries the two things you check first — which file, and
* how much changed — and only then the body.
*/
export function DiffCard() {
return (
<div className="ap-diff">
<div className="ap-diff-head">
<span className="ap-diff-file">{MOCK_DIFF.file}</span>
<span className="ap-diff-stat">{MOCK_DIFF.stat}</span>
</div>
<div className="ap-diff-body">
{MOCK_DIFF.lines.map((line, index) => (
<div
className={`ap-diff-line ap-diff-${line.kind} ap-diff-line-${index + 1}`}
key={line.text}
>
{line.text}
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,40 @@
import { GLYPHS, type GlyphName } from "#/content/editor-glyphs";
/**
* Every glyph in the hero's editor, rendered from one place.
*
* `aria-hidden` is not a prop: the entire hero is an illustration, and there is
* no glyph in it that carries meaning a screen reader should hear. Hard-coding
* it here means `noSvgWithoutTitle` is satisfied once rather than at twenty call
* sites, and it cannot be forgotten at the twenty-first.
*
* The body is injected as markup because the path data is vendored-shaped
* constant text; turning it into JSX elements would buy nothing and would let
* the formatter reflow coordinates that are meant to stay byte-stable.
*/
export function EditorGlyph({
className,
name,
size = 24,
}: {
className?: string;
name: GlyphName;
size?: number;
}) {
return (
<svg
aria-hidden="true"
className={className}
fill="none"
height={size}
viewBox="0 0 24 24"
width={size}
xmlns="http://www.w3.org/2000/svg"
>
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: GLYPHS is a
module-level constant of literal SVG markup, keyed by a union type —
there is no path through which caller input reaches this string. */}
<g dangerouslySetInnerHTML={{ __html: GLYPHS[name].body }} />
</svg>
);
}
@@ -0,0 +1,36 @@
import { BottomBar } from "#/components/hero/airship-mock/bottom-bar";
import { DesignDock } from "#/components/hero/airship-mock/design-dock";
import { MockPage } from "#/components/hero/airship-mock/mock-page";
import { PickerOverlay } from "#/components/hero/airship-mock/picker-overlay";
/**
* Airship in inline mode: the overlay injected straight into the running app.
*
* `.ap-mock` is authored at a fixed 1200×636 and shrunk with a single
* `transform: scale()`, so every number in hero-overlay.css is the real editor's
* number rather than a pre-divided one. It is also the scope the `--ap-*` editor
* palette is generated into, and — because it carries a transform — the
* containing block the docks position against.
*
* Order matters: the page paints first, the picker's chrome layer sits above it,
* and the docks sit above that. In the real overlay the same stack is enforced
* with z-index for the same reason — a selection outline drawn under a panel is
* a selection you cannot see.
*
* One dock, with the agent in front. The real editor runs two — chat left,
* inspector right — and folding them together is a deliberate simplification for
* the hero; see the note in design-dock.tsx. What is NOT negotiable is that the
* agent is the tab you land on: an earlier draft showed only the inspector and,
* without meaning to, argued that airship is a visual CSS editor that happens to
* write files.
*/
export function InlineOverlay() {
return (
<div className="ap-mock">
<MockPage />
<PickerOverlay />
<DesignDock />
<BottomBar />
</div>
);
}
@@ -0,0 +1,161 @@
import type { ReactNode } from "react";
import { EditorGlyph } from "#/components/hero/airship-mock/editor-glyph";
import type { GlyphName } from "#/content/editor-glyphs";
/*
* The four control primitives the inspector is built from.
*
* All static: this is a picture of a panel, not a panel. Nothing here takes an
* onChange, and the values that appear to change during the loop are driven by
* CSS on `.ap-ctl-value`, not by state — which is what lets the whole animation
* survive `animation-play-state: paused` and `prefers-reduced-motion` without a
* single line of JavaScript.
*/
/**
* A number field. Borderless until touched; the glyph is also the scrub handle,
* which is why it is 20px and sits inside the field rather than beside it.
*
* `letter` renders a mono character (W, H, X, Y) where the real panel has no
* pictogram for the property — the letter IS the icon there.
*/
export function NumField({
className,
glyph,
letter,
suffix,
value,
valueClassName,
}: {
className?: string;
glyph?: GlyphName;
letter?: string;
suffix?: string;
value: ReactNode;
valueClassName?: string;
}) {
return (
<div className={className ? `ap-ctl-num ${className}` : "ap-ctl-num"}>
<span className="ap-ctl-glyph">
{glyph ? <EditorGlyph name={glyph} size={20} /> : null}
{letter ? <span className="ap-ctl-glyph-txt">{letter}</span> : null}
</span>
<span
className={
valueClassName ? `ap-ctl-value ${valueClassName}` : "ap-ctl-value"
}
>
{value}
</span>
{suffix ? <span className="ap-ctl-suffix">{suffix}</span> : null}
</div>
);
}
/**
* A value that changes during the loop, rendered as both states at once.
*
* CSS cannot rewrite text, so the two readings are stacked in a single grid cell
* and cross-faded. The grid — rather than absolute positioning — is what makes
* the field size to the WIDER of the two ("9999", not "6"), so the panel does
* not reflow at the moment the value changes. That reflow is exactly the sort of
* thing that reads as a glitch rather than as an edit.
*/
export function SwapValue({
from,
name,
to,
}: {
from: string;
name: string;
to: string;
}) {
return (
<span className="ap-swap">
<span className={`ap-swap-from ap-swap-from-${name}`}>{from}</span>
<span className={`ap-swap-to ap-swap-to-${name}`}>{to}</span>
</span>
);
}
/** A dropdown. Bordered at rest — it opens something, so it looks like it does. */
export function Select({ value }: { value: string }) {
return (
<span className="ap-select">
{value}
<EditorGlyph name="chevronDown" size={16} />
</span>
);
}
/**
* A segmented control. Text options stay pills; an all-icon group becomes square
* 24px cells, because a row of icon pills reads as five separate buttons.
*/
export function Segmented({
activeIndex,
icons,
options,
}: {
activeIndex: number;
icons?: readonly GlyphName[];
options?: readonly string[];
}) {
const cell = (index: number) =>
index === activeIndex ? "ap-ctl-seg-btn ap-ctl-seg-on" : "ap-ctl-seg-btn";
// Two branches rather than one loop with a ternary inside. A shared loop needs
// `item as GlyphName` to satisfy the union, which discards exactly the check
// that makes the glyph names safe in the first place.
if (icons) {
return (
<span className="ap-ctl-seg ap-ctl-seg-icon">
{icons.map((name, index) => (
<span className={cell(index)} key={name}>
<EditorGlyph name={name} size={16} />
</span>
))}
</span>
);
}
return (
<span className="ap-ctl-seg">
{(options ?? []).map((label, index) => (
<span className={cell(index)} key={label}>
{label}
</span>
))}
</span>
);
}
/** A colour swatch over a conic checkerboard, plus its hex and alpha fields. */
export function PaintRow({
alpha,
hex,
hexClassName,
swatchClassName,
}: {
alpha: string;
hex: ReactNode;
hexClassName?: string;
swatchClassName?: string;
}) {
return (
<span className="ap-paint-row">
<span
className={
swatchClassName ? `ap-ctl-swatch ${swatchClassName}` : "ap-ctl-swatch"
}
/>
<NumField
className={
hexClassName ? `ap-paint-hex ${hexClassName}` : "ap-paint-hex"
}
value={hex}
/>
<NumField className="ap-paint-pct" suffix="%" value={alpha} />
</span>
);
}
@@ -0,0 +1,38 @@
import type { ReactNode } from "react";
import { EditorGlyph } from "#/components/hero/airship-mock/editor-glyph";
/**
* One collapsible section of the inspector.
*
* The header puts its title first and its chevron last, so every arrow in the
* panel lines up in a single column down the right edge however long the title
* is. That is a deliberate choice in the real panel and it is the detail that
* makes a stack of twelve sections read as one control rather than twelve.
*
* A section with no `children` renders header-only — which is also what the real
* panel does for SOURCE and FILTERS, and what keeps this mock legible: at the
* hero's render scale, twelve expanded sections would be grey noise.
*/
export function InspectorSection({
children,
className,
label,
}: {
children?: ReactNode;
className?: string;
label: string;
}) {
return (
<div className={className ? `ap-sect ${className}` : "ap-sect"}>
<div className="ap-sect-head">
{label}
<EditorGlyph
className="ap-sect-chev"
name={children ? "chevronUp" : "chevronDown"}
size={16}
/>
</div>
{children ? <div className="ap-sect-body">{children}</div> : null}
</div>
);
}
@@ -0,0 +1,56 @@
import { EditorGlyph } from "#/components/hero/airship-mock/editor-glyph";
import { MOCK_PAGE } from "#/content/mock-page";
/**
* The app being edited: this site, in miniature.
*
* Not a fictional product — `make run` points the CLI at apps/web, so the page
* inside the hero's browser window really is the page around it. The button the
* agent changes is the same button a reader can see a few hundred pixels above.
*
* `.ap-cta` carries the id the measuring effect looks for, and its radius and
* fill come from custom properties the timeline drives. Everything else here is
* scenery — it exists so the picker has somewhere plausible to hover.
*/
export function MockPage() {
return (
<div className="ap-page">
<div className="ap-page-nav">
<span className="ap-page-brand">
<EditorGlyph name="logo" size={24} />
{MOCK_PAGE.brand}
</span>
<span className="ap-page-links">
{MOCK_PAGE.nav.map((item) => (
<span key={item}>{item}</span>
))}
</span>
</div>
<div className="ap-page-hero">
{/*
A <p>, not an <h2>. This is a picture of a heading inside an
aria-hidden illustration; giving it a real heading element puts a
phantom entry in the document outline that no reader can reach.
*/}
<p className="ap-page-heading">{MOCK_PAGE.heading}</p>
<p className="ap-page-sub">{MOCK_PAGE.sub}</p>
<div className="ap-page-actions">
<span className="ap-cta" id="ap-cta">
{MOCK_PAGE.ctaLabel}
</span>
<span className="ap-cta-secondary">{MOCK_PAGE.secondaryLabel}</span>
</div>
</div>
<div className="ap-page-cards">
{MOCK_PAGE.cards.map((card) => (
<div className="ap-page-card" key={card.title}>
<p className="ap-page-card-title">{card.title}</p>
<p className="ap-page-card-body">{card.body}</p>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,41 @@
import { MOCK_SELECTION } from "#/content/mock-page";
const HANDLES = ["nw", "n", "ne", "e", "se", "s", "sw", "w"] as const;
/**
* What the picker draws over the element under the cursor.
*
* Both boxes are absolutely positioned on a full-mock chrome layer and share one
* geometry, set by the measuring effect into `--pick-*`. That mirrors the real
* overlay, where hover and selection are two boxes on the same layer rather than
* one box that changes style — they need to be able to coexist, and they differ
* in more than colour: hover is 1.5px over a wash, selection is 2px with no fill
* and a white outer ring so it stays visible against a light app.
*/
export function PickerOverlay() {
const geometry = {
height: "var(--pick-h, 44px)",
left: "var(--pick-x, 56px)",
top: "var(--pick-y, 366px)",
width: "var(--pick-w, 148px)",
};
return (
<div className="ap-chrome-layer">
<div className="ap-hover-box" style={geometry}>
<span className="ap-box-label ap-box-label-dim">
{MOCK_SELECTION.tag}
</span>
</div>
<div className="ap-sel-box" style={geometry}>
<span className="ap-box-label">
{MOCK_SELECTION.tag} · {MOCK_SELECTION.size}
</span>
{HANDLES.map((handle) => (
<span className={`ap-handle ap-handle-${handle}`} key={handle} />
))}
</div>
</div>
);
}
@@ -0,0 +1,113 @@
import type { ReactNode } from "react";
/**
* The Safari window the app is served into.
*
* The URL reads `localhost:3000` — the dev server's port, not airship's. In
* inline mode the overlay is injected into the app's own page, so the address
* never changes; that is the claim the whole hero is making, and getting the
* port wrong here would quietly contradict it.
*/
export function BrowserWindow({ children }: { children: ReactNode }) {
return (
<div className="browser-window">
<div className="browser-chrome">
<div className="browser-dots">
<span className="dot dot-red" />
<span className="dot dot-yellow" />
<span className="dot dot-green" />
</div>
<div className="safari-pill">
<NavArrow className="safari-btn safari-back" />
<NavArrow className="safari-btn safari-fwd" forward />
</div>
<div className="safari-url-wrap">
<div className="safari-pill browser-url">
localhost:3000
<ReloadIcon />
</div>
</div>
<div className="safari-pill">
<ShareIcon />
</div>
</div>
<div className="browser-content">{children}</div>
</div>
);
}
function NavArrow({
className,
forward,
}: {
className: string;
forward?: boolean;
}) {
return (
<svg
aria-hidden="true"
className={className}
fill="none"
height="9"
viewBox="0 0 10 10"
width="9"
xmlns="http://www.w3.org/2000/svg"
>
<path
d={forward ? "M3.8 2L6.8 5L3.8 8" : "M6.2 2L3.2 5L6.2 8"}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.3"
/>
</svg>
);
}
function ReloadIcon() {
return (
<svg
aria-hidden="true"
className="safari-reload"
fill="none"
height="8"
viewBox="0 0 10 10"
width="8"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8 5a3 3 0 1 1-.9-2.1M8 1.4v1.9H6.1"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.1"
/>
</svg>
);
}
function ShareIcon() {
return (
<svg
aria-hidden="true"
className="safari-btn"
fill="none"
height="9"
viewBox="0 0 10 10"
width="9"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5 1v5.4M3.2 2.6L5 .9l1.8 1.7M2 5.6v3h6v-3"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.1"
/>
</svg>
);
}
@@ -0,0 +1,20 @@
import type { ReactNode } from "react";
/**
* The wallpaper the hero's window sits on: a full-bleed band, edge to edge.
*
* It used to carry a menu bar and a dock as well, and was boxed at 800px with
* rounded corners so the three together read as a screenshot of a Mac. Once the
* band spans the viewport that conceit stops working — chrome authored at 8.5px
* to be seen inside a shrunken desktop is simply small type at 1:1 — so the
* wallpaper now stands on its own and the window is the only thing on it.
*
* `overflow: clip` stays: nothing overhangs any more, but it is what guarantees
* a full-width band can never produce a horizontal scrollbar.
*
* The cursor is still rendered as a SIBLING of this element rather than as a
* child — see the comment in hero-stage.tsx, which owns that constraint.
*/
export function DesktopBackdrop({ children }: { children: ReactNode }) {
return <div className="desktop-bg">{children}</div>;
}
+131
View File
@@ -0,0 +1,131 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { InlineOverlay } from "#/components/hero/airship-mock/inline-overlay";
import { BrowserWindow } from "#/components/hero/browser-window";
import { DesktopBackdrop } from "#/components/hero/desktop-backdrop";
import { MockCursor } from "#/components/hero/mock-cursor";
import { cn } from "#/lib/cn";
import { measureHero } from "#/lib/measure-hero";
import { seekTo, useFrameScrub } from "#/lib/use-frame-scrub";
import { useIsomorphicLayoutEffect } from "#/lib/use-isomorphic-layout-effect";
/**
* The hero's animated still-life of the editor.
*
* `.mock-cursor` is a SIBLING of `.desktop-bg` rather than a child, and that is
* load-bearing: its `left`/`top` are percentages, and they must resolve against
* `.hero-visual` rather than against the desktop's padding box — which would
* offset every measured target by 80px.
*
* The whole thing is `aria-hidden`: it is an illustration of the product, and
* narrating a fake cursor moving across a fake inspector is noise. Everything it
* demonstrates is stated in prose in the sections below it.
*/
export function HeroStage() {
const [paused, setPaused] = useState(false);
const togglePaused = useCallback(() => setPaused((p) => !p), []);
const scrubFrame = useFrameScrub();
const stageRef = useRef<HTMLDivElement>(null);
/*
* The whole measuring layer: one effect, run before paint and again on every
* resize, rAF-debounced so a drag emits one measurement per frame rather than
* one per resize event.
*
* StrictMode's double-mount is fine — everything it writes is a custom
* property derived from the current layout, so the second run recomputes the
* same values.
*/
useIsomorphicLayoutEffect(() => {
const hero = stageRef.current;
if (!hero) {
return;
}
let frame = 0;
const apply = () => measureHero(hero);
const onResize = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(apply);
};
apply();
window.addEventListener("resize", onResize);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("resize", onResize);
};
}, []);
/*
* Development only: `?frame=N` pins the loop. Runs in its own effect, after
* the measuring one, so the cursor's targets are measured before its track is
* seeked — otherwise a pinned frame would show the pointer at its fallback
* position rather than at the element it is supposed to be on.
*/
useEffect(() => {
const hero = stageRef.current;
if (!hero || scrubFrame === null) {
return;
}
return seekTo(hero, scrubFrame);
}, [scrubFrame]);
return (
<div className={cn("hero-visual", paused && "is-paused")} ref={stageRef}>
<div aria-hidden="true">
<DesktopBackdrop>
<BrowserWindow>
<InlineOverlay />
</BrowserWindow>
</DesktopBackdrop>
<MockCursor />
</div>
{/*
Not aria-hidden: an infinite animation must be stoppable, and a control
the keyboard cannot reach does not count as stoppable.
*/}
<button
aria-label={
paused ? "Play the demo animation" : "Pause the demo animation"
}
className="animation-pause-btn"
onClick={togglePaused}
type="button"
>
{paused ? <PlayIcon /> : <PauseIcon />}
</button>
</div>
);
}
function PauseIcon() {
return (
<svg
aria-hidden="true"
height="13"
viewBox="0 0 14 14"
width="13"
xmlns="http://www.w3.org/2000/svg"
>
<rect fill="currentColor" height="10" rx="1" width="3.4" x="3" y="2" />
<rect fill="currentColor" height="10" rx="1" width="3.4" x="7.6" y="2" />
</svg>
);
}
function PlayIcon() {
return (
<svg
aria-hidden="true"
height="13"
viewBox="0 0 14 14"
width="13"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M4 2.4l7.2 4.6L4 11.6z" fill="currentColor" />
</svg>
);
}
@@ -0,0 +1,29 @@
/**
* The pointer that drives the whole loop.
*
* A sibling of `.desktop-bg`, not a child: its `left`/`top` are percentages, and
* they have to resolve against `.hero-visual` — whose box matches the desktop
* exactly — rather than against the desktop's padding box, which would offset
* every measured target by the 80px side padding.
*/
export function MockCursor() {
return (
<svg
aria-hidden="true"
className="mock-cursor"
fill="none"
height="14"
viewBox="0 0 14 14"
width="14"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2 1.4l9.2 5.4-3.8 1-1.9 3.6z"
fill="currentColor"
stroke="rgba(0,0,0,0.45)"
strokeLinejoin="round"
strokeWidth="0.9"
/>
</svg>
);
}
+46
View File
@@ -0,0 +1,46 @@
/*
* The pure maths behind the hero's measuring layer.
*
* Everything here is a function of numbers in and strings out — no DOM, no
* refs, no side effects. That split is not tidiness for its own sake: it keeps
* the effect in hero-stage.tsx down to about forty lines of orchestration.
*/
/** A rectangle, in the coordinate space of whatever measured it. */
export interface Rect {
height: number;
left: number;
top: number;
width: number;
}
/**
* A point expressed as a percentage of a container.
*
* The cursor's `left`/`top` are percentages so it keeps its position when the
* hero is resized between measurements — a pixel offset would be silently wrong
* for the frame or two before the resize handler catches up.
*/
export function toPercent(
point: { x: number; y: number },
container: Rect
): { x: string; y: string } {
/*
* Rounded to 3dp — about a thousandth of the stage, far below a device pixel.
*
* The precision is not cosmetic. These strings are written to custom
* properties that live `@keyframes` read, and in Chrome writing such a
* property restarts the animation. Full float precision meant every resize
* produced a different string for the same position, so `measureHero` could
* never tell "nothing moved" from "moved imperceptibly".
*/
return {
x: `${(((point.x - container.left) / container.width) * 100).toFixed(3)}%`,
y: `${(((point.y - container.top) / container.height) * 100).toFixed(3)}%`,
};
}
/** The centre of a rect, in the same space the rect was measured in. */
export function centerOf(rect: Rect): { x: number; y: number } {
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
}
+61
View File
@@ -0,0 +1,61 @@
/*
* The hero mock's scale, resolved before first paint.
*
* `.ap-mock` is authored at a fixed 1200px and shrunk with `transform: scale()`
* to whatever width the browser window in the hero actually got. Only the
* measuring effect in hero-stage.tsx knows that width exactly — but it cannot
* run until React has hydrated, and the server-rendered HTML paints long before
* that. Whatever the CSS fallback says is therefore what a visitor sees first.
*
* A single fallback was fine when the stage was a fixed 800px card: one number
* was correct at every viewport. Once the band went full-bleed the window's
* width became a function of the viewport, no constant is right below the cap,
* and first paint was out by up to 66% — a visible snap on load.
*
* So: a tiny inlined script that runs before the first paint, computing the
* scale from the only input it needs (viewport width) and letting the real
* measurement refine it after hydration. The numbers below are
* transcribed from hero-desktop.css and hero-overlay.css; if the geometry there
* changes, it changes here.
*/
/** Author-space width of `.ap-mock` — see hero-overlay.css. */
export const MOCK_WIDTH = 1200;
/** `.browser-window`'s `max-width` — see hero-desktop.css. */
export const WINDOW_MAX_WIDTH = 1040;
/**
* `.desktop-bg`'s horizontal padding at each breakpoint, widest first — see the
* band rules in hero-desktop.css. The window is the band's width minus twice
* this, capped at WINDOW_MAX_WIDTH.
*/
export const BAND_PADDING_X = [
{ padding: 12, upTo: 640 },
{ padding: 16, upTo: 847 },
{ padding: 48, upTo: Number.POSITIVE_INFINITY },
] as const;
/** The scale the mock should render at, for a given viewport content width. */
export function heroScaleFor(viewportWidth: number): number {
const step =
BAND_PADDING_X.find((s) => viewportWidth <= s.upTo) ?? BAND_PADDING_X[2];
const content = Math.min(WINDOW_MAX_WIDTH, viewportWidth - step.padding * 2);
return Math.max(0, content) / MOCK_WIDTH;
}
/**
* Runs before first paint, inlined into the document head — see __root.tsx.
*
* Written onto <html> rather than onto `.hero-visual`, because at the time this
* runs the document is still being parsed and the hero does not exist yet.
* Custom properties inherit, so the value reaches `.ap-mock` all the same, and
* the inline style `measureHero` later writes onto `.hero-visual` overrides it
* — the seed is the opening bid, the measurement is the final answer.
*
* `clientWidth`, not `innerWidth`: it excludes the scrollbar, which is what the
* band's own `width: 100%` will resolve against. Stringified verbatim into a
* <script>, so it must be self-contained — no imports, no closure over module
* scope — and the try/catch keeps a failure here from blocking the parser.
*/
export const HERO_SCALE_SEED_SCRIPT = `(function(){try{var w=document.documentElement.clientWidth;var p=w<=${BAND_PADDING_X[0].upTo}?${BAND_PADDING_X[0].padding}:w<=${BAND_PADDING_X[1].upTo}?${BAND_PADDING_X[1].padding}:${BAND_PADDING_X[2].padding};var c=Math.min(${WINDOW_MAX_WIDTH},w-p*2);if(c>0){document.documentElement.style.setProperty("--ap-mock-scale",String(c/${MOCK_WIDTH}))}}catch(e){}})()`;
+146
View File
@@ -0,0 +1,146 @@
import { centerOf, type Rect, toPercent } from "#/lib/hero-measure";
import { MOCK_WIDTH } from "#/lib/hero-scale";
function rectOf(root: ParentNode, selector: string): Rect | null {
const el = root.querySelector(selector);
if (!el) {
return null;
}
const r = el.getBoundingClientRect();
return { height: r.height, left: r.left, top: r.top, width: r.width };
}
/**
* Measure the hero and write everything the CSS animation needs back onto it.
*
* Called on mount and again on every (debounced) resize.
*
* @param hero The `.hero-visual` element to write custom properties onto.
*/
export function measureHero(hero: HTMLElement): void {
/*
* Writing a custom property that a live `@keyframes` reads makes Chrome tear
* the animation down and start it again from zero. The cursor's whole path is
* expressed in these, so an unguarded `setProperty` on every resize event
* restarted the pointer's track while the other seventy carried on,
* permanently out of step.
*
* Hence: round to a stable precision, and skip the write when nothing moved.
* A resize that does not change the hero's geometry then costs nothing at all
* — which is the common case on a wide viewport, where the band is full-bleed
* but the window inside it is pinned to its max-width and does not move.
*/
let changed = false;
const setVar = (name: string, value: string) => {
if (hero.style.getPropertyValue(name) === value) {
return;
}
hero.style.setProperty(name, value);
changed = true;
};
// The shared clock, read before anything is written, so it can be restored if
// a genuine change does force a restart.
const clock = readClock(hero);
// 1. Mock scale — how far 1200 author pixels must shrink to fit the window.
const content = rectOf(hero, ".browser-content");
if (!(content && content.width > 0)) {
return;
}
const scale = content.width / MOCK_WIDTH;
setVar("--ap-mock-scale", String(scale));
const stage = hero.getBoundingClientRect();
const container: Rect = {
height: stage.height,
left: stage.left,
top: stage.top,
width: stage.width,
};
// 2. Cursor targets: the centre of a real element each time, as a percentage
// of the stage so it survives a resize between one measurement and the
// next.
//
// These are the four places the pointer actually goes in the story — the
// button it picks, the composer it types into, the send button, and the
// two controls in the panel. Everything else on the desktop is scenery and
// is never clicked.
const targets: [string, string][] = [
["sel", "#ap-cta"],
["prompt", ".ap-input"],
["send", ".ap-send"],
["tab", ".ap-tab-edit"],
["radius", ".ap-field-radius"],
];
for (const [name, selector] of targets) {
const rect = rectOf(hero, selector);
if (!rect) {
continue;
}
const { x, y } = toPercent(centerOf(rect), container);
setVar(`--${name}-x`, x);
setVar(`--${name}-y`, y);
}
// 3. The picker's box, in `.ap-mock` AUTHOR space — it is drawn inside the
// scaled container, so it has to be sized in the same units its siblings
// use, not in the screen pixels the rect came back in.
const mock = rectOf(hero, ".ap-mock");
const cta = rectOf(hero, "#ap-cta");
if (mock && cta) {
setVar("--pick-x", px((cta.left - mock.left) / scale));
setVar("--pick-y", px((cta.top - mock.top) / scale));
setVar("--pick-w", px(cta.width / scale));
setVar("--pick-h", px(cta.height / scale));
}
if (changed) {
restoreClock(hero, clock);
}
}
/** `12.3456` → `"12.35px"`. Two decimals is below one device pixel at any DPR. */
function px(value: number): string {
return `${value.toFixed(2)}px`;
}
interface Clock {
paused: boolean;
time: CSSNumberish | null;
}
/** Where the shared `--ap-loop` clock currently is, and whether it is running. */
function readClock(hero: HTMLElement): Clock | null {
const reference = hero.querySelector(".mock-cursor")?.getAnimations()[0];
if (!reference) {
return null;
}
return {
paused: reference.playState === "paused",
time: reference.currentTime,
};
}
/**
* Put every track back on the shared clock after a genuine geometry change.
*
* Deferred a frame because Chrome recreates the affected animations during the
* next style recalculation — restoring synchronously would set the time on the
* objects that are about to be discarded.
*/
function restoreClock(hero: HTMLElement, clock: Clock | null): void {
if (!clock) {
return;
}
requestAnimationFrame(() => {
for (const animation of hero.getAnimations({ subtree: true })) {
if (clock.paused) {
animation.pause();
}
animation.currentTime = clock.time;
}
});
}
+69
View File
@@ -0,0 +1,69 @@
import { useEffect, useState } from "react";
import { FRAME_MS } from "#/content/timeline";
/**
* `?frame=62` — freeze the hero's loop at one percent of its run.
*
* A ~30-track choreography cannot be reviewed by watching it: by the time you
* notice a value swapped a beat too early, it is gone.
*
* Read from `window.location` rather than the route's search schema on purpose:
* this is a development affordance, and routing it through validated search
* params would put a debug flag in the app's public contract.
*
* @returns the frame to pin at, or `null` to run normally.
*/
export function useFrameScrub(): number | null {
const [frame, setFrame] = useState<number | null>(null);
useEffect(() => {
const raw = new URLSearchParams(window.location.search).get("frame");
if (raw === null) {
return;
}
const parsed = Number(raw);
if (Number.isFinite(parsed)) {
// Wrap rather than clamp, so `?frame=105` shows frame 5 — which is what
// you want when stepping across the seam to check it.
setFrame(((parsed % 100) + 100) % 100);
}
}, []);
return frame;
}
/**
* Seek every animation under `root` to a given frame and pause it.
*
* Driven through the Web Animations API rather than by overriding
* `animation-delay` in CSS. The CSS route looks simpler and is wrong: the scrub
* class can only be applied after the first paint, by which point every track
* has already started, and changing `animation-delay` on a running animation
* shifts its timeline relative to a start time that is now in the past. The
* result is a frame that is confidently displayed and quietly incorrect — the
* worst possible property for a debugging tool.
*
* `currentTime` is absolute and unambiguous, so what you asked for is what you
* get.
*
* @returns a function that resumes everything, for the effect's cleanup.
*/
export function seekTo(root: HTMLElement, frame: number): () => void {
const time = frame * FRAME_MS;
const animations = root.getAnimations({ subtree: true });
for (const animation of animations) {
// Pause BEFORE seeking. Assigning `currentTime` to a still-running animation
// sets it, but the animation then advances by however long it takes to reach
// the `pause()` on the next line — about a frame — and lands ~0.17% late.
// That is invisible during a hold and completely wrong on a fast beat.
animation.pause();
animation.currentTime = time;
}
return () => {
for (const animation of animations) {
animation.play();
}
};
}
@@ -0,0 +1,12 @@
import { useEffect, useLayoutEffect } from "react";
/**
* `useLayoutEffect` in the browser, `useEffect` on the server.
*
* React warns that `useLayoutEffect` does nothing during SSR, and it is right —
* but the warning is noise for an effect that measures the DOM, because there is
* no DOM to measure until the browser has one. Swapping the import on the server
* silences the warning without changing what runs where.
*/
export const useIsomorphicLayoutEffect =
typeof window === "undefined" ? useEffect : useLayoutEffect;
+316
View File
@@ -0,0 +1,316 @@
/*
* The full-bleed band the hero sits on: wallpaper, the Safari window, the
* cursor, and the pause button.
*
* Static geometry only. Every `animation:` binding lives in hero-timeline.css,
* so this file can be read as "what it looks like" without also being "when".
*
* The band used to be an 800px rounded card carrying a menu bar and a dock as
* well, so that the three together read as a screenshot of a Mac. Edge to edge
* that conceit inverts: chrome authored at 8.5px to be legible inside a shrunken
* desktop is just small type at 1:1. The wallpaper now stands on its own, and
* the window it carries is ~1040px rather than 744px — which is the point of the
* change, because it takes the mock inside from `scale(0.62)` to ~0.87 and its
* composer prompt, the one line here anybody has to read, from 8px to ~11px.
*
* One structural fact is load-bearing and is enforced by the markup in
* components/hero/hero-stage.tsx rather than by anything here: `.mock-cursor` is
* a SIBLING of `.desktop-bg`, not a child, because its left/top are percentages
* and they must resolve against `.hero-visual`, whose box matches `.desktop-bg`
* exactly. Both are `width: 100%` with no margin, so that still holds.
*/
/* ── the stage ─────────────────────────────────────────────────────────── */
.hero-visual {
/*
* Cursor targets, as percentages of this element. These are FALLBACKS — the
* measuring effect in hero-stage.tsx overwrites each one with a real measured
* position after mount. They are what the pre-hydration frame paints with, and
* what the loop runs against if a measurement ever fails.
*
* Transcribed from a real measurement at a 1440px viewport, where the window
* sits at its 1040px cap. They were previously a different set entirely, taken
* when this element was capped at 800px — once it went full-bleed the box these
* resolve against became the viewport, every number was wrong by 20–50 points,
* and the cursor teleported on hydration.
*
* Being percentages of a full-width box, they are only exact at 1440. That is
* the trade the full-bleed band costs: below the window's cap the true values
* drift with viewport width, and only the measurement knows them.
*/
--sel-x: 20.94%;
--sel-y: 52%;
--prompt-x: 74.5%;
--prompt-y: 85.54%;
--send-x: 82.56%;
--send-y: 85.59%;
--tab-x: 72.89%;
--tab-y: 23.72%;
--radius-x: 79.46%;
--radius-y: 68.45%;
position: relative;
width: 100%;
}
/* ── the band ──────────────────────────────────────────────────────────── */
.desktop-bg {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
/* Nothing overhangs any more — this is now purely the guarantee that a band
as wide as the viewport can never produce a horizontal scrollbar. */
overflow: clip;
overflow-clip-margin: 0;
width: 100%;
/*
* No `aspect-ratio`. At 800px wide, 16/10 was a 500px card. Kept at full
* width it would be a 900px-tall band on a 1440 display and taller still on a
* wide monitor — the band's height comes from the window it holds plus this
* padding instead.
*/
padding: 56px 48px;
background:
url("/wallpaper-day.avif") center / cover no-repeat,
var(--pk-color-surface-input);
}
/* ── browser window ────────────────────────────────────────────────────── */
/*
* Sized by its content, not by the space available.
*
* `flex: 1 0 0` was the obvious choice and is wrong: `.desktop-bg` no longer has
* an aspect-ratio, so the column has no definite height and a zero flex-basis
* would have nothing to resolve against. Letting `.browser-content` declare the
* mock's aspect ratio instead makes this element's height a function of its
* width at every breakpoint — which is also what lets the band be sized by the
* window rather than the other way round.
*
* 1040px is the cap, and it is the number that sets the whole band's proportion:
* at 1040 the content box is ~551px tall, so the band lands near 680px with its
* padding. It is also 1040/1200 of `.ap-mock`'s authored width, which puts
* `--ap-mock-scale` at ~0.87 — close enough to 1 that the mock renders at very
* nearly the size it was drawn at. Raising it much past here starts to cost more
* vertical fold than the extra legibility buys.
*/
.browser-window {
position: relative;
z-index: 2;
display: flex;
flex: 0 0 auto;
flex-direction: column;
width: 100%;
max-width: 1040px;
overflow: hidden;
border-radius: var(--pk-radius-lg);
box-shadow:
0 0 0 0.5px rgb(0 0 0 / 0.15),
0 6px 20px 4px rgb(0 0 0 / 0.15);
}
.browser-chrome {
display: flex;
gap: 8px;
align-items: center;
padding: 6px 12px;
background: var(--pk-color-surface-chrome);
border-radius: var(--pk-radius-lg) var(--pk-radius-lg) 0 0;
}
.browser-dots {
display: flex;
gap: 6px;
}
.dot {
width: 7px;
height: 7px;
border-radius: 50%;
}
.dot-red {
background: #f87171;
}
.dot-yellow {
background: #fbbf24;
}
.dot-green {
background: #4ade80;
}
.safari-pill {
display: flex;
flex-shrink: 0;
gap: 4px;
align-items: center;
padding: 3px 6px;
background: rgb(255 255 255 / 0.5);
border: 1px solid rgb(255 255 255 / 0.06);
border-radius: 100px;
box-shadow: 0 2px 4px rgb(0 0 0 / 0.03);
}
.safari-btn {
display: block;
color: var(--pk-color-text-primary);
opacity: 0.5;
}
.safari-back {
opacity: 0.26;
}
.safari-url-wrap {
display: flex;
flex: 1;
justify-content: center;
}
.browser-url {
position: relative;
justify-content: center;
width: 100%;
max-width: 280px;
padding: 3px 10px;
font-family: system-ui, sans-serif;
font-size: 8px;
color: var(--pk-color-text-muted);
background: var(--pk-color-surface-panel);
border-color: var(--pk-color-surface-panel);
box-shadow: 0 1px 5px rgb(0 0 0 / 0.02);
}
.safari-reload {
position: absolute;
right: 8px;
color: var(--pk-color-text-muted);
opacity: 0.4;
}
/*
* The viewport the app is served into. `.ap-mock` inside it is the only
* positioned coordinate space the overlay's docks resolve against — see
* hero-overlay.css.
*/
.browser-content {
position: relative;
/* `flex: 0 0 auto`, not `flex: 1`: this element's height comes from the
aspect-ratio below, and growing to fill would override it. */
flex: 0 0 auto;
width: 100%;
/*
* Matches `.ap-mock`'s authored 1200×636, so the scaled mock fills this box
* exactly at any width. Change one without the other and you get a gap along
* the bottom edge, or a clipped bottom bar.
*
* 636 is not arbitrary: it is a realistic browser viewport ratio (1.89,
* against ~1.85 for a maximised window on a 16:10 display). It used to be
* chosen to make the window's total height land in the 394px an 800px desktop
* had left over; now the band has no fixed height and this ratio is what sets
* one, so it is the cause rather than the consequence.
*/
aspect-ratio: 1200 / 636;
overflow: hidden;
background: var(--pk-color-surface-page);
border-radius: 0 0 var(--pk-radius-lg) var(--pk-radius-lg);
}
/* ── cursor ────────────────────────────────────────────────────────────── */
.mock-cursor {
position: absolute;
top: 50%;
left: 50%;
z-index: 30;
width: 14px;
height: 14px;
color: #fff;
pointer-events: none;
filter: drop-shadow(0 1px 2px rgb(0 0 0 / 0.4));
will-change: transform, left, top;
}
/* ── pause button ──────────────────────────────────────────────────────── */
/*
* Bottom-left of the band, white on the wallpaper, at every width.
*
* This used to be two designs behind an 848px query: a tertiary-grey button
* parked below the card on narrow screens, because below 848 the wallpaper went
* away and there was nothing dark to sit on. The band keeps its wallpaper at
* every width now, so the corner treatment is simply always right and the
* fallback has nothing left to fall back to.
*/
.animation-pause-btn {
position: absolute;
bottom: 8px;
left: 8px;
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
color: #fff;
cursor: pointer;
background: transparent;
border: none;
border-radius: var(--pk-radius-lg);
transition:
color 150ms ease,
background 150ms ease,
transform 150ms ease;
}
.animation-pause-btn:hover,
.animation-pause-btn:focus-visible {
color: #fff;
background: rgb(255 255 255 / 0.28);
backdrop-filter: blur(12px);
}
.animation-pause-btn:active {
transform: scale(0.92);
transition:
color 150ms ease,
background 150ms ease,
transform 100ms ease-out;
}
/* ── narrow ────────────────────────────────────────────────────────────── */
/*
* The band survives narrow widths intact, which is new.
*
* When it was an 800px card the whole conceit was dropped below 848px — the
* wallpaper was thrown away and a bare window was left behind, because a
* shrunken desktop read as a bug. Full-bleed there is no desktop to shrink: the
* wallpaper is just a painting the width of the screen, and that works at 375px
* exactly as well as at 1440. Only the gutters come in.
*
* The cursor still goes at 847, though. Its targets are real measured elements
* inside a mock that is scaled down along with everything else, and past this
* point it is chasing controls too small to make sense of.
*/
@media (max-width: 847px) {
.desktop-bg {
padding: 32px 16px;
}
.mock-cursor {
display: none;
}
}
@media (max-width: 640px) {
.desktop-bg {
padding: 24px 12px;
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff