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:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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(" ");
|
||||
}
|
||||
@@ -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>>
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"target": "react",
|
||||
"routesDirectory": "./src/routes",
|
||||
"generatedRouteTree": "./src/routeTree.gen.ts"
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user