diff --git a/TASKLIST.md b/TASKLIST.md
index db60e3b..d802094 100644
--- a/TASKLIST.md
+++ b/TASKLIST.md
@@ -152,3 +152,35 @@ all clean.
Still to do by hand at the gate: a real polyglot public repository charted end to end in real
Chrome, checking the tree language labels, the per-node badge, and Shiki highlighting for each
language.
+
+---
+
+## The landing page (`landing-page`, not a phase)
+
+A second full surface that no phase's exit test touches — `docs/UI_GUIDE.md` §3.1 specified it
+during Phase 3b and named it the next branch after that gate. Phases 4 and 5 went first.
+
+- [x] **Route.** `lib/router.tsx` over `pushState`; `/` is the landing page, `/app` is the canvas,
+ `APP_ROUTE` is shared so the OAuth callback stops returning people to the marketing page.
+- [x] **Hero.** Plain SVG drawing itself under a mask sweep, never an animated `pathLength`, with a
+ ghost node at the map's edge.
+- [x] **Sections.** Title block, hero, resolution, pipeline, index, coverage, closing, footer. Each
+ section rule is the confidence tier that is true of that section.
+- [x] **Installed, not written.** animate-ui `effects/fade`, `texts/sliding-number` and
+ `components-base-files`; `lenis` for smooth scrolling, mounted from `Landing` alone.
+- [x] **Palette.** Ember/Vellum replaced product-wide by Ultramarine/Letterpress. `UI_GUIDE.md`
+ §1.1 rewritten, §7.1 records the old one as shipped and replaced.
+- [x] **Done when.** `make test`, `make lint`, `make typecheck`, `make go-vet` clean.
+
+**Verified by hand in real Chrome:** both themes, the full page, the finished graph, `/app` still
+resolving its session. **Not verified, and stated as such in the PR:** the draw animation and the
+mobile breakpoints — `requestAnimationFrame` ran at roughly one frame per half-second in the
+available browser window and `resize_window` was ignored, so neither could be observed.
+
+## Next
+
+- [ ] **NFR-4 — `docker compose up`.** Four verified faults: `apps/web` has no Dockerfile; there is
+ no `worker` service, so a webhook enqueues a job nothing consumes; the API image runs `node
+ dist/index.js` against `packages/shared` exports that point at `.ts`; and the `parser` service
+ sets both `network_mode: none` and `depends_on` Postgres health. Do **not** resolve the last
+ by giving the parser a network — that undoes a Phase 1 guarantee.
diff --git a/apps/api/src/auth/routes.test.ts b/apps/api/src/auth/routes.test.ts
index d978e77..f3c5855 100644
--- a/apps/api/src/auth/routes.test.ts
+++ b/apps/api/src/auth/routes.test.ts
@@ -1,5 +1,6 @@
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import type { FastifyInstance } from "fastify";
+import { APP_ROUTE } from "@funcatlas/shared";
import { buildApp } from "../app.js";
import { env } from "../env.js";
import { cookieHeader } from "../test-helpers.js";
@@ -99,7 +100,10 @@ describe("GET /auth/callback", () => {
});
expect(res.statusCode).toBe(302);
- expect(res.headers.location).toBe(env.WEB_APP_URL);
+ // The canvas, not the web app's root -- the root is the marketing landing
+ // page, and pitching the product to someone who has just signed in to it
+ // is the failure this asserts against.
+ expect(res.headers.location).toBe(new URL(APP_ROUTE, env.WEB_APP_URL).toString());
// Reaching here proves the state in the redirect and the state in the
// cookie are the same value -- nothing else compares them.
diff --git a/apps/api/src/auth/routes.ts b/apps/api/src/auth/routes.ts
index fd893dc..3ad1b1f 100644
--- a/apps/api/src/auth/routes.ts
+++ b/apps/api/src/auth/routes.ts
@@ -1,6 +1,6 @@
import { randomBytes, timingSafeEqual } from "node:crypto";
import type { FastifyInstance } from "fastify";
-import { oauthCallbackSchema } from "@funcatlas/shared";
+import { APP_ROUTE, oauthCallbackSchema } from "@funcatlas/shared";
import { env } from "../env.js";
import {
OAUTH_SCOPES,
@@ -74,9 +74,10 @@ export function registerAuth(app: FastifyInstance) {
}
setSessionCookie(reply, sessionId);
- // The web app, not this one. Redirecting to the API's own origin lands a
- // freshly signed-in user on a JSON endpoint.
- return reply.redirect(env.WEB_APP_URL);
+ // The web app's canvas, not this API and not the web app's root. The root
+ // is the marketing landing page, so a bare origin here would sign someone
+ // in and then show them the pitch for the product they just signed in to.
+ return reply.redirect(new URL(APP_ROUTE, env.WEB_APP_URL).toString());
});
// POST, not GET: with SameSite=Lax a cross-site GET navigation still carries
diff --git a/apps/web/components.json b/apps/web/components.json
index c5f0814..06f5512 100644
--- a/apps/web/components.json
+++ b/apps/web/components.json
@@ -21,5 +21,7 @@
},
"menuColor": "default",
"menuAccent": "subtle",
- "registries": {}
+ "registries": {
+ "@animate-ui": "https://animate-ui.com/r/{name}.json"
+ }
}
diff --git a/apps/web/package.json b/apps/web/package.json
index 03a1809..c9b4c44 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -22,11 +22,13 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"framer-motion": "^11.18.2",
+ "lenis": "^1.3.26",
"lucide-react": "^0.469.0",
"next-themes": "^0.4.6",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-resizable-panels": "^4.12.2",
+ "react-use-measure": "^2.1.7",
"reactflow": "^11.11.4",
"shadcn": "^4.18.0",
"shiki": "^3.2.1",
diff --git a/apps/web/src/App.test.tsx b/apps/web/src/App.test.tsx
index 8ae3be0..4178531 100644
--- a/apps/web/src/App.test.tsx
+++ b/apps/web/src/App.test.tsx
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { APP_ROUTE } from "@funcatlas/shared";
import App from "./App";
import { ApiError, api } from "./lib/api";
@@ -36,6 +37,30 @@ const SIGNED_OUT = new ApiError(401, "unauthorized");
beforeEach(() => {
vi.clearAllMocks();
+ // Every one of these is about the canvas route. At jsdom's default `/` the
+ // app renders the marketing page, which resolves no session at all.
+ window.history.replaceState(null, "", APP_ROUTE);
+});
+
+describe("routing", () => {
+ it("shows the landing page at the root, without resolving a session", async () => {
+ window.history.replaceState(null, "", "/");
+ mocked.me.mockRejectedValue(SIGNED_OUT);
+
+ renderApp();
+
+ expect(await screen.findByRole("heading", { level: 1 })).toHaveTextContent(/map of every call/i);
+ expect(mocked.me).not.toHaveBeenCalled();
+ });
+
+ it("shows the canvas route for anything else", async () => {
+ window.history.replaceState(null, "", APP_ROUTE);
+ mocked.me.mockResolvedValue({ userId: 7, login: "octocat" });
+
+ renderApp();
+
+ expect(await screen.findByText("octocat")).toBeInTheDocument();
+ });
});
describe("session states", () => {
@@ -84,8 +109,16 @@ describe("session states", () => {
renderApp(false);
// A sign-in button here would point at an API that cannot answer.
- expect(await screen.findByText(/api is not responding/i)).toBeInTheDocument();
+ expect(await screen.findByText(/cannot reach its API/i)).toBeInTheDocument();
expect(screen.queryByRole("link", { name: /sign in with github/i })).not.toBeInTheDocument();
+
+ // On a deploy where only the web app is up, this screen is where a visitor
+ // lands. It has to offer a way onward rather than a command they cannot run.
+ expect(screen.getByRole("link", { name: "overview" })).toHaveAttribute("href", "/");
+ expect(screen.getByRole("link", { name: "source" })).toHaveAttribute(
+ "href",
+ expect.stringContaining("github.com"),
+ );
});
});
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index 01cdd64..258ed6f 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -1,18 +1,21 @@
import { useRef, useState } from "react";
-import type { SessionUser } from "@funcatlas/shared";
+import { APP_ROUTE, type SessionUser } from "@funcatlas/shared";
import type { PanelImperativeHandle } from "react-resizable-panels";
import { AppHeader } from "./components/AppHeader";
import { Canvas } from "./components/Canvas";
import { CommandPalette } from "./components/CommandPalette";
+import { Landing } from "./components/landing/Landing";
import { LoginScreen } from "./components/LoginScreen";
import { Sidebar } from "./components/Sidebar";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "./components/ui/resizable";
import { SidebarProvider } from "./components/ui/sidebar";
import { Skeleton } from "./components/ui/skeleton";
import { TooltipProvider } from "./components/ui/tooltip";
+import { Link, usePath } from "./lib/router";
import { useSession } from "./lib/session";
import {
CANVAS_DEFAULT,
+ GITHUB_REPO_URL,
SIDEBAR_DEFAULT,
SIDEBAR_MAX,
SIDEBAR_MIN,
@@ -27,13 +30,26 @@ import {
* back to an even split.
*/
+/**
+ * Two routes. Anything that is not the canvas is the landing page -- there is
+ * no 404, because there is nothing else to be.
+ *
+ * The split matters beyond tidiness: `useSession` lives inside `AppRoute`, so
+ * the landing page issues no request at all and renders with the API down.
+ * A marketing page that needs a backend to say what the product is is not a
+ * marketing page.
+ */
+export default function App() {
+ return usePath() === APP_ROUTE ? : ;
+}
+
/**
* Three states, and the server decides which: resolving, signed out, signed in.
*
* There is no local "is logged in" flag to drift out of sync -- the cookie is
* HttpOnly, so `useSession` asking the API is the only honest answer.
*/
-export default function App() {
+function AppRoute() {
const session = useSession();
if (session.isPending) {
@@ -149,14 +165,39 @@ function ResolvingSession() {
/**
* The API could not be reached at all -- which is different from being signed
* out, and showing a sign-in button here would send the user to a dead link.
+ *
+ * Written for two readers, because both really arrive here. One is running the
+ * stack and has forgotten a process. The other followed a link to a deploy
+ * where only the web app is up, and "run `pnpm dev`" means nothing to them --
+ * they need to know the page is not broken and where the source is.
*/
function SessionUnavailable() {
return (
-
-
-
The API is not responding
-
- Start it with pnpm dev, then reload.
+
+
+
The canvas cannot reach its API
+
+
+ Charting a repository needs the API, the parse worker, Postgres and Redis. None of them
+ answered, so there is nothing to draw.
+
+
+
+ Running it locally? Start them with make start{" "}
+ and reload. Otherwise the{" "}
+
+ source
+ {" "}
+ has the setup, and the{" "}
+
+ overview
+ {" "}
+ explains what it does.
diff --git a/apps/web/src/components/ConfidenceLegend.tsx b/apps/web/src/components/ConfidenceLegend.tsx
index 87a67f5..c11285a 100644
--- a/apps/web/src/components/ConfidenceLegend.tsx
+++ b/apps/web/src/components/ConfidenceLegend.tsx
@@ -1,5 +1,6 @@
import { cn } from "../lib/cn";
import { CONFIDENCE, CONFIDENCE_ORDER } from "../lib/confidence";
+import { ConfidenceRule } from "./ConfidenceRule";
import { Item, ItemContent, ItemDescription, ItemGroup, ItemMedia, ItemTitle } from "./ui/item";
/**
@@ -15,33 +16,12 @@ export function ConfidenceLegend({ className }: { className?: string }) {
return (
{CONFIDENCE_ORDER.map((tier) => {
- const { label, meaning, strokeDasharray, textClass } = CONFIDENCE[tier];
+ const { label, meaning, textClass } = CONFIDENCE[tier];
return (
- {/* The actual line, not a colour chip. A swatch would show the
- hue but not the pattern, and the pattern is the part that
- carries the meaning. currentColor, so it follows the theme. */}
-
+
diff --git a/apps/web/src/components/ConfidenceRule.tsx b/apps/web/src/components/ConfidenceRule.tsx
new file mode 100644
index 0000000..bb89f6e
--- /dev/null
+++ b/apps/web/src/components/ConfidenceRule.tsx
@@ -0,0 +1,41 @@
+import type { ResolutionConfidence } from "@funcatlas/shared";
+import { cn } from "../lib/cn";
+import { CONFIDENCE } from "../lib/confidence";
+
+/**
+ * One tier, drawn as the line the canvas draws it as.
+ *
+ * A colour swatch would show the hue and lose the pattern, and the pattern is
+ * the part that carries the meaning. `currentColor` through the tier's text
+ * class, so it follows the theme without reading it.
+ *
+ * Shared by the legend (fixed 28px) and the landing page (full width, as a
+ * section rule): the same three lines, drawn once.
+ */
+export function ConfidenceRule({
+ tier,
+ className,
+}: {
+ tier: ResolutionConfidence;
+ className?: string;
+}) {
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/animate-ui/components/base/files.tsx b/apps/web/src/components/animate-ui/components/base/files.tsx
new file mode 100644
index 0000000..233919d
--- /dev/null
+++ b/apps/web/src/components/animate-ui/components/base/files.tsx
@@ -0,0 +1,150 @@
+// Generated by `npx shadcn add @animate-ui/components-base-files`, then
+// edited: the upstream right-hand slot is a git status dot in hardcoded
+// green/amber/red. This project has no diff to report and no colour outside
+// tokens.ts, so the slot became `meta` and carries a function count.
+// `add --overwrite` silently reverts this -- see docs/UI_GUIDE.md §2.
+
+import * as React from 'react';
+import { FolderIcon, FolderOpenIcon, FileIcon } from 'lucide-react';
+
+import {
+ Files as FilesPrimitive,
+ FilesHighlight as FilesHighlightPrimitive,
+ FolderItem as FolderItemPrimitive,
+ FolderHeader as FolderHeaderPrimitive,
+ FolderTrigger as FolderTriggerPrimitive,
+ FolderHighlight as FolderHighlightPrimitive,
+ Folder as FolderPrimitive,
+ FolderIcon as FolderIconPrimitive,
+ FileLabel as FileLabelPrimitive,
+ FolderPanel as FolderPanelPrimitive,
+ FileHighlight as FileHighlightPrimitive,
+ File as FilePrimitive,
+ FileIcon as FileIconPrimitive,
+ type FilesProps as FilesPrimitiveProps,
+ type FolderItemProps as FolderItemPrimitiveProps,
+ type FolderPanelProps as FolderPanelPrimitiveProps,
+ type FileProps as FilePrimitiveProps,
+ type FileLabelProps as FileLabelPrimitiveProps,
+} from '@/components/animate-ui/primitives/base/files';
+import { cn } from '@/lib/cn';
+
+type FilesProps = FilesPrimitiveProps;
+
+function Files({ className, children, ...props }: FilesProps) {
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+type SubFilesProps = FilesProps;
+
+function SubFiles(props: SubFilesProps) {
+ return ;
+}
+
+type FolderItemProps = FolderItemPrimitiveProps;
+
+function FolderItem(props: FolderItemProps) {
+ return ;
+}
+
+type FolderTriggerProps = FileLabelPrimitiveProps & {
+ /** Replaces the upstream git-status dot: this project has no diff to
+ * report, and its hardcoded green/amber/red sit outside tokens.ts. */
+ meta?: React.ReactNode;
+};
+
+function FolderTrigger({
+ children,
+ className,
+ meta,
+ ...props
+}: FolderTriggerProps) {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/landing/ClosingCta.tsx b/apps/web/src/components/landing/ClosingCta.tsx
new file mode 100644
index 0000000..c270fa4
--- /dev/null
+++ b/apps/web/src/components/landing/ClosingCta.tsx
@@ -0,0 +1,33 @@
+import { OpenAtlas } from "./OpenAtlas";
+import { Section } from "./Section";
+
+/** The limits, under a dotted rule, because that is what a dotted line means
+ * everywhere else in this product. */
+const LIMITS = [
+ "Public repositories only. The OAuth scope is read:user, and the parser clones over public HTTPS.",
+ "Nothing is written to your GitHub account: no commits, no issues, no status checks.",
+ "A push updates the graph through a webhook, so what you are looking at is the current commit.",
+] as const;
+
+export function ClosingCta() {
+ return (
+
+
+
+ {LIMITS.map((limit) => (
+
+ {limit}
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/landing/GitHubStars.tsx b/apps/web/src/components/landing/GitHubStars.tsx
new file mode 100644
index 0000000..3a55ba8
--- /dev/null
+++ b/apps/web/src/components/landing/GitHubStars.tsx
@@ -0,0 +1,51 @@
+import { Github, Star } from "lucide-react";
+import { SlidingNumber } from "../animate-ui/primitives/texts/sliding-number";
+import { GITHUB_REPO_URL } from "../../lib/constants";
+import { useGitHubStars } from "../../lib/github";
+import { useMotionEnabled } from "../../lib/motion";
+
+/**
+ * A link to the source, carrying the star count once it arrives.
+ *
+ * The count rolls up from zero rather than appearing, because it lands late
+ * and a number that pops in reads as a layout shift. Reduced motion gets the
+ * final figure with no roll.
+ *
+ * The link is the component and the count is an ornament on it: GitHub
+ * rate-limits anonymous callers, so `isSuccess` is the common failure and the
+ * control has to stay useful without it.
+ */
+export function GitHubStars() {
+ const stars = useGitHubStars();
+ const animate = useMotionEnabled();
+
+ return (
+
+
+
+ {stars.isSuccess ? (
+
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/apps/web/src/components/landing/Hero.tsx b/apps/web/src/components/landing/Hero.tsx
new file mode 100644
index 0000000..752526a
--- /dev/null
+++ b/apps/web/src/components/landing/Hero.tsx
@@ -0,0 +1,64 @@
+import { cn } from "../../lib/cn";
+import { Bezel } from "./Bezel";
+import { HeroGraph } from "./HeroGraph";
+import { OpenAtlas } from "./OpenAtlas";
+import { LANDING_SHELL } from "./shell";
+
+/**
+ * The thesis, stated twice: once in the headline and once as a drawing.
+ *
+ * Split left and right the way the app itself is -- index on the left, map on
+ * the right -- so the page's shape is already the product's shape before a
+ * reader signs in.
+ *
+ * The headline drives Bricolage Grotesque's width and optical-size axes, which
+ * is the whole reason the face was chosen (UI_GUIDE §1.2) and is otherwise
+ * left unused.
+ */
+export function Hero() {
+ return (
+
+
+
+ exact · name_match · unresolved
+
+
+ {/* Sized to land on three lines beside the graph. Bricolage's width
+ axis is driven down a little so a long line fits without dropping
+ the display size, which is the axis existing to be used. */}
+
+ A map of every call in a repository, and of where the map ends.
+
+
+
+ funcatlas clones a repository, extracts every function and call site
+ with tree-sitter, and resolves each call to the function it reaches.
+ Where it cannot tell which function that is, it says so instead of
+ guessing.
+
+
+
+
+
+
+
+ Public repositories. GitHub sign-in at{" "}
+ read:user, and nothing is written
+ to your account.
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/landing/HeroGraph.test.tsx b/apps/web/src/components/landing/HeroGraph.test.tsx
new file mode 100644
index 0000000..4b5e5a3
--- /dev/null
+++ b/apps/web/src/components/landing/HeroGraph.test.tsx
@@ -0,0 +1,45 @@
+import { describe, expect, it } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { HERO_EDGES } from "../../lib/hero-graph";
+import { HeroGraph } from "./HeroGraph";
+
+function edgePaths(): SVGPathElement[] {
+ return Array.from(document.querySelectorAll("g > path"));
+}
+
+describe("HeroGraph", () => {
+ it("draws the three tiers as three different lines", () => {
+ render();
+
+ const paths = edgePaths();
+ expect(paths).toHaveLength(HERO_EDGES.length);
+
+ // The regression this exists for: Framer's `pathLength` writes an inline
+ // stroke-dasharray, which collapses solid, dashed and dotted into one
+ // pattern. The graph still animates, still looks fine, and has stopped
+ // saying the only thing it is there to say (PRD §8).
+ const patterns = new Set(
+ paths.map((path) => path.getAttribute("stroke-dasharray")),
+ );
+ expect(patterns.size).toBe(3);
+
+ // Solid is the absence of a pattern, not a pattern that looks solid.
+ expect(patterns).toContain(null);
+ });
+
+ it("describes itself for a reader who cannot see it", () => {
+ render();
+
+ const graph = screen.getByRole("img", { name: "A resolved call graph" });
+ expect(graph).toHaveAccessibleDescription(/could not be resolved/i);
+ });
+
+ it("labels the unresolved callee instead of hiding it", () => {
+ render();
+
+ // The map shows its own boundary (UI_GUIDE §3.2). Dropping the ghost node
+ // would make the hero claim a completeness the parser never promised.
+ expect(screen.getByText("formatError")).toBeInTheDocument();
+ expect(screen.getByText("Unresolved")).toBeInTheDocument();
+ });
+});
diff --git a/apps/web/src/components/landing/HeroGraph.tsx b/apps/web/src/components/landing/HeroGraph.tsx
new file mode 100644
index 0000000..3201af7
--- /dev/null
+++ b/apps/web/src/components/landing/HeroGraph.tsx
@@ -0,0 +1,145 @@
+import { useId } from "react";
+import { motion } from "framer-motion";
+import { cn } from "../../lib/cn";
+import { CONFIDENCE } from "../../lib/confidence";
+import { EDGE_STAGGER_SECONDS } from "../../lib/constants";
+import {
+ HERO_ALT,
+ HERO_EDGES,
+ HERO_NODE,
+ HERO_NODES,
+ HERO_VIEWBOX,
+ heroEdgePath,
+} from "../../lib/hero-graph";
+import { DURATION, EASE_DRAW, useMotionEnabled } from "../../lib/motion";
+
+/**
+ * The hero: a call graph drawing itself, not a picture of one.
+ *
+ * Plain SVG rather than React Flow. React Flow wants a measured container and
+ * brings pan, zoom and handles that a hero has no use for, along with the
+ * `node.width` trap in `docs/CANVAS_DECISIONS.md` §4. A fixed viewBox scales
+ * on its own and cannot drop an edge in silence.
+ *
+ * **Do not animate `pathLength`.** Framer implements it by writing an inline
+ * `stroke-dasharray`, which overwrites the dash pattern that distinguishes the
+ * three tiers -- solid, dashed and dotted all come out identical, which is the
+ * one picture this product must never show (PRD §8). The draw is a mask
+ * sweeping across instead, so every path keeps the dash pattern it was given.
+ *
+ * The sweep runs left to right and the graph is laid out by depth, so the
+ * sweep *is* the stagger: one animated element for the whole orchestrated
+ * moment (UI_GUIDE §4).
+ */
+export function HeroGraph({ className }: { className?: string }) {
+ const animate = useMotionEnabled();
+ const id = useId();
+ const titleId = `${id}-title`;
+ const descId = `${id}-desc`;
+ const maskId = `${id}-reveal`;
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/landing/HowItWorks.tsx b/apps/web/src/components/landing/HowItWorks.tsx
new file mode 100644
index 0000000..c121d5c
--- /dev/null
+++ b/apps/web/src/components/landing/HowItWorks.tsx
@@ -0,0 +1,57 @@
+import { Section } from "./Section";
+
+/**
+ * Four steps, numbered because this genuinely is a sequence — each one takes
+ * the output of the one before it. Numbering anything else on this page would
+ * be decoration wearing a structural costume.
+ */
+const STEPS = [
+ {
+ title: "Clone, in a box",
+ body: "The repository is cloned into a sandbox with no network, a read-only root filesystem, no capabilities and a non-root user. Symlinks fail the run rather than being followed; files over 1 MB are skipped.",
+ },
+ {
+ title: "Extract",
+ body: "tree-sitter walks every file for function declarations and call sites. One pinned grammar per extension, never shared, because a mismatched grammar fails silently and drops every call in the file.",
+ },
+ {
+ title: "Resolve",
+ body: "Each call site is matched against the symbol table and given a confidence tier. The table is partitioned by language, so no edge can cross a language boundary, and ambiguity resolves to unresolved.",
+ },
+ {
+ title: "Explore",
+ body: "The graph opens on a canvas: the file tree as an index, a file card, a function mind-map branching from it, and the source inline. ⌘K jumps to any function by name.",
+ },
+] as const;
+
+export function HowItWorks() {
+ return (
+
+
+ {STEPS.map((step, index) => (
+
+
+ {String(index + 1).padStart(2, "0")}
+
+
+
+
+ {step.title}
+
+
+ {step.body}
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/apps/web/src/components/landing/Index.tsx b/apps/web/src/components/landing/Index.tsx
new file mode 100644
index 0000000..c3c1df2
--- /dev/null
+++ b/apps/web/src/components/landing/Index.tsx
@@ -0,0 +1,139 @@
+import { FileCode2 } from "lucide-react";
+import {
+ FileItem,
+ FolderItem,
+ FolderPanel,
+ FolderTrigger,
+ Files,
+ SubFiles,
+} from "../animate-ui/components/base/files";
+import { Bezel } from "./Bezel";
+import { Section } from "./Section";
+
+/**
+ * What the reader lands on: the index, not the map.
+ *
+ * An atlas has an index and the file tree is it (UI_GUIDE §3.2), so the page
+ * shows one rather than describing it. The counts are the point -- a file's
+ * worth on this canvas is how many functions it holds, and that is the number
+ * the sidebar puts beside every path.
+ *
+ * The tree is this repository's own shape. A made-up `src/components/Button.tsx`
+ * would say nothing about a tool built to read a polyglot monorepo.
+ */
+function Count({ functions }: { functions: number }) {
+ return (
+
+ {functions}
+
+ );
+}
+
+export function Index() {
+ return (
+
+
+
+
+ One file card at a time, with as
+ many function branches off it as you open. Collapsing a branch hides
+ its subtree without forgetting it.
+
+
+ ⌘K jumps to any function by name,
+ across every file in the repository, and lands the canvas on it.
+
+
+ The selection survives a reload:
+ repository, file and every branch you opened. Rebuilding a map by
+ hand after a refresh is not exploring.
+
+
+
+ {/* Capped: a file tree stretched to the full shell puts a filename and
+ its count at opposite ends of the screen. */}
+
+
+
+ apps
+
+
+
+
+ web
+
+
+ }
+ >
+ App.tsx
+
+ }
+ >
+ Canvas.tsx
+
+ }
+ >
+ graph.ts
+
+
+
+
+
+ api
+
+
+ }
+ >
+ routes.ts
+
+
+
+
+
+
+
+
+ services
+
+
+
+ }
+ >
+ resolver.go
+
+ }
+ >
+ extract.go
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/landing/Landing.test.tsx b/apps/web/src/components/landing/Landing.test.tsx
new file mode 100644
index 0000000..6aad6f9
--- /dev/null
+++ b/apps/web/src/components/landing/Landing.test.tsx
@@ -0,0 +1,113 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { render, screen, within } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { APP_ROUTE } from "@funcatlas/shared";
+import { CONFIDENCE, CONFIDENCE_ORDER } from "../../lib/confidence";
+import { Landing } from "./Landing";
+
+// Smooth scrolling has nothing to assert in a document with no layout, and
+// Lenis reaches for scroll APIs jsdom does not implement.
+vi.mock("../../lib/useSmoothScroll", () => ({ useSmoothScroll: () => {} }));
+
+function renderLanding() {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return render(
+
+
+ ,
+ );
+}
+
+beforeEach(() => {
+ vi.restoreAllMocks();
+ window.history.replaceState(null, "", "/");
+});
+
+describe("the landing page", () => {
+ it("asks for nothing from our API", () => {
+ // The page explains the product. Needing the backend up to do that would
+ // make it fail exactly when someone most needs to read it.
+ const fetchSpy = vi
+ .spyOn(globalThis, "fetch")
+ .mockRejectedValue(new Error("offline"));
+
+ renderLanding();
+
+ const ours = fetchSpy.mock.calls.filter(
+ ([input]) => !String(input).includes("api.github.com"),
+ );
+ expect(ours).toEqual([]);
+ });
+
+ it("sends every call to action to the canvas", () => {
+ renderLanding();
+
+ const ctas = screen.getAllByRole("link", { name: /open the atlas/i });
+ expect(ctas.length).toBeGreaterThan(1);
+ for (const cta of ctas) {
+ expect(cta).toHaveAttribute("href", APP_ROUTE);
+ }
+ });
+
+ it("names all three tiers and what each one means", () => {
+ renderLanding();
+
+ const tiers = screen
+ .getByRole("heading", { name: /certainty is the product/i })
+ .closest("section");
+ expect(tiers).not.toBeNull();
+
+ for (const tier of CONFIDENCE_ORDER) {
+ const { label, meaning } = CONFIDENCE[tier];
+ expect(within(tiers as HTMLElement).getByText(label)).toBeInTheDocument();
+ expect(
+ within(tiers as HTMLElement).getByText(meaning),
+ ).toBeInTheDocument();
+ }
+ });
+
+ it("states the limits rather than leaving them to the docs", () => {
+ renderLanding();
+
+ // Public-repositories-only is the constraint a reader hits first, and
+ // finding it out after signing in is the experience this prevents.
+ expect(screen.getByText(/public repositories only/i)).toBeInTheDocument();
+ // Said in the hero and again at the closing call to action -- a reader who
+ // scrolls past the first one still meets it before signing in.
+ expect(screen.getAllByText(/read:user/).length).toBeGreaterThan(1);
+ expect(
+ screen.getByText(/extracted, resolved within a file/i),
+ ).toBeInTheDocument();
+ });
+
+ it("shows the index with a function count on every file", () => {
+ renderLanding();
+
+ const index = screen
+ .getByRole("heading", { name: /an atlas has an index/i })
+ .closest("section");
+ expect(index).not.toBeNull();
+
+ // The count is what the sidebar puts beside a path, and it is the reason
+ // the tree is worth showing at all rather than describing.
+ const files = within(index as HTMLElement).getAllByText(/\.(tsx?|go)$/);
+ expect(files.length).toBeGreaterThan(3);
+ expect(within(index as HTMLElement).getByText("23")).toBeInTheDocument();
+ });
+
+ it("links to the source even when GitHub will not answer", async () => {
+ vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("rate limited"));
+
+ renderLanding();
+
+ const links = await screen.findAllByRole("link", {
+ name: /funcatlas on github/i,
+ });
+ expect(links[0]).toHaveAttribute(
+ "href",
+ expect.stringContaining("github.com"),
+ );
+ });
+});
diff --git a/apps/web/src/components/landing/Landing.tsx b/apps/web/src/components/landing/Landing.tsx
new file mode 100644
index 0000000..dc226c1
--- /dev/null
+++ b/apps/web/src/components/landing/Landing.tsx
@@ -0,0 +1,42 @@
+import { useMotionEnabled } from "../../lib/motion";
+import { useSmoothScroll } from "../../lib/useSmoothScroll";
+import { ClosingCta } from "./ClosingCta";
+import { Hero } from "./Hero";
+import { HowItWorks } from "./HowItWorks";
+import { Index } from "./Index";
+import { LandingFooter } from "./LandingFooter";
+import { LandingHeader } from "./LandingHeader";
+import { Languages } from "./Languages";
+import { Tiers } from "./Tiers";
+
+/**
+ * The marketing surface, at `/`.
+ *
+ * It calls no API. The session is resolved inside the canvas route, not here,
+ * so this page renders whether or not the backend is up -- which is the least
+ * a page whose job is to explain the product can do.
+ *
+ * Smooth scrolling is mounted here rather than at the app root: the canvas
+ * treats the wheel as zoom, and a scroll library at the root would take those
+ * gestures away from it.
+ */
+export function Landing() {
+ useSmoothScroll(useMotionEnabled());
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/landing/LandingFooter.tsx b/apps/web/src/components/landing/LandingFooter.tsx
new file mode 100644
index 0000000..9e4af44
--- /dev/null
+++ b/apps/web/src/components/landing/LandingFooter.tsx
@@ -0,0 +1,32 @@
+import { cn } from "../../lib/cn";
+import { GITHUB_REPO_URL } from "../../lib/constants";
+import { LANDING_SHELL } from "./shell";
+
+export function LandingFooter() {
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/landing/LandingHeader.tsx b/apps/web/src/components/landing/LandingHeader.tsx
new file mode 100644
index 0000000..ccf3198
--- /dev/null
+++ b/apps/web/src/components/landing/LandingHeader.tsx
@@ -0,0 +1,37 @@
+import { cn } from "../../lib/cn";
+import { ThemeToggle } from "../ThemeToggle";
+import { GitHubStars } from "./GitHubStars";
+import { OpenAtlas } from "./OpenAtlas";
+import { LANDING_SHELL } from "./shell";
+
+/**
+ * A chart's title block, not a navigation bar.
+ *
+ * Deliberately not sticky and deliberately not a floating pill: there is
+ * nowhere to navigate to -- no in-page anchors, three controls -- so a bar
+ * that follows the reader down the page would be encoding nothing. The closing
+ * section carries the call to action instead.
+ */
+export function LandingHeader() {
+ return (
+
+
+ funcatlas
+
+
+
+
+
+
+ {/* Hidden on the narrowest widths, where the hero's own call to action
+ is already on screen and a second one crowds the wordmark out. */}
+
+
+
+ );
+}
diff --git a/apps/web/src/components/landing/Languages.tsx b/apps/web/src/components/landing/Languages.tsx
new file mode 100644
index 0000000..f7a2438
--- /dev/null
+++ b/apps/web/src/components/landing/Languages.tsx
@@ -0,0 +1,60 @@
+import { Bezel } from "./Bezel";
+import { Section } from "./Section";
+
+/**
+ * Eight languages, in the two groups that are actually true of them.
+ *
+ * The grouping is the honest footnote, which is why there is no asterisk: past
+ * the ECMAScript family the parser extracts functions and calls and resolves
+ * only within a file. Saying "eight languages" and leaving that in the docs
+ * would be the kind of claim this product exists not to make.
+ */
+const GROUPS = [
+ {
+ heading: "Resolved across files",
+ detail:
+ "Imports are followed, so a call reaches a definition in another file.",
+ languages: ["TypeScript", "TSX", "JavaScript", "JSX"],
+ },
+ {
+ heading: "Extracted, resolved within a file",
+ detail:
+ "Every function and call site is charted. Cross-file resolution is not built yet.",
+ languages: ["Go", "Rust", "Python", "Java"],
+ },
+] as const;
+
+export function Languages() {
+ return (
+
+
+ {GROUPS.map((group) => (
+
+
+ {group.heading}
+
+
+ {group.detail}
+
+
+
+ {group.languages.map((language) => (
+
+ {language}
+
+ ))}
+
+
+ ))}
+
+
+ );
+}
diff --git a/apps/web/src/components/landing/OpenAtlas.tsx b/apps/web/src/components/landing/OpenAtlas.tsx
new file mode 100644
index 0000000..c040553
--- /dev/null
+++ b/apps/web/src/components/landing/OpenAtlas.tsx
@@ -0,0 +1,40 @@
+import { APP_ROUTE } from "@funcatlas/shared";
+import { ArrowRight } from "lucide-react";
+import { cn } from "../../lib/cn";
+import { Link } from "../../lib/router";
+import { buttonVariants } from "../ui/button";
+
+/**
+ * The page's only call to action, and it says the same thing in both places it
+ * appears — the hero and the closing section. One name for one action, kept
+ * through the flow (UI_GUIDE §3.4).
+ *
+ * It goes to `/app` rather than straight to the OAuth endpoint: the sign-in
+ * card is the surface that carries "Sign in with GitHub" (§3.1), and sending a
+ * reader through it is how they learn the confidence legend before the canvas
+ * uses it.
+ */
+export function OpenAtlas({
+ size = "lg",
+ className,
+}: {
+ size?: "sm" | "lg";
+ className?: string;
+}) {
+ return (
+
+ Open the atlas
+ {/* The arrow rides in its own well, so the control reads as a thing with
+ a moving part rather than as text with a glyph after it. */}
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/landing/Reveal.tsx b/apps/web/src/components/landing/Reveal.tsx
new file mode 100644
index 0000000..6b4e7dd
--- /dev/null
+++ b/apps/web/src/components/landing/Reveal.tsx
@@ -0,0 +1,36 @@
+import type { ReactNode } from "react";
+import { Fade } from "../animate-ui/primitives/effects/fade";
+import { useMotionEnabled } from "../../lib/motion";
+
+/**
+ * A section arriving as it is scrolled to. Opacity only.
+ *
+ * No slide, no blur, no scale: the hero is the page's one orchestrated moment
+ * (UI_GUIDE §4), and a section that also moves turns that into scattered
+ * effects -- which is the most reliable tell of a generated design.
+ *
+ * `Fade` does not check `prefers-reduced-motion` itself, so the branch is
+ * here: reduced motion renders the content with no wrapper at all rather than
+ * a wrapper that animates instantly.
+ */
+export function Reveal({
+ children,
+ className,
+ delay,
+}: {
+ children: ReactNode;
+ className?: string;
+ delay?: number;
+}) {
+ const animate = useMotionEnabled();
+
+ if (!animate) {
+ return
{children}
;
+ }
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/web/src/components/landing/Section.tsx b/apps/web/src/components/landing/Section.tsx
new file mode 100644
index 0000000..94f8821
--- /dev/null
+++ b/apps/web/src/components/landing/Section.tsx
@@ -0,0 +1,69 @@
+import type { ReactNode } from "react";
+import type { ResolutionConfidence } from "@funcatlas/shared";
+import { cn } from "../../lib/cn";
+import { ConfidenceRule } from "../ConfidenceRule";
+import { Reveal } from "./Reveal";
+import { LANDING_SHELL } from "./shell";
+
+/**
+ * One section of the landing page: a rule, an eyebrow, a heading, a lede, and
+ * the content.
+ *
+ * The rule is the page's structural device and it is not decoration -- its
+ * dash pattern is a confidence tier, and each section takes the tier that is
+ * true of it. The tiers section is solid, languages is dashed because support
+ * genuinely is partial past the ECMAScript family, and the closing section is
+ * dotted because what it states are the product's limits. A reader who has
+ * scrolled the page has read the notation three times before ever reaching the
+ * canvas.
+ *
+ * The generous padding is deliberate and belongs to this surface only
+ * (UI_GUIDE §3.1): the canvas is dense by nature, and marketing whitespace on
+ * a file tree is how a tool starts feeling like a brochure.
+ */
+export function Section({
+ tier,
+ eyebrow,
+ title,
+ lede,
+ children,
+ className,
+}: {
+ tier: ResolutionConfidence;
+ eyebrow: string;
+ title: string;
+ lede?: string;
+ children: ReactNode;
+ className?: string;
+}) {
+ return (
+
+
+
+
+
+
+ {eyebrow}
+
+
+
+ {title}
+
+
+ {lede === undefined ? null : (
+
+ {lede}
+
+ )}
+
+
+ {children}
+
+
+ );
+}
diff --git a/apps/web/src/components/landing/Tiers.tsx b/apps/web/src/components/landing/Tiers.tsx
new file mode 100644
index 0000000..31650ae
--- /dev/null
+++ b/apps/web/src/components/landing/Tiers.tsx
@@ -0,0 +1,68 @@
+import type { ResolutionConfidence } from "@funcatlas/shared";
+import { cn } from "../../lib/cn";
+import { CONFIDENCE, CONFIDENCE_ORDER } from "../../lib/confidence";
+import { ConfidenceRule } from "../ConfidenceRule";
+import { Bezel } from "./Bezel";
+import { Section } from "./Section";
+
+/**
+ * What produces each tier, in a sentence a reader can check against their own
+ * codebase. The tier's name and meaning come from `lib/confidence`, which the
+ * canvas legend also reads -- so this page cannot end up describing a scale
+ * the product does not draw.
+ */
+const CAUSE: Record = {
+ exact:
+ "The import was followed to a declaration, and only one function could be the target.",
+ name_match:
+ "A function with that name is in scope. Another one elsewhere may be the function actually called.",
+ unresolved:
+ "The call is real and its target is ambiguous: a barrel re-export, a default import, a path alias.",
+};
+
+export function Tiers() {
+ return (
+
+
+ An unresolved call is an admission, not an error, and it is never
+ coloured like one. A tool that guesses is worse than a tool that stops,
+ because a wrong edge is read as fact and costs more than the missing one
+ it replaced.
+
+
+ );
+}
diff --git a/apps/web/src/components/landing/shell.ts b/apps/web/src/components/landing/shell.ts
new file mode 100644
index 0000000..774159f
--- /dev/null
+++ b/apps/web/src/components/landing/shell.ts
@@ -0,0 +1,13 @@
+/**
+ * The landing page's one content width.
+ *
+ * Written once because four surfaces share it -- the title block, the hero,
+ * every section and the footer -- and when they each carried their own max
+ * width the wordmark did not line up with the headline under it.
+ *
+ * Wide on purpose. A 64rem measure centred on a modern display reads as a
+ * column floating in a field of ground rather than as a page. Prose inside
+ * still caps itself at a readable measure; it is the layout that fills the
+ * screen, not the paragraphs.
+ */
+export const LANDING_SHELL = "mx-auto w-full max-w-[104rem] px-6 sm:px-10 lg:px-16";
diff --git a/apps/web/src/hooks/use-controlled-state.tsx b/apps/web/src/hooks/use-controlled-state.tsx
new file mode 100644
index 0000000..f806fad
--- /dev/null
+++ b/apps/web/src/hooks/use-controlled-state.tsx
@@ -0,0 +1,33 @@
+import * as React from 'react';
+
+interface CommonControlledStateProps {
+ value?: T;
+ defaultValue?: T;
+}
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function useControlledState(
+ props: CommonControlledStateProps & {
+ onChange?: (value: T, ...args: Rest) => void;
+ },
+): readonly [T, (next: T, ...args: Rest) => void] {
+ const { value, defaultValue, onChange } = props;
+
+ const [state, setInternalState] = React.useState(
+ value !== undefined ? value : (defaultValue as T),
+ );
+
+ React.useEffect(() => {
+ if (value !== undefined) setInternalState(value);
+ }, [value]);
+
+ const setState = React.useCallback(
+ (next: T, ...args: Rest) => {
+ setInternalState(next);
+ onChange?.(next, ...args);
+ },
+ [onChange],
+ );
+
+ return [state, setState] as const;
+}
diff --git a/apps/web/src/hooks/use-is-in-view.tsx b/apps/web/src/hooks/use-is-in-view.tsx
new file mode 100644
index 0000000..9554e47
--- /dev/null
+++ b/apps/web/src/hooks/use-is-in-view.tsx
@@ -0,0 +1,29 @@
+// Generated by `npx shadcn add @animate-ui/...`, then edited: the import was
+// `motion/react`, which is a second copy of Framer Motion beside the
+// `framer-motion` this project locks. Same API, so retargeting is enough.
+// `add --overwrite` silently reverts this -- see docs/UI_GUIDE.md §2.
+import * as React from 'react';
+import { useInView, type UseInViewOptions } from 'framer-motion';
+
+interface UseIsInViewOptions {
+ inView?: boolean;
+ inViewOnce?: boolean;
+ inViewMargin?: UseInViewOptions['margin'];
+}
+
+function useIsInView(
+ ref: React.Ref,
+ options: UseIsInViewOptions = {},
+) {
+ const { inView, inViewOnce = false, inViewMargin = '0px' } = options;
+ const localRef = React.useRef(null);
+ React.useImperativeHandle(ref, () => localRef.current as T);
+ const inViewResult = useInView(localRef, {
+ once: inViewOnce,
+ margin: inViewMargin,
+ });
+ const isInView = !inView || inViewResult;
+ return { ref: localRef, isInView };
+}
+
+export { useIsInView, type UseIsInViewOptions };
diff --git a/apps/web/src/lib/confidence.ts b/apps/web/src/lib/confidence.ts
index 5d9bb9e..530300a 100644
--- a/apps/web/src/lib/confidence.ts
+++ b/apps/web/src/lib/confidence.ts
@@ -42,6 +42,11 @@ export interface ConfidencePresentation {
* a class name built by concatenation and would purge it. Resolves through
* a CSS variable, so it follows the active theme on its own. */
textClass: string;
+ /** For SVG we draw ourselves -- the landing page's hero graph. Same reason
+ * the literal is complete, and the same reason it beats `confidenceColor`
+ * here: a class follows the theme without the component subscribing to it.
+ * The canvas still needs the raw value; see `confidenceColor`. */
+ strokeClass: string;
/** What the tier is called in the interface. */
label: string;
/** What it actually means, shown in the canvas legend. */
@@ -53,6 +58,7 @@ export const CONFIDENCE: Record =
style: CONFIDENCE_STYLE.exact,
strokeDasharray: DASH_ARRAY[CONFIDENCE_STYLE.exact],
textClass: "text-confidence-exact",
+ strokeClass: "stroke-confidence-exact",
label: "Exact",
meaning: "Matched to this function.",
},
@@ -60,6 +66,7 @@ export const CONFIDENCE: Record =
style: CONFIDENCE_STYLE.name_match,
strokeDasharray: DASH_ARRAY[CONFIDENCE_STYLE.name_match],
textClass: "text-confidence-name",
+ strokeClass: "stroke-confidence-name",
label: "Name match",
meaning: "A function with this name is in scope, but it may not be the one called.",
},
@@ -67,6 +74,7 @@ export const CONFIDENCE: Record =
style: CONFIDENCE_STYLE.unresolved,
strokeDasharray: DASH_ARRAY[CONFIDENCE_STYLE.unresolved],
textClass: "text-confidence-unresolved",
+ strokeClass: "stroke-confidence-unresolved",
label: "Unresolved",
meaning: "The call is real, but which function it reaches could not be determined.",
},
diff --git a/apps/web/src/lib/constants.ts b/apps/web/src/lib/constants.ts
index 459c967..00b0576 100644
--- a/apps/web/src/lib/constants.ts
+++ b/apps/web/src/lib/constants.ts
@@ -28,6 +28,14 @@ export const THEME_STORAGE_KEY = "funcatlas-theme";
* complaint that put this here. */
export const UI_STORAGE_KEY = "funcatlas-ui";
+// --- Landing page ---------------------------------------------------------
+
+/** The repository the landing page links to and reads a star count from.
+ * Only the web app needs it, so it stays here rather than in the shared
+ * package. */
+export const GITHUB_REPO = "ARCoder181105/funcatlas";
+export const GITHUB_REPO_URL = `https://github.com/${GITHUB_REPO}`;
+
// --- Motion ---------------------------------------------------------------
/** Milliseconds. Page-level motion in UI_GUIDE §4 is 400-600ms, and this moves
diff --git a/apps/web/src/lib/get-strict-context.tsx b/apps/web/src/lib/get-strict-context.tsx
new file mode 100644
index 0000000..be139dc
--- /dev/null
+++ b/apps/web/src/lib/get-strict-context.tsx
@@ -0,0 +1,36 @@
+import * as React from 'react';
+
+function getStrictContext(
+ name?: string,
+): readonly [
+ ({
+ value,
+ children,
+ }: {
+ value: T;
+ children?: React.ReactNode;
+ }) => React.JSX.Element,
+ () => T,
+] {
+ const Context = React.createContext(undefined);
+
+ const Provider = ({
+ value,
+ children,
+ }: {
+ value: T;
+ children?: React.ReactNode;
+ }) => {children};
+
+ const useSafeContext = () => {
+ const ctx = React.useContext(Context);
+ if (ctx === undefined) {
+ throw new Error(`useContext must be used within ${name ?? 'a Provider'}`);
+ }
+ return ctx;
+ };
+
+ return [Provider, useSafeContext] as const;
+}
+
+export { getStrictContext };
diff --git a/apps/web/src/lib/github.ts b/apps/web/src/lib/github.ts
new file mode 100644
index 0000000..4a8eb04
--- /dev/null
+++ b/apps/web/src/lib/github.ts
@@ -0,0 +1,31 @@
+import { useQuery } from "@tanstack/react-query";
+import { GITHUB_REPO } from "./constants";
+
+/**
+ * The repository's star count.
+ *
+ * A bare `fetch` rather than `lib/api.ts`'s `request`: that helper carries
+ * our credentials and our error shape, and this is a public endpoint on
+ * someone else's origin. Sending a session cookie to GitHub would be the bug.
+ *
+ * Not retried and not required. GitHub rate-limits unauthenticated callers
+ * hard, so a failure here is ordinary -- the header falls back to a plain link
+ * to the repository, which is the part that actually matters.
+ */
+export function useGitHubStars() {
+ return useQuery({
+ queryKey: ["github-stars", GITHUB_REPO],
+ retry: false,
+ staleTime: Infinity,
+ queryFn: async (): Promise => {
+ const res = await fetch(`https://api.github.com/repos/${GITHUB_REPO}`);
+ if (!res.ok) throw new Error(`github responded ${res.status}`);
+
+ const body: unknown = await res.json();
+ const count = (body as { stargazers_count?: unknown }).stargazers_count;
+ if (typeof count !== "number") throw new Error("github sent no star count");
+
+ return count;
+ },
+ });
+}
diff --git a/apps/web/src/lib/hero-graph.test.ts b/apps/web/src/lib/hero-graph.test.ts
new file mode 100644
index 0000000..1b08ed5
--- /dev/null
+++ b/apps/web/src/lib/hero-graph.test.ts
@@ -0,0 +1,60 @@
+import { describe, expect, it } from "vitest";
+import { CONFIDENCE_STYLE } from "@funcatlas/shared";
+import { HERO_ALT, HERO_EDGES, HERO_NODES, heroEdgePath, heroNode } from "./hero-graph";
+
+describe("the hero fixture", () => {
+ it("names an existing node at both ends of every edge", () => {
+ const ids = new Set(HERO_NODES.map((node) => node.id));
+
+ for (const edge of HERO_EDGES) {
+ expect(ids).toContain(edge.from);
+ expect(ids).toContain(edge.to);
+ }
+ });
+
+ it("shows all three tiers", () => {
+ // The hero exists to teach the scale. Losing a tier to an edit would leave
+ // it teaching two thirds of it, and nothing else would fail.
+ const tiers = new Set(HERO_EDGES.map((edge) => edge.tier));
+
+ expect(tiers).toEqual(new Set(Object.keys(CONFIDENCE_STYLE)));
+ });
+
+ it("puts the one ghost node at the end of the unresolved edge", () => {
+ const ghosts = HERO_NODES.filter((node) => node.ghost === true);
+ const unresolved = HERO_EDGES.filter((edge) => edge.tier === "unresolved");
+
+ expect(ghosts).toHaveLength(1);
+ expect(unresolved.map((edge) => edge.to)).toEqual(ghosts.map((node) => node.id));
+ });
+
+ it("draws a callee to the right of its caller", () => {
+ // The reveal sweeps left to right and is the only thing staggering the
+ // draw, so a callee placed left of its caller would appear before the call
+ // that reaches it.
+ for (const edge of HERO_EDGES) {
+ expect(heroNode(edge.to).depth).toBeGreaterThan(heroNode(edge.from).depth);
+ }
+ });
+
+ it("describes every tier it draws, for a reader who cannot see it", () => {
+ expect(HERO_ALT).toMatch(/exact/i);
+ expect(HERO_ALT).toMatch(/name match/i);
+ expect(HERO_ALT).toMatch(/resolved/i);
+ });
+});
+
+describe("heroEdgePath", () => {
+ it("runs from the caller's right edge to the callee's left edge", () => {
+ const path = heroEdgePath({ from: "handleRequest", to: "parseBody", tier: "exact" });
+
+ // 20 + 132 wide, centred on 116 + 18; target starts at 214, centred on 42.
+ expect(path).toBe("M 152 134 C 183 134, 183 42, 214 42");
+ });
+
+ it("refuses a node it does not have rather than drawing from NaN", () => {
+ expect(() => heroEdgePath({ from: "handleRequest", to: "nope", tier: "exact" })).toThrow(
+ /no node nope/,
+ );
+ });
+});
diff --git a/apps/web/src/lib/hero-graph.ts b/apps/web/src/lib/hero-graph.ts
new file mode 100644
index 0000000..db1b61e
--- /dev/null
+++ b/apps/web/src/lib/hero-graph.ts
@@ -0,0 +1,110 @@
+import type { ResolutionConfidence } from "@funcatlas/shared";
+
+/**
+ * The graph the landing page draws, and the geometry it is drawn with.
+ *
+ * A fixture, not a screenshot and not a fetch: the landing page talks to no
+ * API (see `App.tsx`). It is still a real drawing -- the same three edge
+ * styles, the same rule that ambiguity resolves to `unresolved`, and the ghost
+ * node from `docs/UI_GUIDE.md` §3.2 marking the edge of what was charted.
+ *
+ * The shape is a request handler, because that is the code a reader already
+ * has a mental model of. Every tier here is one a real repository produces:
+ * `logger.info` is a name match because more than one `info` is in scope, and
+ * `formatError` is unresolved because it arrives through a barrel re-export,
+ * which `docs/PARSING_STRATEGY.md` lists as a limit we do not guess past.
+ *
+ * Data and coordinates live here rather than in the component so the component
+ * is markup and this is testable.
+ */
+
+export interface HeroNode {
+ id: string;
+ label: string;
+ /** Column index. Also the stagger index: the graph draws outward, so a
+ * node's depth is when it appears. */
+ depth: number;
+ /** Top-left, in viewBox units. */
+ x: number;
+ y: number;
+ /**
+ * A callee the resolver could not reach -- drawn at the map's edge, dotted
+ * and faded, labelled with the name the parser actually saw. Not an error
+ * and not hidden: the map showing its own boundary (UI_GUIDE §3.2).
+ */
+ ghost?: boolean;
+}
+
+export interface HeroEdge {
+ from: string;
+ to: string;
+ tier: ResolutionConfidence;
+}
+
+/**
+ * Wide rather than tall: the hero sits beside the headline, and the graph
+ * reads left to right because that is the direction calls run.
+ *
+ * The height is measured off the drawing rather than picked -- 268 is the
+ * lowest node's baseline plus the ghost's caption plus a margin. A taller box
+ * scales the whole graph down to fit its own empty space.
+ */
+export const HERO_VIEWBOX = { width: 560, height: 268 } as const;
+
+/** One size for every node. Measured off `handleRequest` at mono 12px, which
+ * is the longest label here; a card narrower than its own name is the bug
+ * `graph-constants.ts` exists to avoid on the canvas. */
+export const HERO_NODE = { width: 132, height: 36, radius: 8 } as const;
+
+const COLUMN = [20, 214, 408] as const;
+
+export const HERO_NODES: HeroNode[] = [
+ { id: "handleRequest", label: "handleRequest", depth: 0, x: COLUMN[0], y: 116 },
+ { id: "parseBody", label: "parseBody", depth: 1, x: COLUMN[1], y: 24 },
+ { id: "validate", label: "validate", depth: 1, x: COLUMN[1], y: 116 },
+ { id: "loggerInfo", label: "logger.info", depth: 1, x: COLUMN[1], y: 208 },
+ { id: "readStream", label: "readStream", depth: 2, x: COLUMN[2], y: 24 },
+ { id: "formatError", label: "formatError", depth: 2, x: COLUMN[2], y: 170, ghost: true },
+];
+
+export const HERO_EDGES: HeroEdge[] = [
+ { from: "handleRequest", to: "parseBody", tier: "exact" },
+ { from: "handleRequest", to: "validate", tier: "exact" },
+ { from: "parseBody", to: "readStream", tier: "exact" },
+ { from: "handleRequest", to: "loggerInfo", tier: "name_match" },
+ { from: "validate", to: "formatError", tier: "unresolved" },
+];
+
+/** What the graph says, for anyone who cannot see it. The tiers are named
+ * because they are the point, not the decoration. */
+export const HERO_ALT =
+ "A call graph of a request handler. handleRequest calls parseBody and validate as exact " +
+ "matches, and logger.info as a name match. validate calls formatError, which could not be " +
+ "resolved and is drawn at the edge of the map.";
+
+const byId = new Map(HERO_NODES.map((node) => [node.id, node]));
+
+export function heroNode(id: string): HeroNode {
+ const node = byId.get(id);
+ if (node === undefined) throw new Error(`hero graph has no node ${id}`);
+ return node;
+}
+
+/**
+ * A cubic from the right edge of the caller to the left edge of the callee,
+ * with both control points on the horizontal midline. The same shape React
+ * Flow's bezier edge draws, so the hero and the canvas agree about what a call
+ * looks like.
+ */
+export function heroEdgePath(edge: HeroEdge): string {
+ const from = heroNode(edge.from);
+ const to = heroNode(edge.to);
+
+ const x1 = from.x + HERO_NODE.width;
+ const y1 = from.y + HERO_NODE.height / 2;
+ const x2 = to.x;
+ const y2 = to.y + HERO_NODE.height / 2;
+ const mid = x1 + (x2 - x1) / 2;
+
+ return `M ${x1} ${y1} C ${mid} ${y1}, ${mid} ${y2}, ${x2} ${y2}`;
+}
diff --git a/apps/web/src/lib/motion.ts b/apps/web/src/lib/motion.ts
index 032fbf5..aa08b7d 100644
--- a/apps/web/src/lib/motion.ts
+++ b/apps/web/src/lib/motion.ts
@@ -17,6 +17,15 @@ export const DURATION = {
page: 0.5,
} as const;
+/**
+ * A heavy decelerate, for the one thing on the landing page that draws itself.
+ *
+ * Not `easeOut`: a symmetric built-in curve reads as a tween, and the hero is
+ * meant to read as a pen being drawn across a chart -- fast away from rest,
+ * settling slowly.
+ */
+export const EASE_DRAW = [0.32, 0.72, 0, 1] as const;
+
/** Cards and edges use spring physics; routes and fades use easing (§4). */
const SPRING: Transition = { type: "spring", stiffness: 240, damping: 26 };
diff --git a/apps/web/src/lib/router.test.tsx b/apps/web/src/lib/router.test.tsx
new file mode 100644
index 0000000..f3de814
--- /dev/null
+++ b/apps/web/src/lib/router.test.tsx
@@ -0,0 +1,94 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { Link, navigate, usePath } from "./router";
+
+function Path() {
+ return
at {usePath()}
;
+}
+
+beforeEach(() => {
+ window.history.replaceState(null, "", "/");
+});
+
+describe("usePath", () => {
+ it("follows a programmatic navigation", async () => {
+ render();
+ expect(screen.getByText("at /")).toBeInTheDocument();
+
+ navigate("/app");
+
+ // pushState raises no event of its own, so this only passes because
+ // navigate() dispatches one.
+ expect(await screen.findByText("at /app")).toBeInTheDocument();
+ });
+
+ it("follows the back button", async () => {
+ render();
+ navigate("/app");
+ await screen.findByText("at /app");
+
+ // jsdom moves history but does not fire popstate for it, so the event is
+ // raised here the way a browser would.
+ window.history.replaceState(null, "", "/");
+ window.dispatchEvent(new PopStateEvent("popstate"));
+
+ expect(await screen.findByText("at /")).toBeInTheDocument();
+ });
+});
+
+describe("Link", () => {
+ it("carries a real href, so it can be copied and opened in a new tab", () => {
+ render(Open the atlas);
+
+ expect(screen.getByRole("link", { name: "Open the atlas" })).toHaveAttribute("href", "/app");
+ });
+
+ it("handles a plain click itself, without a page load", async () => {
+ render(
+ <>
+ Open the atlas
+
+ >,
+ );
+
+ await userEvent.click(screen.getByRole("link", { name: "Open the atlas" }));
+
+ expect(screen.getByText("at /app")).toBeInTheDocument();
+ });
+
+ it("leaves a modifier-click to the browser", async () => {
+ render(
+ <>
+ Open the atlas
+
+ >,
+ );
+
+ // `setup()`, not the bare `userEvent.click`: each bare call is a fresh
+ // instance and forgets the held key, so the modifier never reaches the
+ // handler and the assertion passes without testing anything.
+ const user = userEvent.setup();
+
+ // Meta-click means "open in a new tab". Calling preventDefault here is the
+ // classic hand-rolled-router bug: the link stops working as a link.
+ await user.keyboard("{Meta>}");
+ await user.click(screen.getByRole("link", { name: "Open the atlas" }));
+ await user.keyboard("{/Meta}");
+
+ expect(screen.getByText("at /")).toBeInTheDocument();
+ });
+
+ it("still calls a handler the caller passed", async () => {
+ const onClick = vi.fn();
+ render(
+
+ Open the atlas
+ ,
+ );
+
+ await userEvent.click(screen.getByRole("link", { name: "Open the atlas" }));
+
+ expect(onClick).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/apps/web/src/lib/router.tsx b/apps/web/src/lib/router.tsx
new file mode 100644
index 0000000..3d23c74
--- /dev/null
+++ b/apps/web/src/lib/router.tsx
@@ -0,0 +1,70 @@
+import { useSyncExternalStore, type ComponentPropsWithoutRef, type MouseEvent } from "react";
+
+/**
+ * Two routes, so this is the router.
+ *
+ * `/` is the landing page and `/app` is the canvas -- no parameters, no nested
+ * layouts, no data loading. TanStack Query already owns everything from the
+ * server and Zustand owns the canvas, so a router library would arrive with a
+ * data layer that has nothing to do and a config about as long as this file.
+ *
+ * The part worth getting right is `Link`: a hand-rolled one usually swallows
+ * the modifier-click that means "open this in a new tab". This one does not.
+ */
+
+/** `pushState` raises no event of its own, so `navigate` raises this one.
+ * `popstate` covers the back and forward buttons; nothing covers our own
+ * pushes. */
+const NAVIGATED = "funcatlas:navigated";
+
+function subscribe(onChange: () => void): () => void {
+ window.addEventListener("popstate", onChange);
+ window.addEventListener(NAVIGATED, onChange);
+ return () => {
+ window.removeEventListener("popstate", onChange);
+ window.removeEventListener(NAVIGATED, onChange);
+ };
+}
+
+function currentPath(): string {
+ return window.location.pathname;
+}
+
+/** The active path. Read from `location` every time rather than mirrored into
+ * state, which is the copy that drifts when the back button moves one and not
+ * the other. */
+export function usePath(): string {
+ return useSyncExternalStore(subscribe, currentPath);
+}
+
+export function navigate(to: string): void {
+ if (to === currentPath()) return;
+
+ window.history.pushState(null, "", to);
+ // A push is a new page, not a new position on this one. Going back is left
+ // alone: the browser restores that scroll itself.
+ window.scrollTo(0, 0);
+ window.dispatchEvent(new Event(NAVIGATED));
+}
+
+type LinkProps = Omit, "href"> & { to: string };
+
+/**
+ * A real anchor with a real `href`, so it is copyable, middle-clickable and
+ * announced as a link. The click handler claims only a plain left click --
+ * anything holding a modifier means "somewhere else", and the browser is
+ * better at that than we are.
+ */
+export function Link({ to, onClick, ...rest }: LinkProps) {
+ const handleClick = (event: MouseEvent) => {
+ onClick?.(event);
+
+ if (event.defaultPrevented || event.button !== 0) return;
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
+
+ event.preventDefault();
+ navigate(to);
+ };
+
+ return ;
+}
diff --git a/apps/web/src/lib/tokens.ts b/apps/web/src/lib/tokens.ts
index 78b0981..825f489 100644
--- a/apps/web/src/lib/tokens.ts
+++ b/apps/web/src/lib/tokens.ts
@@ -35,68 +35,76 @@ export interface Palette {
}
/**
- * Dark — "Ember". A near-black ground against a cool accent, which is the
- * pairing the alternatives did not make: certainty reads as temperature, from
- * cyan through apricot to a warm slate that is barely chromatic at all.
+ * The scale is a two-colour press: two spot inks and a neutral.
*
- * The ground was warmer — `#141210` on `#1d1a17` — and read as brown rather
- * than as dark. It is near-black now, with the warmth kept as a trace (the
- * hue is still there, the saturation is not) so the apricot and the slate
- * still belong to it. Everything expressive stays in the confidence colours,
- * which is where the meaning is.
+ * A printer with two plates and paper has exactly three things to say, which
+ * is exactly how many answers resolution has. Ultramarine is the fact, fired
+ * clay is the report, and the ash is what neither plate covered. The order is
+ * read as ink weight rather than as temperature, so it survives being drawn as
+ * a hairline on a canvas the reader is zoomed out of.
+ *
+ * This replaced Ember/Vellum, which ran cyan through apricot to a warm slate.
+ * The reasoning there was sound and the execution was fine; it was changed
+ * because cyan-on-near-black is the single most common developer-tool accent
+ * there is, and §7 of `docs/UI_GUIDE.md` is about not landing on the look
+ * every tool in this category already has.
*/
-const EMBER: Palette = {
+
+/** Dark — "Ultramarine". Near-black with a blue cast, so both inks sit on a
+ * ground that belongs to the same press run. */
+const ULTRAMARINE: Palette = {
surface: {
- DEFAULT: "#0a0a0b",
- raised: "#131315",
- border: "#26262a",
+ DEFAULT: "#0a0b10",
+ raised: "#12141c",
+ border: "#242839",
},
ink: {
- DEFAULT: "#f3ede5",
- muted: "#9d9388",
+ DEFAULT: "#eceefa",
+ muted: "#8b90a8",
},
confidence: {
- exact: "#4cc9f0",
- name: "#f2a154",
+ exact: "#6b8cff",
+ name: "#e0885a",
/**
- * A warm slate at about 9% saturation. Deliberately not red: an
- * unresolved call is an honest admission that resolution could not reach
- * the callee, not a failure -- colouring it as an error tells the user the
- * opposite of what PRD §8 promises. It shares the ground's hue family, so
- * it recedes into the map rather than standing out of it.
+ * Ash at about 11% saturation, in the ground's own hue family so it
+ * recedes into the map rather than standing out of it. Deliberately not
+ * red: an unresolved call is an honest admission that resolution could not
+ * reach the callee, not a failure -- colouring it as an error tells the
+ * user the opposite of what PRD §8 promises.
*/
- unresolved: "#8a7f73",
+ unresolved: "#767c92",
},
- onAccent: "#04161c",
+ onAccent: "#080a14",
};
/**
- * Light — "Vellum". A drawing on cool paper rather than cream; cream with a
- * serif display is the most common generated look there is, and the cool grey
- * stays out of it.
+ * Light — "Letterpress". Cool paper rather than cream; cream with a serif
+ * display is the most common generated look there is, and the cool grey stays
+ * out of it. Both inks darken rather than changing hue, which is what a press
+ * would actually do on white stock.
*/
-const VELLUM: Palette = {
+const LETTERPRESS: Palette = {
surface: {
- DEFAULT: "#f2f5f7",
+ DEFAULT: "#f1f2f7",
raised: "#ffffff",
- border: "#d9e1e8",
+ border: "#d8dbe6",
},
ink: {
- DEFAULT: "#0f1a24",
- muted: "#566573",
+ DEFAULT: "#12141f",
+ muted: "#5a5f74",
},
confidence: {
- exact: "#0d7c6b",
- name: "#b26a00",
- /** Cool slate, dark enough to stay legible as a hairline on paper. */
- unresolved: "#75838f",
+ exact: "#2a44c4",
+ name: "#a55424",
+ /** Cool ash, dark enough to hold as a hairline on paper. */
+ unresolved: "#71768a",
},
onAccent: "#ffffff",
};
export const PALETTE: Record = {
- dark: EMBER,
- light: VELLUM,
+ dark: ULTRAMARINE,
+ light: LETTERPRESS,
};
/** The mode used when the browser states no preference. */
diff --git a/apps/web/src/lib/useSmoothScroll.ts b/apps/web/src/lib/useSmoothScroll.ts
new file mode 100644
index 0000000..bcce8b7
--- /dev/null
+++ b/apps/web/src/lib/useSmoothScroll.ts
@@ -0,0 +1,21 @@
+import { useEffect } from "react";
+import Lenis from "lenis";
+
+/**
+ * Smooth scrolling, on the landing page only.
+ *
+ * Mounted from `Landing` rather than from the app root on purpose: on the
+ * canvas the wheel already means zoom, and handing those events to a scroll
+ * library would fight React Flow for every gesture.
+ *
+ * `autoRaf` lets Lenis own its own frame loop, which is one less thing here to
+ * get wrong on unmount.
+ */
+export function useSmoothScroll(enabled: boolean): void {
+ useEffect(() => {
+ if (!enabled) return;
+
+ const lenis = new Lenis({ autoRaf: true });
+ return () => lenis.destroy();
+ }, [enabled]);
+}
diff --git a/apps/web/src/test-setup.ts b/apps/web/src/test-setup.ts
index 67885c1..c224c7c 100644
--- a/apps/web/src/test-setup.ts
+++ b/apps/web/src/test-setup.ts
@@ -53,6 +53,28 @@ if (typeof globalThis.ResizeObserver === "undefined") {
} as unknown as typeof ResizeObserver;
}
+// The landing page's scroll reveals ask Framer whether an element is in view,
+// and jsdom has no IntersectionObserver at all -- without this, mounting the
+// page throws from inside an effect.
+//
+// A no-op that never reports an intersection, which is the honest answer: this
+// document has no viewport for anything to be inside. The revealed content is
+// still in the DOM the whole time, only at opacity 0, so presence is testable
+// here and whether it actually appears is a browser check.
+if (typeof globalThis.IntersectionObserver === "undefined") {
+ globalThis.IntersectionObserver = class {
+ readonly root = null;
+ readonly rootMargin = "";
+ readonly thresholds: readonly number[] = [];
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ takeRecords(): IntersectionObserverEntry[] {
+ return [];
+ }
+ } as unknown as typeof IntersectionObserver;
+}
+
// React Flow reads the pane's transform through DOMMatrixReadOnly when it
// measures a node, and jsdom has no implementation. It only started throwing
// once `lib/graph.ts` stopped pre-declaring node dimensions -- before that
diff --git a/docs/UI_GUIDE.md b/docs/UI_GUIDE.md
index 28d744b..765b961 100644
--- a/docs/UI_GUIDE.md
+++ b/docs/UI_GUIDE.md
@@ -25,39 +25,45 @@ Every value lives in `apps/web/src/lib/tokens.ts`, once per theme. Nothing is ha
component — `grep -rE '#[0-9a-fA-F]{6}' apps/web/src --include=*.tsx` returns nothing, and that grep
is the check.
-**Dark — "Ember."** A near-black ground against a cool accent, which is the pairing the alternatives
-did not make.
+**The scale is a two-colour press.** Two spot inks and a neutral: a printer with two plates and
+paper has exactly three things to say, which is exactly how many answers resolution has. Ultramarine
+is the fact, fired clay is the report, and the ash is what neither plate covered. Read as ink weight
+rather than as temperature, so it survives being drawn as a hairline on a canvas the reader is
+zoomed out of.
-**Revised in B8.** The ground was `#141210` on `#1d1a17` — a coffee undertone that read as brown
-rather than as dark, which is not what a reader means when they ask for dark mode. It is near-black
-now, with the warmth kept as a trace of hue and none of the saturation, so the apricot and the slate
-still belong to the ground they sit on. Everything expressive stays in the confidence colours, which
-is where the meaning is.
+**Revised after the landing-page branch.** This replaced Ember/Vellum, which ran cyan through
+apricot to a warm slate. The reasoning there was sound and the execution was fine; it was changed
+because cyan-on-near-black is the single most common developer-tool accent there is, and §7 is about
+not landing on the look every tool in this category already has.
+
+**Dark — "Ultramarine."** Near-black with a blue cast, so both inks sit on a ground that belongs to
+the same press run.
| Role | Token | Value | Why |
|---|---|---|---|
-| Ground | `surface` | `#0a0a0b` | Near-black. A trace of hue, no saturation to speak of. |
-| Raised | `surface.raised` | `#131315` | Panels, cards, the sidebar. |
-| Rule | `surface.border` | `#26262a` | Hairlines and card edges. |
-| Ink | `ink` | `#f3ede5` | Warm off-white, matched to the ground's temperature. |
-| Ink, quiet | `ink.muted` | `#9d9388` | Secondary labels, counts. |
-| `exact` | `confidence.exact` | `#4cc9f0` | Cool cyan against a warm ground — the strongest separation available. |
-| `name_match` | `confidence.name` | `#f2a154` | Apricot. Reported, not verified. |
-| `unresolved` | `confidence.unresolved` | `#8a7f73` | Warm slate at ~9% saturation. Shares the ground's hue family, so it recedes into the map. |
-
-**Light — "Vellum."** A drawing on cool paper. Deliberately not cream: cream with a serif display is
-the single most common generated look there is (§7).
+| Ground | `surface` | `#0a0b10` | Near-black, a trace of blue, no saturation to speak of. |
+| Raised | `surface.raised` | `#12141c` | Panels, cards, the sidebar. |
+| Rule | `surface.border` | `#242839` | Hairlines and card edges. |
+| Ink | `ink` | `#eceefa` | Off-white, matched to the ground's cast. |
+| Ink, quiet | `ink.muted` | `#8b90a8` | Secondary labels, counts. |
+| `exact` | `confidence.exact` | `#6b8cff` | Ultramarine. The first plate: verified. |
+| `name_match` | `confidence.name` | `#e0885a` | Fired clay. The second plate: reported, not verified. |
+| `unresolved` | `confidence.unresolved` | `#767c92` | Ash at ~11% saturation. Shares the ground's hue family, so it recedes into the map. |
+
+**Light — "Letterpress."** Cool paper, deliberately not cream: cream with a serif display is the
+single most common generated look there is (§7). Both inks darken rather than changing hue, which is
+what a press would do on white stock.
| Role | Token | Value | Why |
|---|---|---|---|
-| Ground | `surface` | `#f2f5f7` | Cool paper. |
+| Ground | `surface` | `#f1f2f7` | Cool paper. |
| Raised | `surface.raised` | `#ffffff` | Panels, cards, the sidebar. |
-| Rule | `surface.border` | `#d9e1e8` | Hairlines and card edges. |
-| Ink | `ink` | `#0f1a24` | Near-black with a blue cast, matched to the paper. |
-| Ink, quiet | `ink.muted` | `#566573` | Secondary labels, counts. |
-| `exact` | `confidence.exact` | `#0d7c6b` | Deep teal — the cool end of the same scale. |
-| `name_match` | `confidence.name` | `#b26a00` | Burnt amber. |
-| `unresolved` | `confidence.unresolved` | `#75838f` | Cool slate, dark enough to hold as a hairline on paper. |
+| Rule | `surface.border` | `#d8dbe6` | Hairlines and card edges. |
+| Ink | `ink` | `#12141f` | Near-black with a blue cast, matched to the paper. |
+| Ink, quiet | `ink.muted` | `#5a5f74` | Secondary labels, counts. |
+| `exact` | `confidence.exact` | `#2a44c4` | Ultramarine, darkened for white stock. |
+| `name_match` | `confidence.name` | `#a55424` | Fired clay, darkened the same way. |
+| `unresolved` | `confidence.unresolved` | `#71768a` | Cool ash, dark enough to hold as a hairline on paper. |
Two rules bind both palettes, and `confidence.test.ts` enforces them:
@@ -167,16 +173,45 @@ A single centred card: wordmark, one line saying what the tool does, the confide
The legend earns its place here where a background texture did not: it is the notation the canvas is
about to use, and reading it once beats decoding it later.
-**The marketing landing page gets its own PR, still unopened.** It is a second full surface that no
-phase's exit test touches, so it has never belonged in a phase review. It was requested during 3b
-and named the next branch after that gate; Phases 4 and 5 went first, so it is now overdue rather
-than upcoming. It follows §1 like everything else.
-
-The landing page is the one surface that takes the maximal spatial treatment: section padding at
-`py-24` and above, nested double-bezel cards, and a hero that is a live drawing graph rather than a
-screenshot. The canvas is dense by nature and does not; matching complexity to the surface is the
-point, and applying marketing whitespace to a file tree is how a tool starts feeling like a
-brochure.
+**The marketing landing page shipped on the `landing-page` branch.** It is a second full surface
+that no phase's exit test touches, so it never belonged in a phase review. Requested during 3b and
+named the next branch after that gate; Phases 4 and 5 went first.
+
+It is the one surface that takes the maximal spatial treatment: section padding at `py-24` and
+above, nested double-bezel cards, and a hero that is a live drawing graph rather than a screenshot.
+The canvas is dense by nature and does not; matching complexity to the surface is the point, and
+applying marketing whitespace to a file tree is how a tool starts feeling like a brochure.
+
+### 3.1a Landing page (`/`) · the `landing-page` branch
+
+`apps/web/src/components/landing/`. Title block, hero, tiers, pipeline, index, coverage, closing
+call to action, footer.
+
+- **Routing.** `/` is the landing page and `/app` is the canvas; anything else falls to the landing
+ page, and there is no 404. `lib/router.tsx` is thirty-odd lines over `pushState` rather than a
+ router library: two static routes, no parameters, no loaders. `useSession` lives behind `/app`, so
+ **the landing page issues no request to our API and renders with the backend down.**
+- **The structural device is the product's notation.** Each section rule is a confidence tier's dash
+ pattern, and each section takes the tier that is true of it — solid over resolution and the
+ pipeline, dashed over coverage because support past the ECMAScript family genuinely is partial,
+ dotted over the limits. `ConfidenceRule` draws it and the legend uses the same component.
+- **One colour rule.** No colour appears that does not carry its canvas meaning. The accent is the
+ hue that already means "known"; clay appears only on a name match, ash only on unresolved.
+- **The hero is plain SVG, not React Flow**, and the draw is a mask sweeping across rather than an
+ animated `pathLength` — that writes an inline `stroke-dasharray` and flattens all three tiers into
+ one pattern. `HeroGraph.test.tsx` asserts three distinct dash values for exactly that reason.
+ The graph carries a ghost node: the signature from §3.2, leading with what the tool cannot do.
+- **Smooth scrolling is mounted from `Landing` only** (Lenis). At the app root it would take the
+ wheel away from the canvas, where the wheel means zoom.
+- **What it deliberately is not:** no sticky bar and no floating glass pill (there is nowhere to
+ navigate to), no bento grid, no gradient mesh, no glow, no backdrop blur. §1.3 and §7.
+
+**Installed, not written.** Three animate-ui pieces via the shadcn CLI — `effects/fade` for the
+section reveals, `texts/sliding-number` for the star count, `components-base-files` for the index
+tree. Every generated file was edited and says so at the top, because `add --overwrite` reverts it
+silently: they ship importing `motion/react` and `@base-ui-components/react`, which are second
+copies of the `framer-motion` and `@base-ui/react` this project locks, and the files component's
+git-status slot carried hardcoded green/amber/red that became a function count instead.
### 3.2 Canvas explorer (authenticated)
- **Sidebar — the index.** An atlas has an index, and the file tree is it. Collapsible, directories
@@ -244,7 +279,7 @@ away.
- Desktop-first (it's a power-user tool). The canvas is not a phone surface.
- Canvas toolbars collapse to icon-only on narrow widths.
-- The sign-in card and, when it exists, the landing page must work on mobile.
+- The sign-in card and the landing page must work on mobile.
## 5.1 Quality floor
@@ -259,8 +294,8 @@ reachable tab order through tree, palette and canvas, and `prefers-reduced-motio
- Custom theming UI, saved layouts/perspectives.
**No longer deferred.** The light theme shipped in Phase 3b alongside the dark one — both palettes
-are in §1.1 and both are enforced by `confidence.test.ts`. The marketing landing page moved from
-"post-MVP" to a branch of its own, which has not been opened yet (§3.1).
+are in §1.1 and both are enforced by `confidence.test.ts`. The marketing landing page shipped on the
+`landing-page` branch (§3.1a).
## 7. What this must not look like
@@ -268,7 +303,7 @@ Kept explicit, because the failure mode here is converging on a look that reads
regardless of subject. Three clusters to stay out of:
1. Warm cream ground (near `#F4F1EA`), high-contrast serif display, terracotta accent. **This is why
- the light theme is cool paper `#f2f5f7` and not cream.**
+ the light theme is cool paper `#f1f2f7` and not cream.**
2. Near-black ground with one bright acid-green or violet accent. **The tokens this project shipped
with — `#0b0d12` plus `#7c5cff` — were exactly this.**
3. Broadsheet layout: hairline rules, zero border-radius, dense newspaper columns.
@@ -289,3 +324,9 @@ Recorded so they are not re-proposed as if new, and not treated as mistakes.
which was cut in B8.
- **Space Grotesk** as the display face. Dropped because it appears on every "reads as
AI-generated" list, this document's §7 included.
+- **Ember / Vellum** (near-black + cyan `#4cc9f0` / apricot / warm slate; cool paper + deep teal).
+ Shipped through Phases 3b to 5 and replaced on the landing-page branch. Nothing was wrong with it
+ in isolation: it passed both palette rules and read clearly. It went because cyan on near-black is
+ the accent every developer tool already uses, which §7 exists to keep us off. The *structure* it
+ established survived intact -- three tiers, one accent doing double duty, `unresolved` quiet and
+ achromatic -- and Ultramarine/Letterpress only changed the inks.
diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts
index e2c47d2..fc12ebe 100644
--- a/packages/shared/src/constants.ts
+++ b/packages/shared/src/constants.ts
@@ -1,5 +1,15 @@
/** Every shared literal used by both the API and the web app. */
+/**
+ * Where the canvas lives in the web app.
+ *
+ * Shared because both sides act on it: the web app routes on it, and the OAuth
+ * callback redirects there rather than to `/`, which is the marketing landing
+ * page -- a freshly signed-in user landing on marketing copy is the bug this
+ * constant exists to prevent.
+ */
+export const APP_ROUTE = "/app";
+
/** Mirrors the CHECK constraint on edges.resolution_confidence. */
export const RESOLUTION_CONFIDENCE = ["exact", "name_match", "unresolved"] as const;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 00f18ee..3c3fefe 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -117,6 +117,9 @@ importers:
framer-motion:
specifier: ^11.18.2
version: 11.18.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ lenis:
+ specifier: ^1.3.26
+ version: 1.3.26(react@19.2.7)
lucide-react:
specifier: ^0.469.0
version: 0.469.0(react@19.2.7)
@@ -132,6 +135,9 @@ importers:
react-resizable-panels:
specifier: ^4.12.2
version: 4.12.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ react-use-measure:
+ specifier: ^2.1.7
+ version: 2.1.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
reactflow:
specifier: ^11.11.4
version: 11.11.4(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -3208,6 +3214,20 @@ packages:
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
engines: {node: '>=6'}
+ lenis@1.3.26:
+ resolution: {integrity: sha512-s/xTCZCxTFvHbAN1OzuhNaN5YPJH2ail0XAkctKW1b+RUAG4nUL5UHLXwNko1h8aEeT2jspBXegMgPJd8zcuag==}
+ peerDependencies:
+ '@nuxt/kit': '>=3.0.0'
+ react: '>=17.0.0'
+ vue: '>=3.0.0'
+ peerDependenciesMeta:
+ '@nuxt/kit':
+ optional: true
+ react:
+ optional: true
+ vue:
+ optional: true
+
levn@0.4.1:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
@@ -3786,6 +3806,15 @@ packages:
'@types/react':
optional: true
+ react-use-measure@2.1.7:
+ resolution: {integrity: sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==}
+ peerDependencies:
+ react: '>=16.13'
+ react-dom: '>=16.13'
+ peerDependenciesMeta:
+ react-dom:
+ optional: true
+
react@19.2.7:
resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==}
engines: {node: '>=0.10.0'}
@@ -7477,6 +7506,10 @@ snapshots:
kleur@4.1.5: {}
+ lenis@1.3.26(react@19.2.7):
+ optionalDependencies:
+ react: 19.2.7
+
levn@0.4.1:
dependencies:
prelude-ls: 1.2.1
@@ -8024,6 +8057,12 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.17
+ react-use-measure@2.1.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
+ dependencies:
+ react: 19.2.7
+ optionalDependencies:
+ react-dom: 19.2.7(react@19.2.7)
+
react@19.2.7: {}
reactflow@11.11.4(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):