feat(web): the tanstack start app shell

apps/web is both airship's home page and the app `make run` points the CLI at —
which makes the site the thing that gets edited during development, and therefore
the first place a regression in the editor shows up.

routeTree.gen.ts is generated and committed on purpose so a clean checkout
typechecks without a build first; preflight regenerates and diffs it, because
committed generated files drift.
This commit is contained in:
Nayan
2026-08-08 13:54:00 +05:30
parent 27710507cb
commit 6611c6b21c
11 changed files with 520 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
{
"name": "@airship/web",
"version": "0.0.0",
"private": true,
"type": "module",
"imports": {
"#/*": "./src/*"
},
"scripts": {
"build": "vite build",
"clean": "rm -rf dist .output .nitro .tanstack node_modules/.vite .turbo",
"dev": "vite dev",
"og": "node scripts/og.mjs",
"routes": "tsr generate",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@airship/site-tokens": "workspace:*",
"@tanstack/react-router": "^1.170.0",
"@tanstack/react-start": "^1.168.0",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.0",
"@tanstack/router-cli": "^1.167.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.2.0",
"playwright": "^1.62.1",
"tailwindcss": "^4.3.0",
"typescript": "^5.7.0",
"vite": "^7.3.0",
"wrangler": "^4.42.0"
}
}
+12
View File
@@ -0,0 +1,12 @@
/**
* Join class names, dropping anything falsy.
*
* Deliberately not `clsx` and deliberately not `tailwind-merge`: this page is a
* CSS-first port whose class names are semantic (`.step-card`, `.toc-link`), not
* utility soup, so there are no conflicting Tailwind utilities to resolve and
* nothing to gain from a merge pass. Objects and nested arrays are unsupported
* for the same reason — if a call site wants one, it can spread it itself.
*/
export function cn(...parts: (string | false | null | undefined)[]): string {
return parts.filter(Boolean).join(" ");
}
+68
View File
@@ -0,0 +1,68 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/'
fileRoutesByTo: FileRoutesByTo
to: '/'
id: '__root__' | '/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
import type { getRouter } from './router.tsx'
import type { createStart } from '@tanstack/react-start'
declare module '@tanstack/react-start' {
interface Register {
ssr: true
router: Awaited<ReturnType<typeof getRouter>>
}
}
+17
View File
@@ -0,0 +1,17 @@
import { createRouter as createTanStackRouter } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";
export function getRouter() {
return createTanStackRouter({
defaultPreload: "intent",
defaultPreloadStaleTime: 0,
routeTree,
scrollRestoration: true,
});
}
declare module "@tanstack/react-router" {
interface Register {
router: ReturnType<typeof getRouter>;
}
}
+104
View File
@@ -0,0 +1,104 @@
import { createRootRoute, HeadContent, Scripts } from "@tanstack/react-router";
import { SITE } from "#/content/site";
import { SOFTWARE_JSON_LD } from "#/content/structured-data";
import { HERO_SCALE_SEED_SCRIPT } from "#/lib/hero-scale";
import appCss from "#/styles/app.css?url";
/*
* The document shell.
*
* There is no index.html — TanStack Start owns the document, which is why the
* <head> is described here as data rather than written as markup.
*/
export const Route = createRootRoute({
head: () => ({
links: [
{ href: appCss, rel: "stylesheet" },
{ href: "/favicon.svg", rel: "icon", type: "image/svg+xml" },
],
meta: [
{ charSet: "utf-8" },
{ content: "width=device-width, initial-scale=1", name: "viewport" },
{ title: `${SITE.name} — ${SITE.tagline}` },
{ content: SITE.description, name: "description" },
/*
* Absolute URLs, because a crawler resolving an Open Graph image against
* the wrong base is the classic way to ship a card with no picture.
*/
{ content: `${SITE.name} — ${SITE.tagline}`, property: "og:title" },
{ content: SITE.description, property: "og:description" },
{ content: `${SITE.origin}/og.png`, property: "og:image" },
/*
* Declared because the card is a fixed 1200×630 (scripts/og.mjs). A
* crawler that has to fetch the image before it can lay the card out
* often just renders a link instead; these let it reserve the box first.
*/
{ content: "1200", property: "og:image:width" },
{ content: "630", property: "og:image:height" },
{ content: `${SITE.name} — ${SITE.tagline}`, property: "og:image:alt" },
{ content: SITE.origin, property: "og:url" },
{ content: "website", property: "og:type" },
{ content: "summary_large_image", name: "twitter:card" },
/*
* The page is light, full stop — there is no second palette to switch to,
* so both of these are unconditional.
*
* `theme-color` names the shell surface rather than the page surface: the
* shell is what meets the top of the viewport, so it is what the browser
* chrome should continue. `color-scheme` is the half that speaks for the
* parts of the window the stylesheet does not own — scrollbars, form
* controls, the canvas painted before the first byte of CSS lands. Without
* it a visitor on a dark OS gets dark UA chrome framing a light page.
*/
{ content: "#ffffff", name: "theme-color" },
{ content: "light", name: "color-scheme" },
],
}),
shellComponent: RootDocument,
});
function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<HeadContent />
{/*
Sizes the hero mock before first paint. Render-blocking on purpose:
the server cannot know the viewport, the measurement cannot run until
hydration, and the frame in between is one a visitor sees. Without it
the mock paints at a constant scale and then snaps to the measured one.
`suppressHydrationWarning` on <html> above is the matching half — this
writes `--ap-mock-scale` onto the element the server rendered, so its
style attribute is expected to differ from the server's markup.
A <script> body cannot be expressed as a React child, and the content
is a module-level constant with no interpolated input — hence the
suppression below.
*/}
<script
// biome-ignore lint/security/noDangerouslySetInnerHtml: see above.
dangerouslySetInnerHTML={{ __html: HERO_SCALE_SEED_SCRIPT }}
/>
{/*
Structured data. Same constraint as the script above — a <script>
body cannot be a React child — and the content is JSON serialised from
a module-level constant, so there is no injection surface.
*/}
<script
// biome-ignore lint/security/noDangerouslySetInnerHtml: see above.
dangerouslySetInnerHTML={{ __html: JSON.stringify(SOFTWARE_JSON_LD) }}
type="application/ld+json"
/>
</head>
<body>
{children}
<Scripts />
</body>
</html>
);
}
+48
View File
@@ -0,0 +1,48 @@
import { createFileRoute } from "@tanstack/react-router";
import { HeroStage } from "#/components/hero/hero-stage";
import { AgentOutputSection } from "#/components/sections/agent-output-section";
import { FaqSection } from "#/components/sections/faq-section";
import { GetStartedSection } from "#/components/sections/get-started-section";
import { HeroSection } from "#/components/sections/hero-section";
import { HowItWorksSection } from "#/components/sections/how-it-works-section";
import { SiteFooter } from "#/components/sections/site-footer";
import { SiteHeader } from "#/components/shell/site-header";
import { SkipLink } from "#/components/shell/skip-link";
export const Route = createFileRoute("/")({
component: HomePage,
});
/**
* The whole site. One route, six sections, and nothing else — every anchor in
* content/nav.ts points at an `id` below, and the scroll-spy watches exactly
* those. Not every section has a link: `#output` is reached by scrolling, and
* `#overview` is what the wordmark points at.
*
* `HeroStage` is a direct child of <main> rather than living inside
* `HeroSection`, and that is structural rather than cosmetic: it is the one
* full-bleed element on the page, so it must sit outside `.content` and its
* horizontal padding. Nesting it and escaping with `width: 100vw` would have
* meant fighting both the scrollbar and `.content`'s centring.
*/
function HomePage() {
return (
<div className="layout" id="top">
<SkipLink />
<SiteHeader />
<main className="main" id="main-content">
<HeroSection />
<HeroStage />
<div className="content">
<HowItWorksSection />
<AgentOutputSection />
<GetStartedSection />
<FaqSection />
<SiteFooter />
</div>
</main>
</div>
);
}
+160
View File
@@ -0,0 +1,160 @@
/*
* The only stylesheet __root.tsx links. Everything else arrives through these
* imports, and the ORDER BELOW IS LOAD-BEARING in three separate ways.
*/
@import "tailwindcss";
/*
* 1. Unlayered on purpose. `@font-face` is not subject to cascade layers, and
* wrapping it in one is a no-op that reads as if it did something.
*
* Note what is NOT here: `@airship/editor-tokens/fonts.css`. That package
* declares the SAME family names ("Inter", "JetBrains Mono") pointing at its
* own copies of the same binaries, so importing both downloads every face
* twice. The hero needs the editor's *colours*, not its fonts — and it gets
* them from editor-mock.css below.
*/
@import "@airship/site-tokens/fonts.css";
/*
* 2. `layer(components)` is load-bearing. These files carry the `.pk-*`
* typography role classes alongside the custom properties, and unlayered CSS
* outranks every cascade layer — so without this, a bare `.pk-body` would beat
* every Tailwind utility that tried to adjust it.
*/
@import "@airship/site-tokens/tokens.css" layer(components);
/*
* editor-mock.css is the visual editor's own `--ap-*` palette, generated from
* packages/editor-tokens/EDITOR.md and scoped by this package's postbuild to
* `.ap-mock` — the one wrapper inside the hero's browser window. Scoped, not
* `:root`, because that palette is dark editor chrome and this is a light
* marketing page.
*/
@import "@airship/site-tokens/editor-mock.css" layer(components);
/*
* 3. These five are UNLAYERED, deliberately — the exact inverse of the reasoning
* two blocks up, and the reason that reasoning is spelled out there.
*
* This page is a near-literal port of a hand-written stylesheet: the geometry
* was transcribed, not re-derived, and it has to win. Unlayered CSS outranks
* preflight and every utility layer, which is precisely what a port needs.
*
* The consequence is real and intended: a Tailwind utility can never override
* a rule in these files. Do not "fix" this by wrapping them in a layer.
*/
@import "./shell.css";
@import "./cards.css";
@import "./hero-desktop.css";
@import "./hero-overlay.css";
@import "./hero-timeline.css";
/*
* Tailwind 4 has no config file — content sources are declared here.
*
* Relative to THIS FILE, which lives in src/styles/ — hence `../`. Getting this
* wrong does not error: Tailwind scans a directory that does not exist, finds no
* class names, and silently emits no utilities at all. The symptom is a utility
* like `sr-only` having no effect, which reads as a specificity problem rather
* than a missing file.
*/
@source "../components";
@source "../content";
@source "../routes";
/*
* The token spec, mapped into Tailwind's theme BY REFERENCE. `inline` means the
* utility resolves to `var(--pk-…)` rather than to the value it holds right now,
* so a token change flows through without a Tailwind rebuild — and, more to the
* point, so `bg-page` and `var(--pk-color-surface-page)` cannot disagree.
*/
@theme inline {
--font-sans: var(--pk-font-sans);
--font-mono: var(--pk-font-mono);
--color-primary: var(--pk-color-text-primary);
--color-secondary: var(--pk-color-text-secondary);
--color-tertiary: var(--pk-color-text-tertiary);
--color-muted: var(--pk-color-text-muted);
--color-faint: var(--pk-color-text-faint);
--color-page: var(--pk-color-surface-page);
--color-panel: var(--pk-color-surface-panel);
--color-shell: var(--pk-color-surface-shell);
--color-input: var(--pk-color-surface-input);
--color-chrome: var(--pk-color-surface-chrome);
--color-line: var(--pk-color-border-default);
--color-line-subtle: var(--pk-color-border-subtle);
--color-line-faint: var(--pk-color-border-faint);
--color-cta: var(--pk-color-cta-bg);
--color-on-cta: var(--pk-color-cta-text);
--color-cta-hover: var(--pk-color-cta-hover);
--radius-xs: var(--pk-radius-xs);
--radius-sm: var(--pk-radius-sm);
--radius-md: var(--pk-radius-md);
--radius-lg: var(--pk-radius-lg);
--radius-xl: var(--pk-radius-xl);
--radius-pill: var(--pk-radius-pill);
--ease-chrome: var(--pk-motion-ease-chrome);
--ease-panel: var(--pk-motion-ease-panel);
--ease-reveal: var(--pk-motion-ease-reveal);
--ease-overshoot: var(--pk-motion-ease-overshoot);
}
@layer base {
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
/*
* The page is light and has no second palette, so this says so to the parts
* of the window the stylesheet below does not paint: scrollbars, form
* controls, and the canvas the browser shows before any of this arrives.
* Left unset, a visitor on a dark OS gets those in dark against a light
* page. `.ap-mock` re-declares it as `dark` for its own subtree, which is
* correct — that is the editor's chrome, not the page.
*/
color-scheme: light;
/* Set on <html> rather than <body> so the overscroll gutter is the shell
colour too, not the browser's default canvas. */
background: var(--pk-color-surface-shell);
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
font-family: var(--pk-font-sans);
font-feature-settings:
"liga" 1,
"calt" 1;
-webkit-font-smoothing: antialiased;
color: var(--pk-color-text-primary);
/* Global, and every sans role in DESIGN.md restates it so applying a
`.pk-*` class never silently resets it to zero. */
letter-spacing: -0.045px;
background: var(--pk-color-surface-shell);
}
::selection {
color: var(--pk-color-selection-text);
background: var(--pk-color-selection-bg);
}
:focus-visible {
outline: 2px solid var(--pk-color-focus-ring);
outline-offset: 2px;
}
a {
color: inherit;
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"paths": {
"#/*": ["./src/*"]
},
"noEmit": true
},
"include": ["src", "vite.config.ts"]
}
+5
View File
@@ -0,0 +1,5 @@
{
"target": "react",
"routesDirectory": "./src/routes",
"generatedRouteTree": "./src/routeTree.gen.ts"
}
+17
View File
@@ -0,0 +1,17 @@
import tailwindcss from "@tailwindcss/vite";
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
// Plugin order matters: tailwind first so it sees every generated module, then
// TanStack Start (which owns the document shell and the route tree), then the
// React transform. No devtools plugin on purpose — this app gets driven through
// airship's own overlay, and a second floating panel in the corner fights it for
// the same screen real estate.
//
// Port 5173; airship's overlay always takes TARGET + 1, so `make run` serves the
// editor on 5174. See the root Makefile.
export default defineConfig({
plugins: [tailwindcss(), tanstackStart(), react()],
server: { port: 5173 },
});
+34
View File
@@ -0,0 +1,34 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "airship-web",
// TanStack Start's build already emits a module worker: dist/server/server.js
// exports `default = { fetch(request) }`. No adapter, no preset — the same
// artifact `make web:build` produces locally is what runs here.
"main": "./dist/server/server.js",
"compatibility_date": "2025-01-01",
// Required, not optional: the server entry imports node:async_hooks and
// node:stream. Without this flag the worker fails to boot at deploy time.
"compatibility_flags": ["nodejs_compat"],
// Static assets are served before the worker runs, so hashed client bundles
// never pay for an SSR invocation; anything unmatched falls through to the
// fetch handler above.
"assets": {
"directory": "./dist/client"
},
"observability": {
"enabled": true
},
// Both lanes name their target explicitly. Production could ride the
// top-level config instead, but once any env exists wrangler warns on an
// unqualified deploy — and `--env=""` is an awkward thing to thread through a
// GitHub Action's command string. Two named envs keeps both calls symmetric.
// main, compatibility_*, and assets are inherited from above.
"env": {
"production": {
"name": "airship-web"
},
"preview": {
"name": "airship-web-preview"
}
}
}