feat(web): content as json with typed loaders

Every string on the page lives in a .json file with a typed loader beside it. Copy
changes without touching a component, and the og card can read the same headline
and install command the page renders — the two strings that drift.
This commit is contained in:
Nayan
2026-08-08 14:20:00 +05:30
parent 6611c6b21c
commit e5bd2b2c18
23 changed files with 1140 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
/*
* The marks of the coding agents Airship drives.
*
* Copied verbatim from packages/editor-icons/assets/local/{claude,codex,
* opencode}.svg. Same posture as `GLYPHS.logo`: inlined rather than loaded
* through an <img> so they inherit `currentColor` and follow the text around
* them without a second asset request. If those files change, these strings
* change too.
*
* Deliberately NOT in `editor-glyphs`. Everything there is a hand-drawn stand-in
* for the hero's mock editor and is `aria-hidden`, because the mock is a pure
* illustration. These are the vendors' own brand marks and they carry meaning —
* they name the agents Airship works with, so each one is announced.
*/
export interface AgentMark {
/** Inner markup, already using currentColor. */
body: string;
/** The agent's name. Doubles as the mark's accessible name. */
name: string;
}
export const AGENT_MARKS: readonly AgentMark[] = [
{
body: '<path fill-rule="evenodd" d="M7.139 14.637l3.147-1.765 0.053-0.153-0.053-0.086H10.133l-0.526-0.032-1.799-0.048-1.559-0.065-1.511-0.081-0.381-0.081L4 11.856l0.037-0.235 0.32-0.214 0.457 0.04 1.013 0.069 1.519 0.105 1.101 0.065 1.633 0.17h0.259l0.037-0.105-0.089-0.065-0.069-0.065-1.572-1.064-1.701-1.125-0.891-0.648-0.483-0.327-0.242-0.308-0.106-0.672 0.438-0.482 0.587 0.04 0.15 0.041 0.595 0.457 1.272 0.984 1.661 1.222 0.243 0.203 0.097-0.069 0.013-0.048-0.11-0.183-0.903-1.631-0.964-1.66-0.429-0.688-0.114-0.412a1.98 1.98 0 0 1-0.069-0.486L8.189 4.089 8.464 4l0.664 0.089 0.28 0.243 0.413 0.943 0.668 1.486 1.037 2.02 0.304 0.598 0.162 0.555 0.061 0.17h0.105V10.007l0.085-1.138 0.158-1.396 0.154-1.797 0.053-0.507 0.251-0.606 0.498-0.328 0.389 0.186 0.32 0.457-0.045 0.296-0.19 1.234-0.373 1.935-0.243 1.295h0.142l0.162-0.161 0.656-0.871 1.102-1.376 0.486-0.547 0.567-0.602 0.365-0.288h0.688l0.507 0.753-0.227 0.777-0.709 0.898-0.587 0.762-0.843 1.133-0.527 0.907 0.049 0.073 0.125-0.013 1.904-0.404 1.029-0.187 1.227-0.21 0.556 0.259 0.06 0.263-0.218 0.538-1.313 0.324-1.539 0.308-2.293 0.542-0.028 0.02 0.033 0.041 1.032 0.097 0.442 0.024h1.081l2.013 0.15 0.527 0.348 0.316 0.425-0.053 0.324-0.81 0.413-1.093-0.259-2.553-0.607-0.874-0.219h-0.122v0.073l0.729 0.712 1.337 1.207 1.673 1.553 0.085 0.385-0.215 0.304-0.227-0.033-1.47-1.105-0.567-0.498-1.284-1.08h-0.085v0.114l0.296 0.432 1.563 2.348 0.081 0.72-0.113 0.235-0.405 0.142-0.446-0.081-0.916-1.284-0.943-1.444-0.762-1.296-0.093 0.054-0.45 4.836-0.21 0.246-0.486 0.187-0.405-0.307-0.215-0.498 0.215-0.984 0.259-1.283 0.21-1.02 0.191-1.267 0.113-0.421-0.008-0.028-0.093 0.012-0.956 1.311-1.453 1.964-1.151 1.23-0.276 0.109-0.478-0.247 0.045-0.441 0.267-0.393 1.592-2.024 0.96-1.254 0.62-0.724-0.004-0.106h-0.037L6.755 16.373l-0.754 0.098-0.324-0.304 0.04-0.498 0.154-0.162 1.272-0.874-0.004 0.004z" fill="currentColor" fill-opacity="0.9"/>',
name: "Claude Code",
},
{
body: '<path fill-rule="evenodd" d="M10.137 9.772v-1.507c0-0.127 0.048-0.222 0.158-0.285l3.029-1.744c0.413-0.238 0.904-0.349 1.411-0.349 1.903 0 3.108 1.475 3.108 3.044 0 0.111 0 0.238-0.016 0.365l-3.14-1.839a0.531 0.531 0 0 0-0.57 0l-3.98 2.315z m7.072 5.867V12.04c0-0.222-0.095-0.38-0.286-0.491l-3.98-2.316 1.3-0.745a0.289 0.289 0 0 1 0.318 0l3.028 1.745c0.873 0.507 1.459 1.585 1.46 2.632 0 1.205-0.713 2.315-1.84 2.775zM9.201 12.469l-1.3-0.762c-0.111-0.063-0.159-0.159-0.159-0.285V7.933c0-1.697 1.3-2.981 3.061-2.982 0.667 0 1.285 0.222 1.808 0.619L9.487 7.378c-0.19 0.111-0.285 0.269-0.286 0.491v4.599zM12 14.085l-1.863-1.046v-2.22L12 9.772l1.863 1.047v2.22L12 14.085z m1.197 4.82c-0.667 0-1.285-0.221-1.808-0.618l3.124-1.808c0.19-0.111 0.285-0.269 0.286-0.491v-4.599l1.316 0.762c0.111 0.063 0.159 0.159 0.158 0.285v3.489c0 1.697-1.316 2.981-3.076 2.981z m-3.758-3.535l-3.029-1.745c-0.872-0.507-1.459-1.585-1.459-2.632A2.988 2.988 0 0 1 6.807 8.218v3.615c0 0.222 0.095 0.381 0.285 0.492l3.965 2.3-1.3 0.745a0.288 0.288 0 0 1-0.318 0z m-0.174 2.6c-1.792 0-3.108-1.347-3.108-3.013 0-0.127 0.016-0.253 0.031-0.38l3.124 1.807c0.191 0.111 0.381 0.111 0.571 0l3.98-2.299v1.507c0 0.127-0.047 0.222-0.158 0.285l-3.029 1.744c-0.413 0.238-0.904 0.349-1.411 0.349z m3.932 1.887a3.965 3.965 0 0 0 3.885-3.171C18.858 16.226 20 14.56 20 12.864c0-1.11-0.475-2.188-1.332-2.965 0.079-0.333 0.127-0.666 0.127-0.999 0-2.267-1.839-3.965-3.964-3.965-0.428 0-0.84 0.063-1.254 0.207A3.975 3.975 0 0 0 10.803 4a3.965 3.965 0 0 0-3.884 3.171C5.142 7.631 4 9.297 4 10.993c0 1.111 0.475 2.189 1.332 2.966-0.079 0.333-0.127 0.667-0.127 0.999 0 2.267 1.839 3.964 3.964 3.964 0.428 0 0.84-0.063 1.254-0.206a3.973 3.973 0 0 0 2.774 1.142z" fill="currentColor" fill-opacity="0.9"/>',
name: "Codex",
},
{
body: '<path fill-rule="evenodd" d="M14.667 8H9.333v8h5.333V8zm2.667 10.667H6.667V5.333h10.667v13.333z" fill="currentColor" fill-opacity="0.9"/>',
name: "OpenCode",
},
];
+123
View File
@@ -0,0 +1,123 @@
{
"heading": "Give your agent the full context.",
"desc": "Airship resolves your selection to its source file, including the exact changes you made.",
"chromeLabel": "airship → agent",
"lines": [
[
{
"kind": "heading",
"text": "## Selection"
}
],
[
{
"kind": "plain",
"text": "button.cta-primary"
},
{
"kind": "dim",
"text": " · 122 × 45"
}
],
[
{
"kind": "plain",
"text": "src/components/sections/hero-section.tsx:24"
},
{
"kind": "dim",
"text": " · <CtaButton>"
}
],
[],
[
{
"kind": "heading",
"text": "## Pending tweaks"
}
],
[
{
"kind": "prop",
"text": " border-radius "
},
{
"kind": "old",
"text": "6px"
},
{
"kind": "dim",
"text": " → "
},
{
"kind": "new",
"text": "9999px"
},
{
"kind": "hint",
"text": " scrubbed in the inspector"
}
],
[],
[
{
"kind": "heading",
"text": "## Frames"
}
],
[
{
"kind": "plain",
"text": " Desktop 1440 × 900 /"
}
],
[
{
"kind": "plain",
"text": " Mobile 393 × 852 /"
},
{
"kind": "hint",
"text": " ← selection made here"
}
],
[],
[
{
"kind": "heading",
"text": "## Tools"
}
],
[
{
"kind": "plain",
"text": " get_element_context()"
},
{
"kind": "dim",
"text": " re-read the selection at any point"
}
],
[
{
"kind": "plain",
"text": " Read · Grep · Edit"
},
{
"kind": "dim",
"text": " rooted at --cwd"
}
],
[],
[
{
"kind": "dim",
"text": "> "
},
{
"kind": "plain",
"text": "Make this warmer."
}
]
]
}
+81
View File
@@ -0,0 +1,81 @@
import agentOutput from "#/content/agent-output.json";
import { oneOf } from "#/content/resolve";
/*
* What airship actually hands the agent when you hit send.
*
* This is a transcription of the real payload shape, not a mock-up of one:
* `packages/core/src/prompt.ts` renders the selection identity, the resolved
* source location, the pending inspector deltas and the frame context, and the
* `get_element_context` tool lets the agent re-read the selection mid-turn.
* Showing the real thing is more convincing than any amount of prose about
* "rich context", and it is the section a sceptical reader checks first.
*
* It also spells out what the hero animates: a value nudged by hand travels to
* the agent as INTENT — `border-radius 6px → 9999px` — alongside a sentence, and
* the agent decides how to write both into your project's idiom.
*
* Modelled as tokens rather than a template string so the syntax colouring is
* data, not a regex pass over prose at render time. In agent-output.json a line
* is an array of `{ kind, text }`, and an empty array is a blank line.
*/
/** Every colour the block can paint. Each maps to one class in shell.css. */
export const OUTPUT_TOKEN_KINDS = [
"dim",
"heading",
"hint",
"new",
"old",
"plain",
"prop",
] as const;
export type OutputTokenKind = (typeof OUTPUT_TOKEN_KINDS)[number];
export interface OutputToken {
id: string;
kind: OutputTokenKind;
text: string;
}
/** One rendered line. An empty token list is a blank line. */
export interface OutputLine {
id: string;
tokens: readonly OutputToken[];
}
/*
* Keys, assigned once at module load rather than from the render loop's index.
*
* Position genuinely IS identity here — this is a frozen constant that is never
* reordered, filtered or appended to — but baking that in at the data layer says
* so explicitly, and means the renderer never has to reason about it. It also
* matters that blank lines repeat, so the text alone would not be unique.
*
* `kind` is validated rather than trusted: JSON widens it to `string`, and an
* unrecognised value would render a span with a class nobody styled — invisible
* in review, and wrong on the one block that is meant to prove the page is not
* making things up.
*/
export const AGENT_OUTPUT: readonly OutputLine[] = agentOutput.lines.map(
(tokens, lineIndex) => ({
id: `line-${lineIndex}`,
tokens: tokens.map((token, tokenIndex) => ({
id: `line-${lineIndex}-token-${tokenIndex}`,
kind: oneOf(
OUTPUT_TOKEN_KINDS,
token.kind,
`token kind on line ${lineIndex}`
),
text: token.text,
})),
})
);
export const AGENT_OUTPUT_SECTION = {
/** The label in the block's chrome bar. */
chromeLabel: agentOutput.chromeLabel,
desc: agentOutput.desc,
heading: agentOutput.heading,
};
+138
View File
@@ -0,0 +1,138 @@
/*
* The glyphs the hero's miniature editor draws.
*
* Hand-authored, NOT imported from @airship/editor-icons. That package vendors
* an upstream UI icon set — appropriate inside a local dev tool that is
* deliberately imitating a design tool's affordances, but a different
* distribution posture from serving them to every visitor of a public marketing
* page. These are simple geometric stand-ins drawn against the same 24×24 box,
* and at the mock's render scale (~0.58) they read as shapes rather than as
* anybody's artwork.
*
* Conventions, all inherited from the real set so the mock composes the same:
* - 24×24 viewBox, artwork inset to roughly 4..20 (the optical inset that
* makes a 24px icon sit correctly beside a 24px control).
* - Stroked, not filled, at 1.5 — except `logo`, which is the real brand mark
* and is filled.
* - `currentColor` throughout; colour is the caller's business.
*
* The one glyph here that is NOT a stand-in is `logo`: it carries the exact path
* from assets/logo.svg, which is the geometry of record. If that file changes,
* this string changes with it.
*/
export type GlyphName = keyof typeof GLYPHS;
export interface Glyph {
/** Inner markup, already using currentColor. */
body: string;
/** True when the artwork is filled rather than stroked. */
filled?: boolean;
}
const stroke = (d: string): Glyph => ({
body: `<path d="${d}" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`,
});
export const GLYPHS = {
alignBottom: stroke("M4 19h16M8 9v7M16 5v11"),
alignCenterH: stroke("M12 4v16M8 8h8M6 16h12"),
alignCenterV: stroke("M4 12h16M8 8v8M16 6v12"),
/* ── the nine align-row cells ─────────────────────────────────────── */
alignLeft: stroke("M5 4v16M8 8h7M8 16h11"),
alignRight: stroke("M19 4v16M9 8h7M5 16h11"),
alignTop: stroke("M4 5h16M8 8v7M16 8v11"),
/** Blend / layer mode. */
blend: stroke("M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16zM12 4v16a8 8 0 0 0 0-16z"),
/* ── the terminal beat ────────────────────────────────────────────── */
/** The filled dot that opens a tool-call row, matching the overlay. */
bullet: {
body: '<circle cx="12" cy="12" r="4" fill="currentColor" fill-opacity="0.9"/>',
filled: true,
},
/* ── inspector section chevrons ───────────────────────────────────── */
chevronDown: stroke("M7 10l5 5 5-5"),
chevronRight: stroke("M10 7l5 5-5 5"),
chevronUp: stroke("M7 14l5-5 5 5"),
/** Clip content / overflow. */
clip: stroke("M5 5h14v14H5zM9 9h10M9 9v10"),
code: stroke("M9 8l-4 4 4 4M15 8l4 4-4 4"),
/** Independent corners, the toggle beside a quad field. */
corners: stroke("M5 9V5h4M15 5h4v4M19 15v4h-4M9 19H5v-4"),
/* ── bottom bar tools ─────────────────────────────────────────────── */
/** Move / select arrow. The default tool. */
cursor: {
body: '<path d="M6 4l12 7-5 1.4L10.5 18z" fill="currentColor" fill-opacity="0.9"/>',
filled: true,
},
/* ── inspector tabs ───────────────────────────────────────────────── */
design: stroke("M5 19l3-1 9.5-9.5-2-2L6 16zM15 7l2 2M5 5h6"),
distributeH: stroke("M5 5v14M19 5v14M10.5 8v8h3V8z"),
distributeV: stroke("M5 5h14M5 19h14M8 10.5h8v3H8z"),
/** Edit mode — the pencil half of the Edit|View toggle. */
edit: stroke("M16.5 5.5l2 2L9 17l-3 1 1-3zM15 7l2 2"),
/** The ⎿ elbow that introduces a tool result. */
elbow: stroke("M8 5v11h8"),
/** Hide a row. */
eye: stroke(
"M4 12s3-5 8-5 8 5 8 5-3 5-8 5-8-5-8-5zM12 14a2 2 0 1 0 0-4 2 2 0 0 0 0 4z"
),
/** Hand, for the view-mode tool group. */
hand: stroke(
"M9 11V6.5a1.5 1.5 0 0 1 3 0V11m0-1V5.5a1.5 1.5 0 0 1 3 0V11m0-.5a1.5 1.5 0 0 1 3 0V15a5 5 0 0 1-5 5h-1a5 5 0 0 1-5-5v-2.5a1.5 1.5 0 0 1 3 0"
),
/** Inspect / pick an element. */
inspect: stroke(
"M5 5h4M5 5v4M19 5h-4M19 5v4M5 19h4M5 19v-4M19 19h-4M19 19v-4"
),
layers: stroke("M12 4l8 4-8 4-8-4zM4 12l8 4 8-4M4 16l8 4 8-4"),
/*
* The brand mark. Same path as assets/logo.svg and the `logo` entry in
* packages/overlay/src/icons.ts — three copies of one geometry, which must be
* changed together. Filled, with the 0.9 opacity the original carries.
*/
logo: {
body: '<path d="M12 5.1L20 18.9H15.47L9.73 9.01ZM7.47 12.92H12L8.53 18.9H4Z" fill="currentColor" fill-opacity="0.9"/>',
filled: true,
},
/** Remove a row. */
minus: stroke("M6 12h12"),
/** Opacity — the checkerboard-and-square mark. */
opacity: stroke(
"M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16zM12 4v16M12 7h4M12 11h6M12 15h6"
),
/** Collapse the dock. */
panelRight: stroke("M5 5h14v14H5zM15 5v14"),
/** Add a row. */
plus: stroke("M12 6v12M6 12h12"),
/* ── inspector control glyphs ─────────────────────────────────────── */
/** Corner radius. The classic quarter-round with a tick. */
radius: stroke("M5 19v-8a6 6 0 0 1 6-6h8M5 5h.01M19 19h.01"),
/** Reset panel width. */
reset: stroke("M8 8H5V5M5.5 8.5a7 7 0 1 1-1 5"),
/** Rotation. */
rotation: stroke("M12 5a7 7 0 1 1-6.3 4M5 5v4h4"),
/* ── dock chrome ──────────────────────────────────────────────────── */
/** The right dock's header mark, beside the word "Design". */
settings: stroke(
"M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM19 12a7 7 0 0 0-.1-1.2l1.6-1.2-1.7-3-1.9.7a7 7 0 0 0-2-1.2L14.6 4h-3.4l-.3 2a7 7 0 0 0-2 1.2l-2-.7-1.7 3 1.6 1.2A7 7 0 0 0 6.7 12"
),
/** Text tool. */
text: stroke("M6 6h12M12 6v12M9.5 18h5"),
tidy: stroke("M5 5h6v6H5zM13 5h6v6h-6zM5 13h6v6H5zM13 13h6v6h-6z"),
/** The token badge — "this value is a design token". */
token: stroke("M12 4l7 4v8l-7 4-7-4V8zM12 12l7-4M12 12v8M12 12L5 8"),
/** Undo. Redo is the same mark flipped with scaleX(-1). */
undo: stroke("M8 8H5V5M5.5 8.5a7 7 0 1 1-1 5"),
/** View mode — the eye half. */
view: stroke(
"M4 12s3-5 8-5 8 5 8 5-3 5-8 5-8-5-8-5zM12 14a2 2 0 1 0 0-4 2 2 0 0 0 0 4z"
),
} as const satisfies Record<string, Glyph>;
+56
View File
@@ -0,0 +1,56 @@
{
"heading": "Questions",
"desc": "Including the ones with unflattering answers.",
"entries": [
{
"id": "plugin",
"question": "Do I need a plugin, or to change my build?",
"answer": "No. Airship runs as a reverse proxy in front of your existing dev server. Nothing is added to your dependencies, config, or bundle."
},
{
"id": "styling",
"question": "Does it work with Tailwind, CSS Modules or styled-components?",
"answer": "Yes. Airship works with the styling system already in your project. It works with the tokens and styling conventions already in your codebase, so visual changes can map back to the values your design system already uses."
},
{
"id": "resolution",
"question": "How does it know which file an element came from?",
"answer": "Your development build already carries information about where rendered elements came from. Airship uses those to resolve your selection to a source file, then passes them to the agent."
},
{
"id": "payload",
"question": "What exactly gets sent to the agent?",
"answer": "Airship makes your visual context visible to the agent. It passes along what you selected, where it lives in the code, which values you changed, and the frames you were working in."
},
{
"id": "agents",
"question": "Can I use my existing coding agent?",
"answer": "Yes. Airship works with Claude Code, Codex, and OpenCode, so you don't need to change your agent just to use the visual editor."
},
{
"id": "safety",
"question": "Is it safe to point at a real repository?",
"answer": "Airship runs locally and works directly with the repository you point it at. By default, your coding agent has the same filesystem and network access it normally has. Safe mode can restrict edits to your project and block potentially destructive commands."
},
{
"id": "undo",
"question": "How do I undo something?",
"answer": "Every edit can be undone directly in Airship. Airship keeps the previous version of the file, so you can revert a change instantly without needing Git."
},
{
"id": "frames",
"question": "Can I see how a change affects different devices?",
"answer": "Yes. Airship gives you real viewports running the same app side by side. Make a change once and see how it affects desktop, tablet, and mobile without resizing the frame."
},
{
"id": "git",
"question": "Can I use Airship without Git?",
"answer": "Yes. Git isn't required by Airship. Some agents may still use Git internally for their own diff and change tracking."
},
{
"id": "privacy",
"question": "Does my code leave my machine?",
"answer": "Airship runs entirely on localhost. It has no account, telemetry, or hosted service, and your code isn't sent to Airship. Your chosen coding agent handles requests using the same credentials and provider it would use from your terminal."
}
]
}
+22
View File
@@ -0,0 +1,22 @@
import faq from "#/content/faq.json";
/*
* Ten questions, answered the way the README answers them — including where the
* honest answer is a limitation. The sandboxing and agent-parity entries in
* particular say less than a marketing page would like them to, because the
* README says less, and a FAQ that oversells the safety story is the one place
* on a page like this where being wrong actually costs someone something.
*/
export interface FaqEntry {
answer: string;
id: string;
question: string;
}
export const FAQ: readonly FaqEntry[] = faq.entries;
export const FAQ_SECTION = {
desc: faq.desc,
heading: faq.heading,
};
+14
View File
@@ -0,0 +1,14 @@
{
"blurb": "{{tagline}}",
"columns": [
{
"id": "project",
"title": "Project",
"links": [
{ "link": "github", "label": "GitHub" },
{ "link": "releases", "label": "Releases" },
{ "link": "issues", "label": "Issues" }
]
}
]
}
+52
View File
@@ -0,0 +1,52 @@
import footer from "#/content/footer.json";
import { fill, linkFor } from "#/content/resolve";
/*
* The footer's link columns.
*
* Short on purpose. The obvious move is four columns of eight — Product,
* Company, Resources, Legal — but airship is a CLI in one repository, and every
* one of those columns would have to be filled with pages that do not exist.
* These are the links that resolve today.
*
* Two entries in site.json's `links` are deliberately NOT referenced here.
* Both now name the right thing and still do not resolve: `license` points at
* /blob/main/LICENSE, which the repo has, but the repo is not public yet; `npm`
* points at @airshiplabs/cli, which is the package apps/cli publishes but has
* not published. A footer is the worst place to keep a dead link — it is the
* part of a page people trust to be boringly correct. Add them back to
* footer.json once the repo is public and the first version is on npm.
*/
export interface FooterLink {
href: string;
label: string;
}
export interface FooterColumn {
id: string;
links: readonly FooterLink[];
title: string;
}
export const FOOTER_COLUMNS: readonly FooterColumn[] = footer.columns.map(
(column) => ({
id: column.id,
links: column.links.map((link) => ({
href: linkFor(link.link),
label: link.label,
})),
title: column.title,
})
);
export const FOOTER = {
/*
* The only prose the footer carries. There was a bottom bar under the wordmark
* restating "runs entirely on localhost" and the Node version; both are
* already said where they matter — the first in the FAQ that is actually about
* it, the second in the install steps you read before running anything — and a
* footer repeating them was just the page clearing its throat on the way out.
*/
blurb: fill(footer.blurb),
};
+28
View File
@@ -0,0 +1,28 @@
{
"heading": "Get started",
"desc": "Airship runs alongside your project without adding anything to the codebase.",
"compat": "Airship uses the authentication you already have configured for Claude Code, Codex, or OpenCode",
"steps": [
{
"id": "run-your-app",
"title": "Start your app the way you always do.",
"code": "pnpm dev # http://localhost:3000",
"copyable": false,
"note": "Works with Vite, Next, Remix, Rails, or anything that serves HTML over HTTP. No plugins required."
},
{
"id": "point-airship-at-it",
"title": "Point Airship at your dev server.",
"code": "{{installCommand}}",
"copyable": true,
"note": "Airship connects to the port you're already running and opens the visual editor."
},
{
"id": "pick-your-agent",
"title": "Bring your coding agent.",
"code": "{{installCommand}} --agent codex --safe",
"copyable": true,
"note": "Pick the agent you already use and start making changes without running a separate agent interface."
}
]
}
+28
View File
@@ -0,0 +1,28 @@
import getStarted from "#/content/get-started.json";
import { fill } from "#/content/resolve";
export interface GetStartedStep {
code: string;
/** Shown top-right of the code block; omit when there is nothing to copy. */
copyable: boolean;
id: string;
/** Rendered under the block, 13px, secondary. */
note?: string;
title: string;
}
export const GET_STARTED: readonly GetStartedStep[] = getStarted.steps.map(
(step) => ({
code: fill(step.code),
copyable: step.copyable,
id: step.id,
note: step.note,
title: step.title,
})
);
export const GET_STARTED_SECTION = {
compat: fill(getStarted.compat),
desc: getStarted.desc,
heading: getStarted.heading,
};
+8
View File
@@ -0,0 +1,8 @@
{
"eyebrow": "Works with",
"heading": "Visual editor for your codebase",
"sub": "Airship lets you edit your running app visually with Claude Code, Codex, or OpenCode. Select an element, describe the change, and watch the source update.",
"ctaLabel": "Get started",
"ctaHref": "#get-started",
"installCommand": "{{installCommand}}"
}
+36
View File
@@ -0,0 +1,36 @@
import hero from "#/content/hero.json";
import { fill } from "#/content/resolve";
export interface Hero {
ctaHref: string;
/**
* The primary action is an in-page anchor, not a "try it here" button.
*
* The tool it would have to demo is a CLI that drives a coding agent against
* files on your machine; there is nothing a marketing page can honestly wire
* that button to. A button that fakes the product is worse than one that just
* sends you to the install instructions, and the hero animation already shows
* what the real thing does.
*/
ctaLabel: string;
/**
* The small line above the heading, front-loading the compatibility fact.
*
* It ends on "Works with" because the agents that complete the sentence are
* their own brand marks, not words — see `AGENT_MARKS`, which `HeroSection`
* renders after this string.
*/
eyebrow: string;
heading: string;
installCommand: string;
sub: string;
}
export const HERO: Hero = {
ctaHref: hero.ctaHref,
ctaLabel: hero.ctaLabel,
eyebrow: hero.eyebrow,
heading: hero.heading,
installCommand: fill(hero.installCommand),
sub: hero.sub,
};
+75
View File
@@ -0,0 +1,75 @@
{
"page": {
"brand": "airship",
"cards": [
{
"body": "A reverse proxy, not a build step.",
"title": "No plugin"
},
{
"body": "Frames report their own innerWidth.",
"title": "Real viewports"
},
{
"body": "Resolved back to file and line.",
"title": "Edits source"
}
],
"ctaLabel": "Get started",
"heading": "Point at it, describe the change.",
"nav": ["Overview", "How it works", "FAQ"],
"secondaryLabel": "npx @airshiplabs/cli",
"sub": "An infinite design canvas in front of the dev server you already have."
},
"selection": {
"size": "122 × 45",
"sourceFile": "hero-section.tsx",
"sourceLine": 24,
"sourcePath": "src/components/sections/hero-section.tsx",
"tag": "button.cta-primary"
},
"prompt": "Make this warmer.",
"toolCalls": [
{
"args": "hero-section.tsx",
"result": "Read 84 lines",
"tool": "Read"
},
{
"args": "\"cta-primary\"",
"result": "Found 2 matches",
"tool": "Grep"
},
{
"args": "styles/shell.css",
"result": "1 addition, 1 removal",
"tool": "Edit"
}
],
"diff": {
"file": "shell.css",
"lines": [
{
"kind": "ctx",
"text": " .cta-primary {"
},
{
"kind": "del",
"text": "- background: #1C1917;"
},
{
"kind": "add",
"text": "+ background: #E2603A;"
},
{
"kind": "ctx",
"text": " }"
}
],
"stat": "+1 −1"
},
"status": {
"done": "Done · 1 file changed",
"working": "Applying 1 edit…"
}
}
+81
View File
@@ -0,0 +1,81 @@
import mock from "#/content/mock-page.json";
import { oneOf } from "#/content/resolve";
/*
* Everything the hero's editor mock says.
*
* The app inside the browser window is this site, in miniature — which is not a
* joke about recursion but the literally true thing: `make run` points the CLI at
* apps/web, so the app being edited in the hero is the app you are reading. That
* saves inventing a product to demo against, and it means the payoff beat edits a
* button that actually exists a few hundred pixels above it.
*
* Deliberately short strings. At the mock's render scale a full sentence
* collapses into a grey smear, so each line is written to still read as words.
*/
export const MOCK_PAGE = mock.page;
/**
* What the picker reports, and what the inspector's SOURCE chip shows. Kept
* beside the page it describes so renaming the CTA cannot leave the badge
* quoting a class that no longer exists.
*/
export const MOCK_SELECTION = mock.selection;
/**
* The prompt.
*
* Short on purpose, and short for a reason beyond the render scale: the two
* halves of this demo are split by MECHANISM. Colour is the thing that is
* awkward to say as a number — "warmer" is a judgement, and judgement is what
* you hand to an agent. The corner radius is a number, and a number is what you
* scrub in the inspector.
*
* That division is not a staging trick; it is how the tool is actually used.
*/
export const MOCK_PROMPT: string = mock.prompt;
/**
* The agent's turn, in the order the overlay streams it.
*
* Three calls, because that is what one honest colour change takes: read the
* file, find the other places the class is used, then edit. A demo that shows a
* lone Edit is quietly claiming the agent never has to look around first.
*/
export const MOCK_TOOL_CALLS = mock.toolCalls;
/** Every kind of line the diff card can draw. One class each in hero-overlay.css. */
export const DIFF_LINE_KINDS = ["add", "ctx", "del"] as const;
export type DiffLineKind = (typeof DIFF_LINE_KINDS)[number];
export interface DiffLine {
kind: DiffLineKind;
text: string;
}
/** The diff card that lands before the colour appears on the page. */
export const MOCK_DIFF: {
file: string;
lines: readonly DiffLine[];
stat: string;
} = {
file: mock.diff.file,
lines: mock.diff.lines.map((line, index) => ({
kind: oneOf(DIFF_LINE_KINDS, line.kind, `diff line kind at index ${index}`),
text: line.text,
})),
stat: mock.diff.stat,
};
/** The two status lines the turn moves through. */
export const MOCK_STATUS = mock.status;
/*
* There used to be a MOCK_TERMINAL_LINES here: the install command's startup
* banner, for a CLI window that opened the loop and minimised into the
* dock. The beat is gone — it spent nearly three seconds establishing that the
* tool is launched from a shell, which the install command sitting directly
* above the animation already says, in real copy the visitor can select.
*/
+8
View File
@@ -0,0 +1,8 @@
{
"sections": [
{ "id": "how-it-works", "label": "How it works" },
{ "id": "get-started", "label": "Get started" },
{ "id": "faq", "label": "FAQ" }
],
"external": [{ "link": "github", "label": "GitHub" }]
}
+49
View File
@@ -0,0 +1,49 @@
import nav from "#/content/nav.json";
import { linkFor } from "#/content/resolve";
/*
* The header's navigation.
*
* `id` is both the anchor target and the key the scroll-spy matches on, so every
* entry in nav.json must correspond to a real `id` on a <section> in
* routes/index.tsx. There is no runtime check for that — a typo is a link that
* scrolls nowhere and an indicator that never lights up.
*
* Deliberately shorter than the set of sections on the page. In the old left
* rail this was a table of contents and listing every section was the point. In
* a top bar it is navigation, and seven items in a row reads as a site map
* rather than a route through the page. `#overview` is what the wordmark links
* to, and `#output` still renders — it is simply reached by scrolling.
*/
export interface NavLink {
id: string;
label: string;
}
export interface NavExternalLink {
href: string;
label: string;
}
export const TOC_LINKS: readonly NavLink[] = nav.sections;
/** Just the ids, for the scroll-spy's IntersectionObserver. */
export const SECTION_IDS: readonly string[] = TOC_LINKS.map((link) => link.id);
/** Rendered after the TOC, past a divider, each with the 45° arrow. */
export const TOC_EXTERNAL_LINKS: readonly NavExternalLink[] = nav.external.map(
(entry) => ({ href: linkFor(entry.link), label: entry.label })
);
/*
* `.toc-inner` child count, asserted here because the responsive stagger below
* the mobile breakpoint is keyed to it with :nth-child rules. Adding a link
* without updating the stagger leaves the new one un-animated; the constant
* exists so the CSS has something to be checked against rather than a number
* nobody remembers.
*
* Currently 5: 3 sections, the divider, and 1 external link. The CSS carries
* rules up to :nth-child(8), so there is headroom before it needs touching.
*/
export const TOC_CHILD_COUNT = TOC_LINKS.length + TOC_EXTERNAL_LINKS.length + 1;
+81
View File
@@ -0,0 +1,81 @@
import site from "#/content/site.json";
/*
* The two things every copy file needs from every other one.
*
* The page's prose lives in `content/*.json` so it can be edited without opening
* a component — but prose is not self-contained. The hero quotes the install
* command, the install steps quote the Node version, the footer and the nav
* quote URLs, and every one of those also appears in site.json. Copying the
* literal into each file is how a page ends up advertising two different
* versions of the same command.
*
* So JSON carries references instead of values, and this file is the only place
* that turns one into the other:
*
* "npx {{installCommand}}" → fill() → the real command
* { "link": "github", … } → linkFor() → the real URL
*
* Both throw on an unknown key rather than degrading. A typo'd token would
* otherwise ship to a visitor as the literal text `{{nodeVerison}}`, and a
* typo'd link key as `undefined` in an href — failures that are invisible in
* review and obvious in production. Module-load is the right time to find out.
*/
export type LinkKey = keyof typeof site.links;
const TOKENS: Readonly<Record<string, string>> = {
installCommand: site.installCommand,
name: site.name,
nodeVersion: site.nodeVersion,
origin: site.origin,
tagline: site.tagline,
};
const TOKEN_PATTERN = /\{\{(\w+)\}\}/g;
/** Substitute every `{{token}}` in a copy string. */
export function fill(text: string): string {
return text.replace(TOKEN_PATTERN, (_whole, key: string) => {
const value = TOKENS[key];
if (value === undefined) {
throw new Error(
`content: unknown token {{${key}}}. Known tokens: ${Object.keys(TOKENS).sort().join(", ")}`
);
}
return value;
});
}
/** Resolve a `link` key from a copy file to its URL in site.json. */
export function linkFor(key: string): string {
const url = (site.links as Record<string, string>)[key];
if (url === undefined) {
throw new Error(
`content: unknown link "${key}". Known links: ${Object.keys(site.links).sort().join(", ")}`
);
}
return url;
}
/**
* Narrow a string from JSON to one of a known set of literals.
*
* `resolveJsonModule` widens every string in a JSON file to `string`, so a field
* that is a union in TypeScript — a token's `kind`, a card's `figure` — arrives
* untyped and unchecked. This restores both: the return type is the union, and
* an unrecognised value throws instead of silently rendering an element with a
* class nobody styled.
*/
export function oneOf<T extends string>(
allowed: readonly T[],
value: string,
field: string
): T {
if ((allowed as readonly string[]).includes(value)) {
return value as T;
}
throw new Error(
`content: "${value}" is not a valid ${field}. Expected one of: ${allowed.join(", ")}`
);
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "Airship",
"tagline": "Visual editor for your codebase.",
"description": "Airship puts an infinite design canvas in front of your dev server. Point at any element, describe the change in plain English, and Claude Code, Codex or OpenCode edits the source file — with streaming status, inline diffs, and undo.",
"origin": "https://airship.design",
"installCommand": "npx @airshiplabs/cli --target 3000",
"nodeVersion": "22.13",
"links": {
"github": "https://github.com/0xnyn/airship",
"issues": "https://github.com/0xnyn/airship/issues",
"license": "https://github.com/0xnyn/airship/blob/main/LICENSE",
"npm": "https://www.npmjs.com/package/@airshiplabs/cli",
"releases": "https://github.com/0xnyn/airship/releases"
}
}
+52
View File
@@ -0,0 +1,52 @@
import site from "#/content/site.json";
/*
* The product's primitives, read from site.json.
*
* Unlike the marketing example this app replaces, nothing here is invented:
* airship is a real tool in this repository, and every claim below is one the
* README and apps/cli/src/index.ts actually support. If a capability changes,
* site.json changes with it.
*
* This module is the typed face of that file — it exists so the rest of the app
* imports named constants rather than reaching into a JSON blob, and so the
* shape is checked once here instead of at every call site.
*/
export interface Site {
description: string;
name: string;
/**
* Only used for absolute URLs in metadata (canonical, OG image, sitemap).
* Relative URLs are correct everywhere else and survive being served from a
* preview deployment; these do not, which is why there is exactly one.
*/
origin: string;
tagline: string;
}
export const SITE: Site = {
description: site.description,
name: site.name,
origin: site.origin,
tagline: site.tagline,
};
/*
* Every off-site URL in one place, so a dead link is one edit rather than a grep.
*
* NOTE: `license` and `npm` are currently dead — there is no LICENSE file in the
* repo, and apps/cli is `"private": true` at 0.0.0 so nothing is published under
* that name. They are kept as the registry's record of where those things will
* live; nothing renders them today, and the footer deliberately does not.
*/
export const EXTERNAL_LINKS = site.links;
/** The one command quoted in more than one place. */
export const INSTALL_COMMAND: string = site.installCommand;
/**
* The minimum Node the CLI declares in its engines field. Quoted in the compat
* line and in the FAQ; if apps/cli bumps it, site.json is the place to follow.
*/
export const NODE_VERSION: string = site.nodeVersion;
+18
View File
@@ -0,0 +1,18 @@
{
"heading": "How it works",
"desc": "A visual canvas connected to your codebase.",
"steps": [
{
"id": "proxy",
"figure": "proxy",
"title": "Wrap your dev server with a visual canvas.",
"body": "Airship brings your running app into a Figma-like visual workspace. Work directly with the real UI, explore layouts, and experiment across device sizes without rebuilding it in a separate design tool."
},
{
"id": "source",
"figure": "source",
"title": "Bring your coding agent into the canvas.",
"body": "Use Claude Code, Codex, or OpenCode directly from Airship. Select an element, describe the change, and get the resulting code diff without switching between tools."
}
]
}
+43
View File
@@ -0,0 +1,43 @@
import { oneOf } from "#/content/resolve";
import steps from "#/content/steps.json";
/*
* The two things that distinguish airship from "an AI that edits your code".
* Each is a claim the README and packages/ actually back.
*
* There were three. "Frames are real viewports, not screenshots" was its own
* entry and is now the second half of the proxy card, because the two are one
* mechanism stated twice: the proxy is what makes a frame a same-origin iframe,
* and a same-origin iframe at a device width is what makes it a real viewport
* rather than a picture of one. Splitting them made the page count to three
* where the product only does two things.
*/
/** The illustrations components/figures can draw. */
export const STEP_FIGURES = ["proxy", "source"] as const;
export type StepFigure = (typeof STEP_FIGURES)[number];
export interface Step {
body: string;
/**
* Which figure the card draws. A key rather than a component, so steps.json
* stays free of code — and validated here, because JSON widens it to `string`
* and an unknown value would otherwise render a card with an empty top half.
*/
figure: StepFigure;
id: string;
title: string;
}
export const STEPS: readonly Step[] = steps.steps.map((step) => ({
body: step.body,
figure: oneOf(STEP_FIGURES, step.figure, `figure on step "${step.id}"`),
id: step.id,
title: step.title,
}));
export const STEPS_SECTION = {
desc: steps.desc,
heading: steps.heading,
};
+27
View File
@@ -0,0 +1,27 @@
import { EXTERNAL_LINKS, NODE_VERSION, SITE } from "#/content/site";
/*
* schema.org description of the product, for search engines and for the
* summarizers that increasingly read a page before a person does.
*
* Every claim here is one the README supports. `offers` at zero is not a growth
* tactic — airship is a local CLI with no account and no server, so "free" is
* simply what it costs, and omitting the field invites the guess that it is
* merely unpriced.
*/
export const SOFTWARE_JSON_LD = {
"@context": "https://schema.org",
"@type": "SoftwareApplication",
applicationCategory: "DeveloperApplication",
codeRepository: EXTERNAL_LINKS.github,
description: SITE.description,
name: SITE.name,
offers: {
"@type": "Offer",
price: "0",
priceCurrency: "USD",
},
operatingSystem: `macOS, Linux, Windows (Node ${NODE_VERSION}+)`,
softwareRequirements: `Node.js ${NODE_VERSION} or later, and one of Claude Code, OpenAI Codex or OpenCode`,
url: SITE.origin,
} as const;
+69
View File
@@ -0,0 +1,69 @@
/*
* The hero's 11.5-second loop, as named numbers.
*
* The same percentages appear in three places: the @keyframes in
* styles/hero-timeline.css, the act-by-act comment at the top of that file, and
* the handful of values JS needs (the reduced-motion still frame, the scrubber).
* Naming them here is the only defence against someone nudging a stop in the CSS
* while the comment quietly becomes a lie.
*
* CSS cannot import from TypeScript, so this file does not *drive* the
* stylesheet — it documents it, and the constants below are the ones code
* actually reads. Treat a mismatch between the two as a bug in the stylesheet.
*/
/**
* Loop length. Every track in hero-timeline.css runs at exactly this, via the
* `--ap-loop` custom property on `.hero-visual`.
*
* The number is a residue, not a target. Every beat's duration was chosen on its
* own terms — 72ms a character for the typing, ~460ms for the pointer to cross
* the stage, ~390ms per tool call, a ~1.3s hold on the settled frame — and this
* is what they add up to. It came down from 13s when the CLI act was cut, and it
* came down by exactly the length of that act: nothing else was compressed.
*
* So: to retune the pace, change a beat and let this follow. Changing this on
* its own scales all fifty tracks at once, which is occasionally what you want
* and never what you want by accident.
*/
export const LOOP_MS = 11_500;
/** 1% of the loop, in milliseconds — the scrubber's unit. */
export const FRAME_MS = LOOP_MS / 100;
/**
* The frame the mock is pinned at under `prefers-reduced-motion: reduce`, and
* the one to screenshot when comparing the mock against the real editor.
*
* The breathing beat: selection ring up, the panel on its Edit tab, and BOTH
* edits visible on the button — the agent's colour and the hand-scrubbed radius.
* It is the single frame that best sells the product as a still image, which
* matters because for a visitor who has asked their OS to stop animations it is
* the only frame they will ever see.
*/
export const SETTLED_FRAME = 77;
/** Act boundaries, for the scrubber's labels and for reading the stylesheet. */
export const ACTS = [
{ at: 0, id: "select", label: "Select" },
{ at: 17, id: "prompt", label: "Prompt" },
{ at: 36, id: "agent", label: "Agent" },
{ at: 56, id: "inspector", label: "Inspector" },
{ at: 74, id: "settle", label: "Breathe" },
{ at: 85.5, id: "teardown", label: "Teardown" },
{ at: 95.5, id: "gap", label: "Gap" },
] as const;
/**
* What the agent's edit changes.
*
* Shared between the inspector's fields and the mock page's button so the two
* cannot disagree — the payoff only reads as a payoff if the number the panel
* reports and the shape on the page are the same edit.
*/
export const TWEAKS = {
/** Changed by the agent, from the prompt. */
fill: { from: "1C1917", to: "E2603A" },
/** Changed by hand, scrubbed in the inspector. */
radius: { from: "6", to: "9999" },
} as const;