From 6611c6b21cd9302ae742bab0c21cb41afddbcbc8 Mon Sep 17 00:00:00 2001 From: Nayan Date: Sat, 8 Aug 2026 13:54:00 +0530 Subject: [PATCH] feat(web): the tanstack start app shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/web/package.json | 36 ++++++++ apps/web/src/lib/cn.ts | 12 +++ apps/web/src/routeTree.gen.ts | 68 ++++++++++++++ apps/web/src/router.tsx | 17 ++++ apps/web/src/routes/__root.tsx | 104 +++++++++++++++++++++ apps/web/src/routes/index.tsx | 48 ++++++++++ apps/web/src/styles/app.css | 160 +++++++++++++++++++++++++++++++++ apps/web/tsconfig.json | 19 ++++ apps/web/tsr.config.json | 5 ++ apps/web/vite.config.ts | 17 ++++ apps/web/wrangler.jsonc | 34 +++++++ 11 files changed, 520 insertions(+) create mode 100644 apps/web/package.json create mode 100644 apps/web/src/lib/cn.ts create mode 100644 apps/web/src/routeTree.gen.ts create mode 100644 apps/web/src/router.tsx create mode 100644 apps/web/src/routes/__root.tsx create mode 100644 apps/web/src/routes/index.tsx create mode 100644 apps/web/src/styles/app.css create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/tsr.config.json create mode 100644 apps/web/vite.config.ts create mode 100644 apps/web/wrangler.jsonc diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..459cd03 --- /dev/null +++ b/apps/web/package.json @@ -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" + } +} diff --git a/apps/web/src/lib/cn.ts b/apps/web/src/lib/cn.ts new file mode 100644 index 0000000..f43c2f1 --- /dev/null +++ b/apps/web/src/lib/cn.ts @@ -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(" "); +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts new file mode 100644 index 0000000..dceedff --- /dev/null +++ b/apps/web/src/routeTree.gen.ts @@ -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() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/react-start' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx new file mode 100644 index 0000000..a3ab709 --- /dev/null +++ b/apps/web/src/router.tsx @@ -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; + } +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx new file mode 100644 index 0000000..82ae156 --- /dev/null +++ b/apps/web/src/routes/__root.tsx @@ -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 + * 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 ( + + + + {/* + 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 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