Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions TASKLIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
6 changes: 5 additions & 1 deletion apps/api/src/auth/routes.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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.
Expand Down
9 changes: 5 additions & 4 deletions apps/api/src/auth/routes.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion apps/web/components.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,7 @@
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
"registries": {
"@animate-ui": "https://animate-ui.com/r/{name}.json"
}
}
2 changes: 2 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
35 changes: 34 additions & 1 deletion apps/web/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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"),
);
});
});

Expand Down
55 changes: 48 additions & 7 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 ? <AppRoute /> : <Landing />;
}

/**
* 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) {
Expand Down Expand Up @@ -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 (
<div className="flex h-full w-full items-center justify-center p-6 text-center">
<div className="max-w-sm">
<p className="font-display text-lg text-ink">The API is not responding</p>
<p className="mt-2 text-sm text-ink-muted">
Start it with <span className="font-mono text-ink">pnpm dev</span>, then reload.
<div className="flex h-full w-full items-center justify-center p-6">
<div className="max-w-md text-center">
<p className="font-display text-lg text-ink">The canvas cannot reach its API</p>

<p className="mt-3 text-sm leading-relaxed text-ink-muted">
Charting a repository needs the API, the parse worker, Postgres and Redis. None of them
answered, so there is nothing to draw.
</p>

<p className="mt-3 text-sm leading-relaxed text-ink-muted">
Running it locally? Start them with <span className="font-mono text-ink">make start</span>{" "}
and reload. Otherwise the{" "}
<a
href={GITHUB_REPO_URL}
target="_blank"
rel="noreferrer"
className="text-confidence-exact underline underline-offset-4"
>
source
</a>{" "}
has the setup, and the{" "}
<Link to="/" className="text-confidence-exact underline underline-offset-4">
overview
</Link>{" "}
explains what it does.
</p>
</div>
</div>
Expand Down
26 changes: 3 additions & 23 deletions apps/web/src/components/ConfidenceLegend.tsx
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand All @@ -15,33 +16,12 @@ export function ConfidenceLegend({ className }: { className?: string }) {
return (
<ItemGroup className={cn("gap-0", className)}>
{CONFIDENCE_ORDER.map((tier) => {
const { label, meaning, strokeDasharray, textClass } = CONFIDENCE[tier];
const { label, meaning, textClass } = CONFIDENCE[tier];

return (
<Item key={tier} size="sm" className="items-baseline gap-3 px-0">
<ItemMedia className="pt-1.5">
{/* 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. */}
<svg
width="28"
height="2"
viewBox="0 0 28 2"
className={textClass}
aria-hidden
focusable="false"
>
<line
x1="0"
y1="1"
x2="28"
y2="1"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeDasharray={strokeDasharray}
/>
</svg>
<ConfidenceRule tier={tier} className="w-7" />
</ItemMedia>

<ItemContent>
Expand Down
41 changes: 41 additions & 0 deletions apps/web/src/components/ConfidenceRule.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<svg
height="2"
aria-hidden
focusable="false"
className={cn("w-full", CONFIDENCE[tier].textClass, className)}
>
<line
x1="0"
y1="1"
x2="100%"
y2="1"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeDasharray={CONFIDENCE[tier].strokeDasharray}
/>
</svg>
);
}
Loading
Loading