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:
@@ -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">●</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">⌋</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">×</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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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){}})()`;
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
Reference in New Issue
Block a user