From b4b73a4e56e9e49354bbb8832d6f9867fda8f056 Mon Sep 17 00:00:00 2001 From: Aditya Rana Date: Sat, 29 Aug 2026 00:00:03 +0530 Subject: [PATCH 1/9] add a path router for the two surfaces The app has one surface today and branches on the session to pick it. A marketing landing page is a second surface that has to be reachable while signed in, so something has to choose between them. Two static routes, no parameters, no nesting and 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 configuration about as long as this file. `Link` is the part worth care. A hand-rolled one usually calls preventDefault unconditionally and quietly breaks modifier-click, so it claims only a plain left click and leaves the rest to the browser. The test holds Meta down to pin that, using `userEvent.setup()` -- a bare `userEvent.click` is a fresh instance that forgets the held key, and the assertion passes without testing anything. APP_ROUTE goes in the shared package because the API redirects there too. --- apps/web/src/lib/router.test.tsx | 94 ++++++++++++++++++++++++++++++++ apps/web/src/lib/router.tsx | 70 ++++++++++++++++++++++++ packages/shared/src/constants.ts | 10 ++++ 3 files changed, 174 insertions(+) create mode 100644 apps/web/src/lib/router.test.tsx create mode 100644 apps/web/src/lib/router.tsx 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/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; From e2f43edc34a72424af256d84654259e1b7bc890d Mon Sep 17 00:00:00 2001 From: Aditya Rana Date: Sat, 29 Aug 2026 00:00:14 +0530 Subject: [PATCH 2/9] draw the hero graph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The landing page's hero is a call graph drawing itself, not a screenshot of one (UI_GUIDE §3.1). Plain SVG rather than React Flow: React Flow wants a measured container and brings pan, zoom and handles 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. The draw is a mask sweeping across the edge layer. Framer's `pathLength` writes an inline stroke-dasharray, which overwrites the pattern that distinguishes solid from dashed from dotted -- all three come out identical, which is the one picture this product must never show (PRD §8). The mask leaves every path's own attributes alone. It also translates rather than resizes, so the animated property is a transform. The graph is laid out by depth and the sweep runs left to right, so the sweep is the stagger: one animated element for the whole orchestrated moment. The fixture carries a ghost node -- an unresolved callee at the edge of the map, dotted, faded and labelled with the name the parser saw. That is signature 2 from UI_GUIDE §3.2, and leading a hero with what the tool cannot do is the point rather than an oversight. `CONFIDENCE` gains `strokeClass` beside `textClass`: a class follows the theme without the component subscribing to it, and Tailwind cannot see a class name built by concatenation. `confidenceColor` stays for the canvas, which needs a raw value for real SVG attributes. --- .../src/components/landing/HeroGraph.test.tsx | 43 ++++++ apps/web/src/components/landing/HeroGraph.tsx | 137 ++++++++++++++++++ apps/web/src/lib/confidence.ts | 8 + apps/web/src/lib/hero-graph.test.ts | 60 ++++++++ apps/web/src/lib/hero-graph.ts | 104 +++++++++++++ apps/web/src/lib/motion.ts | 9 ++ 6 files changed, 361 insertions(+) create mode 100644 apps/web/src/components/landing/HeroGraph.test.tsx create mode 100644 apps/web/src/components/landing/HeroGraph.tsx create mode 100644 apps/web/src/lib/hero-graph.test.ts create mode 100644 apps/web/src/lib/hero-graph.ts 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..f3fd093 --- /dev/null +++ b/apps/web/src/components/landing/HeroGraph.test.tsx @@ -0,0 +1,43 @@ +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..847c32b --- /dev/null +++ b/apps/web/src/components/landing/HeroGraph.tsx @@ -0,0 +1,137 @@ +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 ( + + A resolved call graph + {HERO_ALT} + + {animate ? ( + + + {/* Translated rather than resized: a transform is the one thing + cheap to animate, and it leaves the paths' own attributes + untouched, which is the whole point. */} + + + + ) : null} + + + {HERO_EDGES.map((edge) => ( + + ))} + + + {HERO_NODES.map((node) => ( + + + + + {node.label} + + + {/* The signature (UI_GUIDE §3.2): the map naming its own boundary + rather than hiding it. Said in a word, because a dotted outline + alone reads as a style rather than as a claim. */} + {node.ghost === true ? ( + + {CONFIDENCE.unresolved.label} + + ) : null} + + ))} + + ); +} 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/hero-graph.test.ts b/apps/web/src/lib/hero-graph.test.ts new file mode 100644 index 0000000..c34eaf4 --- /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 162 + 18; target starts at 214, centred on 72. + expect(path).toBe("M 152 180 C 183 180, 183 72, 214 72"); + }); + + 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..a12ce1a --- /dev/null +++ b/apps/web/src/lib/hero-graph.ts @@ -0,0 +1,104 @@ +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. */ +export const HERO_VIEWBOX = { width: 560, height: 360 } 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: 162 }, + { id: "parseBody", label: "parseBody", depth: 1, x: COLUMN[1], y: 54 }, + { id: "validate", label: "validate", depth: 1, x: COLUMN[1], y: 162 }, + { id: "loggerInfo", label: "logger.info", depth: 1, x: COLUMN[1], y: 270 }, + { id: "readStream", label: "readStream", depth: 2, x: COLUMN[2], y: 54 }, + { id: "formatError", label: "formatError", depth: 2, x: COLUMN[2], y: 222, 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 }; From 47f632d3b1d8bad177e9cddc914735d17e1978a6 Mon Sep 17 00:00:00 2001 From: Aditya Rana Date: Sat, 29 Aug 2026 00:00:37 +0530 Subject: [PATCH 3/9] add the marketing landing page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The surface UI_GUIDE §3.1 has specified since Phase 3b and that no phase's exit test touches. It is the one page that takes the maximal spatial treatment -- py-24 and above, nested double-bezel cards -- because matching complexity to the surface is the point, and the canvas stays dense. The page's structural device is the product's own notation. Each section heading carries a hairline rule whose dash pattern is a confidence tier, and each section takes the tier that is true of it: the resolution section is solid, languages is dashed because support past the ECMAScript family genuinely is partial, and the closing section is dotted because what it states are the product's limits. A reader has met the notation three times before ever reaching the canvas. Nothing here calls our API. `useSession` moves behind the canvas route in the next commit, 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. The one outbound request is a GitHub star count, which is not required: the control degrades to a plain link to the repository, and GitHub rate-limits anonymous callers hard enough that this is the common case. Colour rule for the page, in one line: no colour appears that does not carry its canvas meaning. The accent is the hue that means "known" everywhere else, apricot appears only on a name match, slate only on unresolved. No gradient mesh, no glow, no glass -- §1.3 bans them and §7 cluster 2 is precisely what this would otherwise become. Installed rather than written: `lenis` for smooth scrolling, mounted from Landing alone because the canvas treats the wheel as zoom; and two animate-ui primitives via the shadcn CLI, `effects/fade` for the scroll reveals and `texts/sliding-number` for the star count. They ship importing `motion/react`, which is a second copy of Framer Motion beside the `framer-motion` this project locks, so each generated file is retargeted and says so -- `add --overwrite` reverts it silently. The legend's tier line and the section rule were the same drawing twice, so ConfidenceRule is extracted and the legend now uses it. --- apps/web/components.json | 4 +- apps/web/package.json | 2 + apps/web/src/components/ConfidenceLegend.tsx | 26 +- apps/web/src/components/ConfidenceRule.tsx | 41 ++ .../animate-ui/primitives/animate/slot.tsx | 101 +++++ .../animate-ui/primitives/effects/fade.tsx | 98 +++++ .../primitives/texts/sliding-number.tsx | 361 ++++++++++++++++++ apps/web/src/components/landing/Bezel.tsx | 42 ++ .../web/src/components/landing/ClosingCta.tsx | 33 ++ .../src/components/landing/GitHubStars.tsx | 48 +++ apps/web/src/components/landing/Hero.tsx | 49 +++ .../web/src/components/landing/HowItWorks.tsx | 53 +++ .../src/components/landing/Landing.test.tsx | 81 ++++ apps/web/src/components/landing/Landing.tsx | 40 ++ .../src/components/landing/LandingFooter.tsx | 23 ++ .../src/components/landing/LandingHeader.tsx | 28 ++ apps/web/src/components/landing/Languages.tsx | 54 +++ apps/web/src/components/landing/OpenAtlas.tsx | 31 ++ apps/web/src/components/landing/Reveal.tsx | 36 ++ apps/web/src/components/landing/Section.tsx | 61 +++ apps/web/src/components/landing/Tiers.tsx | 57 +++ apps/web/src/hooks/use-is-in-view.tsx | 29 ++ apps/web/src/lib/constants.ts | 8 + apps/web/src/lib/github.ts | 31 ++ apps/web/src/lib/useSmoothScroll.ts | 21 + apps/web/src/test-setup.ts | 22 ++ pnpm-lock.yaml | 39 ++ 27 files changed, 1395 insertions(+), 24 deletions(-) create mode 100644 apps/web/src/components/ConfidenceRule.tsx create mode 100644 apps/web/src/components/animate-ui/primitives/animate/slot.tsx create mode 100644 apps/web/src/components/animate-ui/primitives/effects/fade.tsx create mode 100644 apps/web/src/components/animate-ui/primitives/texts/sliding-number.tsx create mode 100644 apps/web/src/components/landing/Bezel.tsx create mode 100644 apps/web/src/components/landing/ClosingCta.tsx create mode 100644 apps/web/src/components/landing/GitHubStars.tsx create mode 100644 apps/web/src/components/landing/Hero.tsx create mode 100644 apps/web/src/components/landing/HowItWorks.tsx create mode 100644 apps/web/src/components/landing/Landing.test.tsx create mode 100644 apps/web/src/components/landing/Landing.tsx create mode 100644 apps/web/src/components/landing/LandingFooter.tsx create mode 100644 apps/web/src/components/landing/LandingHeader.tsx create mode 100644 apps/web/src/components/landing/Languages.tsx create mode 100644 apps/web/src/components/landing/OpenAtlas.tsx create mode 100644 apps/web/src/components/landing/Reveal.tsx create mode 100644 apps/web/src/components/landing/Section.tsx create mode 100644 apps/web/src/components/landing/Tiers.tsx create mode 100644 apps/web/src/hooks/use-is-in-view.tsx create mode 100644 apps/web/src/lib/github.ts create mode 100644 apps/web/src/lib/useSmoothScroll.ts 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/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/primitives/animate/slot.tsx b/apps/web/src/components/animate-ui/primitives/animate/slot.tsx new file mode 100644 index 0000000..fdb7172 --- /dev/null +++ b/apps/web/src/components/animate-ui/primitives/animate/slot.tsx @@ -0,0 +1,101 @@ +'use client'; + +// 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 { motion, isMotionComponent, type HTMLMotionProps } from 'framer-motion'; +import { cn } from '@/lib/cn'; + +type AnyProps = Record; + +type DOMMotionProps = Omit< + HTMLMotionProps, + 'ref' +> & { ref?: React.Ref }; + +type WithAsChild = + | (Base & { asChild: true; children: React.ReactElement }) + | (Base & { asChild?: false | undefined }); + +type SlotProps = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + children?: any; +} & DOMMotionProps; + +function mergeRefs( + ...refs: (React.Ref | undefined)[] +): React.RefCallback { + return (node) => { + refs.forEach((ref) => { + if (!ref) return; + if (typeof ref === 'function') { + ref(node); + } else { + (ref as React.RefObject).current = node; + } + }); + }; +} + +function mergeProps( + childProps: AnyProps, + slotProps: DOMMotionProps, +): AnyProps { + const merged: AnyProps = { ...childProps, ...slotProps }; + + if (childProps.className || slotProps.className) { + merged.className = cn( + childProps.className as string, + slotProps.className as string, + ); + } + + if (childProps.style || slotProps.style) { + merged.style = { + ...(childProps.style as React.CSSProperties), + ...(slotProps.style as React.CSSProperties), + }; + } + + return merged; +} + +function Slot({ + children, + ref, + ...props +}: SlotProps) { + const isAlreadyMotion = + typeof children.type === 'object' && + children.type !== null && + isMotionComponent(children.type); + + const Base = React.useMemo( + () => + isAlreadyMotion + ? (children.type as React.ElementType) + : motion.create(children.type as React.ElementType), + [isAlreadyMotion, children.type], + ); + + if (!React.isValidElement(children)) return null; + + const { ref: childRef, ...childProps } = children.props as AnyProps; + + const mergedProps = mergeProps(childProps, props); + + return ( + , ref)} /> + ); +} + +export { + Slot, + type SlotProps, + type WithAsChild, + type DOMMotionProps, + type AnyProps, +}; diff --git a/apps/web/src/components/animate-ui/primitives/effects/fade.tsx b/apps/web/src/components/animate-ui/primitives/effects/fade.tsx new file mode 100644 index 0000000..d4c3185 --- /dev/null +++ b/apps/web/src/components/animate-ui/primitives/effects/fade.tsx @@ -0,0 +1,98 @@ +'use client'; + +// 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 { motion, type HTMLMotionProps } from 'framer-motion'; + +import { + useIsInView, + type UseIsInViewOptions, +} from '@/hooks/use-is-in-view'; +import { Slot, type WithAsChild } from '@/components/animate-ui/primitives/animate/slot'; + +type FadeProps = WithAsChild< + { + children?: React.ReactNode; + delay?: number; + initialOpacity?: number; + opacity?: number; + ref?: React.Ref; + } & UseIsInViewOptions & + HTMLMotionProps<'div'> +>; + +function Fade({ + ref, + transition = { type: 'spring', stiffness: 200, damping: 20 }, + delay = 0, + inView = false, + inViewMargin = '0px', + inViewOnce = true, + initialOpacity = 0, + opacity = 1, + asChild = false, + ...props +}: FadeProps) { + const { ref: localRef, isInView } = useIsInView( + ref as React.Ref, + { + inView, + inViewOnce, + inViewMargin, + }, + ); + + const Component = asChild ? Slot : motion.div; + + return ( + } + initial="hidden" + animate={isInView ? 'visible' : 'hidden'} + exit="hidden" + variants={{ + hidden: { opacity: initialOpacity }, + visible: { opacity }, + }} + transition={{ + ...transition, + delay: (transition?.delay ?? 0) + delay / 1000, + }} + {...props} + /> + ); +} + +type FadeListProps = Omit & { + children: React.ReactElement | React.ReactElement[]; + holdDelay?: number; +}; + +function Fades({ + children, + delay = 0, + holdDelay = 0, + ...props +}: FadeListProps) { + const array = React.Children.toArray(children) as React.ReactElement[]; + + return ( + <> + {array.map((child, index) => ( + + {child} + + ))} + + ); +} + +export { Fade, Fades, type FadeProps, type FadeListProps }; diff --git a/apps/web/src/components/animate-ui/primitives/texts/sliding-number.tsx b/apps/web/src/components/animate-ui/primitives/texts/sliding-number.tsx new file mode 100644 index 0000000..cde6320 --- /dev/null +++ b/apps/web/src/components/animate-ui/primitives/texts/sliding-number.tsx @@ -0,0 +1,361 @@ +'use client'; + +// 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 { + useSpring, + useTransform, + motion, + useMotionValue, + type MotionValue, + type SpringOptions, + type HTMLMotionProps, +} from 'framer-motion'; +import useMeasure from 'react-use-measure'; + +import { + useIsInView, + type UseIsInViewOptions, +} from '@/hooks/use-is-in-view'; + +type SlidingNumberRollerProps = { + prevValue: number; + value: number; + place: number; + transition: SpringOptions; + delay?: number; +}; + +function SlidingNumberRoller({ + prevValue, + value, + place, + transition, + delay = 0, +}: SlidingNumberRollerProps) { + const startNumber = Math.floor(prevValue / place) % 10; + const targetNumber = Math.floor(value / place) % 10; + const animatedValue = useSpring(startNumber, transition); + + React.useEffect(() => { + const timeoutId = setTimeout(() => { + animatedValue.set(targetNumber); + }, delay); + return () => clearTimeout(timeoutId); + }, [targetNumber, animatedValue, delay]); + + const [measureRef, { height }] = useMeasure(); + + return ( + + 0 + {Array.from({ length: 10 }, (_, i) => ( + + ))} + + ); +} + +type SlidingNumberDisplayProps = { + motionValue: MotionValue; + number: number; + height: number; + transition: SpringOptions; +}; + +function SlidingNumberDisplay({ + motionValue, + number, + height, + transition, +}: SlidingNumberDisplayProps) { + const y = useTransform(motionValue, (latest) => { + if (!height) return 0; + const currentNumber = latest % 10; + const offset = (10 + number - currentNumber) % 10; + let translateY = offset * height; + if (offset > 5) translateY -= 10 * height; + return translateY; + }); + + if (!height) { + return ( + + {number} + + ); + } + + return ( + + {number} + + ); +} + +type SlidingNumberProps = Omit, 'children'> & { + number: number; + fromNumber?: number; + onNumberChange?: (number: number) => void; + padStart?: boolean; + decimalSeparator?: string; + decimalPlaces?: number; + thousandSeparator?: string; + transition?: SpringOptions; + delay?: number; + initiallyStable?: boolean; +} & UseIsInViewOptions; + +function SlidingNumber({ + ref, + number, + fromNumber, + onNumberChange, + inView = false, + inViewMargin = '0px', + inViewOnce = true, + padStart = false, + decimalSeparator = '.', + decimalPlaces = 0, + thousandSeparator, + transition = { stiffness: 200, damping: 20, mass: 0.4 }, + delay = 0, + initiallyStable = false, + ...props +}: SlidingNumberProps) { + const { ref: localRef, isInView } = useIsInView( + ref as React.Ref, + { + inView, + inViewOnce, + inViewMargin, + }, + ); + + const initialNumeric = Math.abs(Number(number)); + const prevNumberRef = React.useRef( + initiallyStable ? initialNumeric : 0, + ); + + const hasAnimated = fromNumber !== undefined; + + const motionVal = useMotionValue( + initiallyStable ? initialNumeric : (fromNumber ?? 0), + ); + const springVal = useSpring(motionVal, { stiffness: 90, damping: 50 }); + + const skippedInitialWhenStable = React.useRef(false); + + React.useEffect(() => { + if (!hasAnimated) return; + if (initiallyStable && !skippedInitialWhenStable.current) { + skippedInitialWhenStable.current = true; + return; + } + const timeoutId = setTimeout(() => { + if (isInView) motionVal.set(number); + }, delay); + return () => clearTimeout(timeoutId); + }, [hasAnimated, initiallyStable, isInView, number, motionVal, delay]); + + const [effectiveNumber, setEffectiveNumber] = React.useState( + initiallyStable ? initialNumeric : 0, + ); + + React.useEffect(() => { + if (hasAnimated) { + const inferredDecimals = + typeof decimalPlaces === 'number' && decimalPlaces >= 0 + ? decimalPlaces + : (() => { + const s = String(number); + const idx = s.indexOf('.'); + return idx >= 0 ? s.length - idx - 1 : 0; + })(); + + const factor = Math.pow(10, inferredDecimals); + + const unsubscribe = springVal.on('change', (latest: number) => { + const newValue = + inferredDecimals > 0 + ? Math.round(latest * factor) / factor + : Math.round(latest); + + if (effectiveNumber !== newValue) { + setEffectiveNumber(newValue); + onNumberChange?.(newValue); + } + }); + return () => unsubscribe(); + } else { + setEffectiveNumber( + initiallyStable ? initialNumeric : !isInView ? 0 : initialNumeric, + ); + } + }, [ + hasAnimated, + springVal, + isInView, + number, + decimalPlaces, + onNumberChange, + effectiveNumber, + initiallyStable, + initialNumeric, + ]); + + const formatNumber = React.useCallback( + (num: number) => + decimalPlaces != null ? num.toFixed(decimalPlaces) : num.toString(), + [decimalPlaces], + ); + + const numberStr = formatNumber(effectiveNumber); + // Defaulted like `prevIntStrRaw` below it, which the upstream file already + // does: this project's tsconfig checks indexed access, and a destructured + // element is `string | undefined` without it. + const [newIntStrRaw = '', newDecStrRaw = ''] = numberStr.split('.'); + + const finalIntLength = padStart + ? Math.max( + Math.floor(Math.abs(number)).toString().length, + newIntStrRaw.length, + ) + : newIntStrRaw.length; + + const newIntStr = padStart + ? newIntStrRaw.padStart(finalIntLength, '0') + : newIntStrRaw; + + const prevFormatted = formatNumber(prevNumberRef.current); + const [prevIntStrRaw = '', prevDecStrRaw = ''] = prevFormatted.split('.'); + const prevIntStr = padStart + ? prevIntStrRaw.padStart(finalIntLength, '0') + : prevIntStrRaw; + + const adjustedPrevInt = React.useMemo(() => { + return prevIntStr.length > finalIntLength + ? prevIntStr.slice(-finalIntLength) + : prevIntStr.padStart(finalIntLength, '0'); + }, [prevIntStr, finalIntLength]); + + const adjustedPrevDec = React.useMemo(() => { + if (!newDecStrRaw) return ''; + return prevDecStrRaw.length > newDecStrRaw.length + ? prevDecStrRaw.slice(0, newDecStrRaw.length) + : prevDecStrRaw.padEnd(newDecStrRaw.length, '0'); + }, [prevDecStrRaw, newDecStrRaw]); + + React.useEffect(() => { + if (isInView || initiallyStable) { + prevNumberRef.current = effectiveNumber; + } + }, [effectiveNumber, isInView, initiallyStable]); + + const intPlaces = React.useMemo( + () => + Array.from({ length: finalIntLength }, (_, i) => + Math.pow(10, finalIntLength - i - 1), + ), + [finalIntLength], + ); + const decPlaces = React.useMemo( + () => + newDecStrRaw + ? Array.from({ length: newDecStrRaw.length }, (_, i) => + Math.pow(10, newDecStrRaw.length - i - 1), + ) + : [], + [newDecStrRaw], + ); + + const newDecValue = newDecStrRaw ? parseInt(newDecStrRaw, 10) : 0; + const prevDecValue = adjustedPrevDec ? parseInt(adjustedPrevDec, 10) : 0; + + return ( + + {isInView && Number(number) < 0 && ( + - + )} + + {intPlaces.map((place, idx) => { + const digitsToRight = intPlaces.length - idx - 1; + const isSeparatorPosition = + typeof thousandSeparator !== 'undefined' && + digitsToRight > 0 && + digitsToRight % 3 === 0; + + return ( + + + {isSeparatorPosition && {thousandSeparator}} + + ); + })} + + {newDecStrRaw && ( + <> + {decimalSeparator} + {decPlaces.map((place) => ( + + ))} + + )} + + ); +} + +export { SlidingNumber, type SlidingNumberProps }; diff --git a/apps/web/src/components/landing/Bezel.tsx b/apps/web/src/components/landing/Bezel.tsx new file mode 100644 index 0000000..f7d80a4 --- /dev/null +++ b/apps/web/src/components/landing/Bezel.tsx @@ -0,0 +1,42 @@ +import type { ReactNode } from "react"; +import { cn } from "../../lib/cn"; + +/** + * A panel sitting in a tray, rather than lying flat on the page. + * + * `docs/UI_GUIDE.md` §3.1 asks the landing page -- and only the landing page -- + * for nested double-bezel cards. Two enclosures, concentric radii from the + * existing scale, and a hairline on each. No shadow, no glass, no glow: §1.3 + * cuts a decoration that encodes nothing, and depth here comes from the + * nesting itself. + * + * The canvas does not use this. Cards there are dense by nature, and a tray + * around each one would cost the reader space they need for the graph. + */ +export function Bezel({ + children, + className, + innerClassName, +}: { + children: ReactNode; + className?: string; + innerClassName?: string; +}) { + return ( +
+
+ {children} +
+
+ ); +} diff --git a/apps/web/src/components/landing/ClosingCta.tsx b/apps/web/src/components/landing/ClosingCta.tsx new file mode 100644 index 0000000..1f194a0 --- /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..2e54856 --- /dev/null +++ b/apps/web/src/components/landing/GitHubStars.tsx @@ -0,0 +1,48 @@ +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..e137e15 --- /dev/null +++ b/apps/web/src/components/landing/Hero.tsx @@ -0,0 +1,49 @@ +import { Bezel } from "./Bezel"; +import { HeroGraph } from "./HeroGraph"; +import { OpenAtlas } from "./OpenAtlas"; + +/** + * 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 +

+ +

+ 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 — + nothing is written to your account. +

+
+ + + + +
+ ); +} diff --git a/apps/web/src/components/landing/HowItWorks.tsx b/apps/web/src/components/landing/HowItWorks.tsx new file mode 100644 index 0000000..cfe288c --- /dev/null +++ b/apps/web/src/components/landing/HowItWorks.tsx @@ -0,0 +1,53 @@ +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 — 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) => ( +
  1. + + {String(index + 1).padStart(2, "0")} + + +
    +

    {step.title}

    +

    {step.body}

    +
    +
  2. + ))} +
+
+ ); +} 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..9494523 --- /dev/null +++ b/apps/web/src/components/landing/Landing.test.tsx @@ -0,0 +1,81 @@ +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("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..1a46785 --- /dev/null +++ b/apps/web/src/components/landing/Landing.tsx @@ -0,0 +1,40 @@ +import { useMotionEnabled } from "../../lib/motion"; +import { useSmoothScroll } from "../../lib/useSmoothScroll"; +import { ClosingCta } from "./ClosingCta"; +import { Hero } from "./Hero"; +import { HowItWorks } from "./HowItWorks"; +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..57df780 --- /dev/null +++ b/apps/web/src/components/landing/LandingFooter.tsx @@ -0,0 +1,23 @@ +import { GITHUB_REPO_URL } from "../../lib/constants"; + +export function LandingFooter() { + return ( +
+
+ funcatlas + +

+ tree-sitter · Postgres ·{" "} + + source + +

+
+
+ ); +} diff --git a/apps/web/src/components/landing/LandingHeader.tsx b/apps/web/src/components/landing/LandingHeader.tsx new file mode 100644 index 0000000..0a01ef2 --- /dev/null +++ b/apps/web/src/components/landing/LandingHeader.tsx @@ -0,0 +1,28 @@ +import { ThemeToggle } from "../ThemeToggle"; +import { GitHubStars } from "./GitHubStars"; +import { OpenAtlas } from "./OpenAtlas"; + +/** + * 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..99c3696 --- /dev/null +++ b/apps/web/src/components/landing/Languages.tsx @@ -0,0 +1,54 @@ +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..a8d3a11 --- /dev/null +++ b/apps/web/src/components/landing/OpenAtlas.tsx @@ -0,0 +1,31 @@ +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..5c05bd6 --- /dev/null +++ b/apps/web/src/components/landing/Section.tsx @@ -0,0 +1,61 @@ +import type { ReactNode } from "react"; +import type { ResolutionConfidence } from "@funcatlas/shared"; +import { cn } from "../../lib/cn"; +import { ConfidenceRule } from "../ConfidenceRule"; +import { Reveal } from "./Reveal"; + +/** + * 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..52d95c0 --- /dev/null +++ b/apps/web/src/components/landing/Tiers.tsx @@ -0,0 +1,57 @@ +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 ( +
+
    + {CONFIDENCE_ORDER.map((tier) => { + const { label, meaning, textClass } = CONFIDENCE[tier]; + + return ( +
  • + + + +

    {label}

    + +

    {meaning}

    + +

    {CAUSE[tier]}

    +
    +
  • + ); + })} +
+ +

+ 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 — a wrong edge is read as fact and costs more + than the missing one it replaced. +

+
+ ); +} 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/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/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/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/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): From 21fd13e909ca4ba4d9103f872c7b92a86be6f0b5 Mon Sep 17 00:00:00 2001 From: Aditya Rana Date: Sat, 29 Aug 2026 00:06:27 +0530 Subject: [PATCH 4/9] show the index on the landing page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 reason it is worth showing: a file's weight 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, because a made-up src/components/Button.tsx would say nothing about a tool built to read a polyglot monorepo. `components-base-files` installed from animate-ui rather than hand-written, and then edited, which is the model for a generated file. Three edits: - The upstream right-hand slot is a git status dot in hardcoded green, amber and red. There is no diff to report here and no colour outside tokens.ts, so the slot became `meta` and carries the function count. - The imports were `motion/react` and `@base-ui-components/react`, second copies of the framer-motion and @base-ui/react this project locks. - This project's Base UI is a major ahead of the one it was generated against: Accordion's value is `AccordionValue` rather than `string | string[]`, and onValueChange takes an event-details argument. The types are taken from the component rather than restated, so the next Base UI upgrade surfaces here as a type error and not as a wrong cast. The one place an array is assumed is narrowed, not cast. Each edited file says so at the top, because `add --overwrite` reverts them silently. --- .../animate-ui/components/base/files.tsx | 150 ++++ .../animate-ui/primitives/base/accordion.tsx | 192 ++++++ .../animate-ui/primitives/base/files.tsx | 241 +++++++ .../primitives/effects/highlight.tsx | 646 ++++++++++++++++++ apps/web/src/components/landing/Index.tsx | 110 +++ .../src/components/landing/Landing.test.tsx | 13 + apps/web/src/components/landing/Landing.tsx | 2 + apps/web/src/hooks/use-controlled-state.tsx | 33 + apps/web/src/lib/get-strict-context.tsx | 36 + 9 files changed, 1423 insertions(+) create mode 100644 apps/web/src/components/animate-ui/components/base/files.tsx create mode 100644 apps/web/src/components/animate-ui/primitives/base/accordion.tsx create mode 100644 apps/web/src/components/animate-ui/primitives/base/files.tsx create mode 100644 apps/web/src/components/animate-ui/primitives/effects/highlight.tsx create mode 100644 apps/web/src/components/landing/Index.tsx create mode 100644 apps/web/src/hooks/use-controlled-state.tsx create mode 100644 apps/web/src/lib/get-strict-context.tsx 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 ( + + + + +
+ } + openIcon={} + /> + + {children} + +
+ + {meta} +
+
+
+
+ ); +} + +type FolderPanelProps = FolderPanelPrimitiveProps; + +function FolderPanel(props: FolderPanelProps) { + return ( +
+ +
+ ); +} + +type FileItemProps = FilePrimitiveProps & { + icon?: React.ElementType; + /** See FolderTriggerProps.meta. */ + meta?: React.ReactNode; +}; + +function FileItem({ + icon: Icon = FileIcon, + className, + children, + meta, + ...props +}: FileItemProps) { + return ( + + +
+ + + + + {children} + +
+ + {meta} +
+
+ ); +} + +export { + Files, + FolderItem, + FolderTrigger, + FolderPanel, + FileItem, + SubFiles, + type FilesProps, + type FolderItemProps, + type FolderTriggerProps, + type FolderPanelProps, + type FileItemProps, + type SubFilesProps, +}; diff --git a/apps/web/src/components/animate-ui/primitives/base/accordion.tsx b/apps/web/src/components/animate-ui/primitives/base/accordion.tsx new file mode 100644 index 0000000..2386adc --- /dev/null +++ b/apps/web/src/components/animate-ui/primitives/base/accordion.tsx @@ -0,0 +1,192 @@ +'use client'; + +// Generated by `npx shadcn add @animate-ui/...`, then edited: the imports were +// `motion/react` and `@base-ui-components/react`, which are second copies of +// the `framer-motion` and `@base-ui/react` this project locks. Same APIs, so +// retargeting is enough. `add --overwrite` silently reverts this -- see +// docs/UI_GUIDE.md §2. + +import * as React from 'react'; +import { Accordion as AccordionPrimitive } from '@base-ui/react/accordion'; +import { AnimatePresence, motion, type HTMLMotionProps } from 'framer-motion'; + +import { getStrictContext } from '@/lib/get-strict-context'; +import { useControlledState } from '@/hooks/use-controlled-state'; + +// This project's `@base-ui/react` is a major ahead of the one animate-ui +// generated against: Root's value is `AccordionValue`, not +// `string | string[]`, and onValueChange takes an event-details second +// argument. Taken from the component rather than restated, so a Base UI +// upgrade shows up here as a type error rather than as a wrong cast. +type AccordionRootValue = React.ComponentProps< + typeof AccordionPrimitive.Root +>['value']; + +type AccordionContextType = { + value: AccordionRootValue; + setValue: (value: AccordionRootValue) => void; +}; + +type AccordionItemContextType = { + isOpen: boolean; + setIsOpen: (open: boolean) => void; +}; + +const [AccordionProvider, useAccordion] = + getStrictContext('AccordionContext'); + +const [AccordionItemProvider, useAccordionItem] = + getStrictContext('AccordionItemContext'); + +type AccordionProps = React.ComponentProps; + +function Accordion(props: AccordionProps) { + const [value, setValue] = useControlledState({ + value: props?.value, + defaultValue: props?.defaultValue, + onChange: props?.onValueChange as (value: AccordionRootValue) => void, + }); + + return ( + + + + ); +} + +type AccordionItemProps = React.ComponentProps; + +function AccordionItem(props: AccordionItemProps) { + const { value } = useAccordion(); + const [isOpen, setIsOpen] = React.useState( + Array.isArray(value) ? value.includes(props?.value) : value === props?.value, + ); + + React.useEffect(() => { + setIsOpen(value?.includes(props?.value) ?? false); + }, [value, props?.value]); + + return ( + + + + ); +} + +type AccordionHeaderProps = React.ComponentProps< + typeof AccordionPrimitive.Header +>; + +function AccordionHeader(props: AccordionHeaderProps) { + return ; +} + +type AccordionTriggerProps = React.ComponentProps< + typeof AccordionPrimitive.Trigger +>; + +function AccordionTrigger(props: AccordionTriggerProps) { + return ( + + ); +} + +type AccordionPanelProps = Omit< + React.ComponentProps, + 'keepMounted' | 'render' +> & + HTMLMotionProps<'div'> & { + keepRendered?: boolean; + }; + +function AccordionPanel({ + transition = { duration: 0.35, ease: 'easeInOut' }, + hiddenUntilFound, + keepRendered = false, + ...props +}: AccordionPanelProps) { + const { isOpen } = useAccordionItem(); + + return ( + + {keepRendered ? ( + + ); +} + +export { + Accordion, + AccordionItem, + AccordionHeader, + AccordionTrigger, + AccordionPanel, + useAccordionItem, + type AccordionProps, + type AccordionItemProps, + type AccordionHeaderProps, + type AccordionTriggerProps, + type AccordionPanelProps, + type AccordionItemContextType, +}; diff --git a/apps/web/src/components/animate-ui/primitives/base/files.tsx b/apps/web/src/components/animate-ui/primitives/base/files.tsx new file mode 100644 index 0000000..ae9f1fe --- /dev/null +++ b/apps/web/src/components/animate-ui/primitives/base/files.tsx @@ -0,0 +1,241 @@ +'use client'; + +// Generated by `npx shadcn add @animate-ui/...`, then edited: the imports were +// `motion/react` and `@base-ui-components/react`, which are second copies of +// the `framer-motion` and `@base-ui/react` this project locks. Same APIs, so +// retargeting is enough. `add --overwrite` silently reverts this -- see +// docs/UI_GUIDE.md §2. + +import * as React from 'react'; +import { AnimatePresence, motion, type HTMLMotionProps } from 'framer-motion'; + +import { + Highlight, + HighlightItem, + type HighlightItemProps, + type HighlightProps, +} from '@/components/animate-ui/primitives/effects/highlight'; +import { + Accordion, + AccordionItem, + AccordionHeader, + AccordionTrigger, + AccordionPanel, + type AccordionProps, + type AccordionItemProps, + type AccordionHeaderProps, + type AccordionTriggerProps, + type AccordionPanelProps, +} from '@/components/animate-ui/primitives/base/accordion'; +import { getStrictContext } from '@/lib/get-strict-context'; +import { useControlledState } from '@/hooks/use-controlled-state'; + +type FilesContextType = { + open: string[]; +}; + +type FolderContextType = { + isOpen: boolean; +}; + +const [FilesProvider, useFiles] = + getStrictContext('FilesContext'); + +const [FolderProvider, useFolder] = + getStrictContext('FolderContext'); + +type FilesProps = { + children: React.ReactNode; + defaultOpen?: string[]; + open?: string[]; + onOpenChange?: (open: string[]) => void; +} & Omit; + +function Files({ + children, + defaultOpen, + open, + onOpenChange, + style, + ...props +}: FilesProps) { + const [openValue, setOpenValue] = useControlledState({ + value: open, + defaultValue: defaultOpen, + onChange: onOpenChange, + }); + + return ( + + ` + // in the version this project locks. + onValueChange={(next) => setOpenValue(Array.isArray(next) ? next.map(String) : [])} + style={{ + position: 'relative', + overflow: 'auto', + ...style, + }} + {...props} + > + {children} + + + ); +} + +type FilesHighlightProps = Omit; + +function FilesHighlight({ hover = true, ...props }: FilesHighlightProps) { + return ( + + ); +} + +type FolderItemProps = AccordionItemProps; + +function FolderItem({ value, ...props }: FolderItemProps) { + const { open } = useFiles(); + + return ( + + + + ); +} + +type FolderHeaderProps = AccordionHeaderProps; + +function FolderHeader(props: FolderHeaderProps) { + return ; +} + +type FolderTriggerProps = AccordionTriggerProps; + +function FolderTrigger(props: FolderTriggerProps) { + return ; +} + +type FolderPanelProps = AccordionPanelProps; + +function FolderPanel(props: FolderPanelProps) { + return ; +} + +type FileHighlightProps = HighlightItemProps; + +function FileHighlight(props: FileHighlightProps) { + return ; +} + +type FileProps = React.ComponentProps<'div'>; + +function File(props: FileProps) { + return
; +} + +type FileIconProps = React.ComponentProps<'span'>; + +function FileIcon(props: FileIconProps) { + return ; +} + +type FileLabelProps = React.ComponentProps<'span'>; + +function FileLabel(props: FileLabelProps) { + return ; +} + +type FolderHighlightProps = HighlightItemProps; + +function FolderHighlight(props: FolderHighlightProps) { + return ; +} + +type FolderProps = React.ComponentProps<'div'>; + +function Folder(props: FolderProps) { + return
; +} + +type FolderIconProps = HTMLMotionProps<'span'> & { + closeIcon: React.ReactNode; + openIcon: React.ReactNode; +}; + +function FolderIcon({ + closeIcon, + openIcon, + transition = { duration: 0.15 }, + ...props +}: FolderIconProps) { + const { isOpen } = useFolder(); + + return ( + + + {isOpen ? openIcon : closeIcon} + + + ); +} + +type FolderLabelProps = React.ComponentProps<'span'>; + +function FolderLabel(props: FolderLabelProps) { + return ; +} + +export { + Files, + FilesHighlight, + FolderItem, + FolderHeader, + FolderTrigger, + FolderPanel, + FileHighlight, + File, + FileIcon, + FileLabel, + FolderHighlight, + Folder, + FolderIcon, + FolderLabel, + useFiles, + useFolder, + type FilesProps, + type FilesHighlightProps, + type FolderItemProps, + type FolderHeaderProps, + type FolderTriggerProps, + type FolderPanelProps, + type FileHighlightProps, + type FileProps, + type FileIconProps, + type FileLabelProps, + type FolderHighlightProps, + type FolderProps, + type FolderIconProps, + type FolderLabelProps, + type FilesContextType, + type FolderContextType, +}; diff --git a/apps/web/src/components/animate-ui/primitives/effects/highlight.tsx b/apps/web/src/components/animate-ui/primitives/effects/highlight.tsx new file mode 100644 index 0000000..d6e3533 --- /dev/null +++ b/apps/web/src/components/animate-ui/primitives/effects/highlight.tsx @@ -0,0 +1,646 @@ +'use client'; + +// Generated by `npx shadcn add @animate-ui/...`, then edited: the imports were +// `motion/react` and `@base-ui-components/react`, which are second copies of +// the `framer-motion` and `@base-ui/react` this project locks. Same APIs, so +// retargeting is enough. `add --overwrite` silently reverts this -- see +// docs/UI_GUIDE.md §2. + +import * as React from 'react'; +import { AnimatePresence, motion, type Transition } from 'framer-motion'; + +import { cn } from '@/lib/cn'; + +type HighlightMode = 'children' | 'parent'; + +type Bounds = { + top: number; + left: number; + width: number; + height: number; +}; + +const DEFAULT_BOUNDS_OFFSET: Bounds = { + top: 0, + left: 0, + width: 0, + height: 0, +}; + +type HighlightContextType = { + as?: keyof HTMLElementTagNameMap; + mode: HighlightMode; + activeValue: T | null; + setActiveValue: (value: T | null) => void; + setBounds: (bounds: DOMRect) => void; + clearBounds: () => void; + id: string; + hover: boolean; + click: boolean; + className?: string; + style?: React.CSSProperties; + activeClassName?: string; + setActiveClassName: (className: string) => void; + transition?: Transition; + disabled?: boolean; + enabled?: boolean; + exitDelay?: number; + forceUpdateBounds?: boolean; +}; + +const HighlightContext = React.createContext< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + HighlightContextType | undefined +>(undefined); + +function useHighlight(): HighlightContextType { + const context = React.useContext(HighlightContext); + if (!context) { + throw new Error('useHighlight must be used within a HighlightProvider'); + } + return context as unknown as HighlightContextType; +} + +type BaseHighlightProps = { + as?: T; + ref?: React.Ref; + mode?: HighlightMode; + value?: string | null; + defaultValue?: string | null; + onValueChange?: (value: string | null) => void; + className?: string; + style?: React.CSSProperties; + transition?: Transition; + hover?: boolean; + click?: boolean; + disabled?: boolean; + enabled?: boolean; + exitDelay?: number; +}; + +type ParentModeHighlightProps = { + boundsOffset?: Partial; + containerClassName?: string; + forceUpdateBounds?: boolean; +}; + +type ControlledParentModeHighlightProps = + BaseHighlightProps & + ParentModeHighlightProps & { + mode: 'parent'; + controlledItems: true; + children: React.ReactNode; + }; + +type ControlledChildrenModeHighlightProps = + BaseHighlightProps & { + mode?: 'children' | undefined; + controlledItems: true; + children: React.ReactNode; + }; + +type UncontrolledParentModeHighlightProps = + BaseHighlightProps & + ParentModeHighlightProps & { + mode: 'parent'; + controlledItems?: false; + itemsClassName?: string; + children: React.ReactElement | React.ReactElement[]; + }; + +type UncontrolledChildrenModeHighlightProps< + T extends React.ElementType = 'div', +> = BaseHighlightProps & { + mode?: 'children'; + controlledItems?: false; + itemsClassName?: string; + children: React.ReactElement | React.ReactElement[]; +}; + +type HighlightProps = + | ControlledParentModeHighlightProps + | ControlledChildrenModeHighlightProps + | UncontrolledParentModeHighlightProps + | UncontrolledChildrenModeHighlightProps; + +function Highlight({ + ref, + ...props +}: HighlightProps) { + const { + as: Component = 'div', + children, + value, + defaultValue, + onValueChange, + className, + style, + transition = { type: 'spring', stiffness: 350, damping: 35 }, + hover = false, + click = true, + enabled = true, + controlledItems, + disabled = false, + exitDelay = 200, + mode = 'children', + } = props; + + const localRef = React.useRef(null); + React.useImperativeHandle(ref, () => localRef.current as HTMLDivElement); + + const propsBoundsOffset = (props as ParentModeHighlightProps)?.boundsOffset; + const boundsOffset = propsBoundsOffset ?? DEFAULT_BOUNDS_OFFSET; + const boundsOffsetTop = boundsOffset.top ?? 0; + const boundsOffsetLeft = boundsOffset.left ?? 0; + const boundsOffsetWidth = boundsOffset.width ?? 0; + const boundsOffsetHeight = boundsOffset.height ?? 0; + + const boundsOffsetRef = React.useRef({ + top: boundsOffsetTop, + left: boundsOffsetLeft, + width: boundsOffsetWidth, + height: boundsOffsetHeight, + }); + + React.useEffect(() => { + boundsOffsetRef.current = { + top: boundsOffsetTop, + left: boundsOffsetLeft, + width: boundsOffsetWidth, + height: boundsOffsetHeight, + }; + }, [ + boundsOffsetTop, + boundsOffsetLeft, + boundsOffsetWidth, + boundsOffsetHeight, + ]); + + const [activeValue, setActiveValue] = React.useState( + value ?? defaultValue ?? null, + ); + const [boundsState, setBoundsState] = React.useState(null); + const [activeClassNameState, setActiveClassNameState] = + React.useState(''); + + const safeSetActiveValue = (id: string | null) => { + setActiveValue((prev) => { + if (prev !== id) { + onValueChange?.(id); + return id; + } + return prev; + }); + }; + + const safeSetBoundsRef = React.useRef< + ((bounds: DOMRect) => void) | undefined + >(undefined); + + React.useEffect(() => { + safeSetBoundsRef.current = (bounds: DOMRect) => { + if (!localRef.current) return; + + const containerRect = localRef.current.getBoundingClientRect(); + const offset = boundsOffsetRef.current; + const newBounds: Bounds = { + top: bounds.top - containerRect.top + offset.top, + left: bounds.left - containerRect.left + offset.left, + width: bounds.width + offset.width, + height: bounds.height + offset.height, + }; + + setBoundsState((prev) => { + if ( + prev && + prev.top === newBounds.top && + prev.left === newBounds.left && + prev.width === newBounds.width && + prev.height === newBounds.height + ) { + return prev; + } + return newBounds; + }); + }; + }); + + const safeSetBounds = (bounds: DOMRect) => { + safeSetBoundsRef.current?.(bounds); + }; + + const clearBounds = React.useCallback(() => { + setBoundsState((prev) => (prev === null ? prev : null)); + }, []); + + React.useEffect(() => { + if (value !== undefined) setActiveValue(value); + else if (defaultValue !== undefined) setActiveValue(defaultValue); + }, [value, defaultValue]); + + const id = React.useId(); + + React.useEffect(() => { + if (mode !== 'parent') return; + const container = localRef.current; + if (!container) return; + + const onScroll = () => { + if (!activeValue) return; + const activeEl = container.querySelector( + `[data-value="${activeValue}"][data-highlight="true"]`, + ); + if (activeEl) + safeSetBoundsRef.current?.(activeEl.getBoundingClientRect()); + }; + + container.addEventListener('scroll', onScroll, { passive: true }); + return () => container.removeEventListener('scroll', onScroll); + }, [mode, activeValue]); + + const render = (children: React.ReactNode) => { + if (mode === 'parent') { + return ( + + + {boundsState && ( + + )} + + {children} + + ); + } + + return children; + }; + + return ( + + {enabled + ? controlledItems + ? render(children) + : render( + React.Children.map(children, (child, index) => ( + + {child} + + )), + ) + : children} + + ); +} + +function getNonOverridingDataAttributes( + element: React.ReactElement, + dataAttributes: Record, +): Record { + return Object.keys(dataAttributes).reduce>( + (acc, key) => { + if ((element.props as Record)[key] === undefined) { + acc[key] = dataAttributes[key]; + } + return acc; + }, + {}, + ); +} + +type ExtendedChildProps = React.ComponentProps<'div'> & { + id?: string; + ref?: React.Ref; + 'data-active'?: string; + 'data-value'?: string; + 'data-disabled'?: boolean; + 'data-highlight'?: boolean; + 'data-slot'?: string; +}; + +type HighlightItemProps = + React.ComponentProps & { + as?: T; + children: React.ReactElement; + id?: string; + value?: string; + className?: string; + style?: React.CSSProperties; + transition?: Transition; + activeClassName?: string; + disabled?: boolean; + exitDelay?: number; + asChild?: boolean; + forceUpdateBounds?: boolean; + }; + +function HighlightItem({ + ref, + as, + children, + id, + value, + className, + style, + transition, + disabled = false, + activeClassName, + exitDelay, + asChild = false, + forceUpdateBounds, + ...props +}: HighlightItemProps) { + const itemId = React.useId(); + const { + activeValue, + setActiveValue, + mode, + setBounds, + clearBounds, + hover, + click, + enabled, + className: contextClassName, + style: contextStyle, + transition: contextTransition, + id: contextId, + disabled: contextDisabled, + exitDelay: contextExitDelay, + forceUpdateBounds: contextForceUpdateBounds, + setActiveClassName, + } = useHighlight(); + + const Component = as ?? 'div'; + const element = children as React.ReactElement; + const childValue = + id ?? value ?? element.props?.['data-value'] ?? element.props?.id ?? itemId; + const isActive = activeValue === childValue; + const isDisabled = disabled === undefined ? contextDisabled : disabled; + const itemTransition = transition ?? contextTransition; + + const localRef = React.useRef(null); + React.useImperativeHandle(ref, () => localRef.current as HTMLDivElement); + + const refCallback = React.useCallback((node: HTMLElement | null) => { + localRef.current = node as HTMLDivElement; + }, []); + + React.useEffect(() => { + if (mode !== 'parent') return; + let rafId: number; + let previousBounds: Bounds | null = null; + const shouldUpdateBounds = + forceUpdateBounds === true || + (contextForceUpdateBounds && forceUpdateBounds !== false); + + const updateBounds = () => { + if (!localRef.current) return; + + const bounds = localRef.current.getBoundingClientRect(); + + if (shouldUpdateBounds) { + if ( + previousBounds && + previousBounds.top === bounds.top && + previousBounds.left === bounds.left && + previousBounds.width === bounds.width && + previousBounds.height === bounds.height + ) { + rafId = requestAnimationFrame(updateBounds); + return; + } + previousBounds = bounds; + rafId = requestAnimationFrame(updateBounds); + } + + setBounds(bounds); + }; + + if (isActive) { + updateBounds(); + setActiveClassName(activeClassName ?? ''); + } else if (!activeValue) clearBounds(); + + if (shouldUpdateBounds) return () => cancelAnimationFrame(rafId); + }, [ + mode, + isActive, + activeValue, + setBounds, + clearBounds, + activeClassName, + setActiveClassName, + forceUpdateBounds, + contextForceUpdateBounds, + ]); + + if (!React.isValidElement(children)) return children; + + const dataAttributes = { + 'data-active': isActive ? 'true' : 'false', + 'aria-selected': isActive, + 'data-disabled': isDisabled, + 'data-value': childValue, + 'data-highlight': true, + }; + + const commonHandlers = hover + ? { + onMouseEnter: (e: React.MouseEvent) => { + setActiveValue(childValue); + element.props.onMouseEnter?.(e); + }, + onMouseLeave: (e: React.MouseEvent) => { + setActiveValue(null); + element.props.onMouseLeave?.(e); + }, + } + : click + ? { + onClick: (e: React.MouseEvent) => { + setActiveValue(childValue); + element.props.onClick?.(e); + }, + } + : {}; + + if (asChild) { + if (mode === 'children') { + return React.cloneElement( + element, + { + key: childValue, + ref: refCallback, + className: cn('relative', element.props.className), + ...getNonOverridingDataAttributes(element, { + ...dataAttributes, + 'data-slot': 'motion-highlight-item-container', + }), + ...commonHandlers, + ...props, + }, + <> + + {isActive && !isDisabled && ( + + )} + + + + {children} + + , + ); + } + + return React.cloneElement(element, { + ref: refCallback, + ...getNonOverridingDataAttributes(element, { + ...dataAttributes, + 'data-slot': 'motion-highlight-item', + }), + ...commonHandlers, + }); + } + + return enabled ? ( + + {mode === 'children' && ( + + {isActive && !isDisabled && ( + + )} + + )} + + {React.cloneElement(element, { + style: { position: 'relative', zIndex: 1 }, + className: element.props.className, + ...getNonOverridingDataAttributes(element, { + ...dataAttributes, + 'data-slot': 'motion-highlight-item', + }), + })} + + ) : ( + children + ); +} + +export { + Highlight, + HighlightItem, + useHighlight, + type HighlightProps, + type HighlightItemProps, +}; diff --git a/apps/web/src/components/landing/Index.tsx b/apps/web/src/components/landing/Index.tsx new file mode 100644 index 0000000..29564f2 --- /dev/null +++ b/apps/web/src/components/landing/Index.tsx @@ -0,0 +1,110 @@ +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. +
  • +
+ + + + + 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 index 9494523..96488fb 100644 --- a/apps/web/src/components/landing/Landing.test.tsx +++ b/apps/web/src/components/landing/Landing.test.tsx @@ -70,6 +70,19 @@ describe("the landing page", () => { 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")); diff --git a/apps/web/src/components/landing/Landing.tsx b/apps/web/src/components/landing/Landing.tsx index 1a46785..dc226c1 100644 --- a/apps/web/src/components/landing/Landing.tsx +++ b/apps/web/src/components/landing/Landing.tsx @@ -3,6 +3,7 @@ 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"; @@ -30,6 +31,7 @@ export function Landing() { + 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/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 }; From 97fb25065db85c2d560feac8aa4e75076182c927 Mon Sep 17 00:00:00 2001 From: Aditya Rana Date: Sat, 29 Aug 2026 00:06:37 +0530 Subject: [PATCH 5/9] route the root to the landing page and the canvas to /app `App` becomes the route switch and the session branching moves into `AppRoute` unchanged. `useSession` moves with it, which is the point of the split rather than a side effect: the landing page now issues no request and renders with the API down, which is the least a page whose job is to explain the product can do. Anything that is not `/app` is the landing page; there is no 404 because there is nothing else to be. The OAuth callback redirected to `env.WEB_APP_URL`, a bare origin. That is now the marketing page, so it redirects to APP_ROUTE under it -- signing someone in and then showing them the pitch for the product they just signed in to is the failure the test asserts against. `.env` is unchanged: WEB_APP_URL stays an origin and the path is joined here. App.test.tsx pushes APP_ROUTE before the session cases, which at jsdom's default `/` would otherwise be asserting against the marketing page. --- apps/api/src/auth/routes.test.ts | 6 +++++- apps/api/src/auth/routes.ts | 9 +++++---- apps/web/src/App.test.tsx | 25 +++++++++++++++++++++++++ apps/web/src/App.tsx | 19 +++++++++++++++++-- 4 files changed, 52 insertions(+), 7 deletions(-) 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/src/App.test.tsx b/apps/web/src/App.test.tsx index 8ae3be0..76d4b64 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", () => { diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 01cdd64..ffb510d 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,15 +1,17 @@ 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 { usePath } from "./lib/router"; import { useSession } from "./lib/session"; import { CANVAS_DEFAULT, @@ -27,13 +29,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) { From d47c1d1f82b1ff602c5b8342128991aac268fb48 Mon Sep 17 00:00:00 2001 From: Aditya Rana Date: Sat, 29 Aug 2026 00:21:16 +0530 Subject: [PATCH 6/9] size the hero to the page and widen the shell Four things, all found by looking at it in a browser rather than at the code. The viewBox was 560x360 for a drawing 268 units tall, and an SVG scales to fit its own empty space, so the graph came out small and floated in the middle of its bezel. The height is now measured off the lowest baseline plus the ghost's caption plus a margin. The four surfaces each carried their own max width -- the title block and the sections at 64rem, the hero at 72rem -- so the wordmark did not line up with the headline under it, and the whole page read as a column floating in a field of ground. One shared LANDING_SHELL at 104rem, and the prose inside caps itself instead. The headline was sized to wrap to five lines in the column it ended up with. It lands on three now. The em dash is gone from the visible copy, here and in the sections. --- .../web/src/components/landing/ClosingCta.tsx | 2 +- .../src/components/landing/GitHubStars.tsx | 5 +- apps/web/src/components/landing/Hero.tsx | 31 ++++++--- .../src/components/landing/HeroGraph.test.tsx | 4 +- apps/web/src/components/landing/HeroGraph.tsx | 12 +++- .../web/src/components/landing/HowItWorks.tsx | 10 ++- apps/web/src/components/landing/Index.tsx | 63 ++++++++++++++----- .../src/components/landing/Landing.test.tsx | 37 ++++++++--- .../src/components/landing/LandingFooter.tsx | 15 ++++- .../src/components/landing/LandingHeader.tsx | 13 +++- apps/web/src/components/landing/Languages.tsx | 14 +++-- apps/web/src/components/landing/OpenAtlas.tsx | 13 +++- apps/web/src/components/landing/Section.tsx | 14 ++++- apps/web/src/components/landing/Tiers.tsx | 29 ++++++--- apps/web/src/components/landing/shell.ts | 13 ++++ apps/web/src/lib/hero-graph.test.ts | 4 +- apps/web/src/lib/hero-graph.ts | 24 ++++--- 17 files changed, 227 insertions(+), 76 deletions(-) create mode 100644 apps/web/src/components/landing/shell.ts diff --git a/apps/web/src/components/landing/ClosingCta.tsx b/apps/web/src/components/landing/ClosingCta.tsx index 1f194a0..c270fa4 100644 --- a/apps/web/src/components/landing/ClosingCta.tsx +++ b/apps/web/src/components/landing/ClosingCta.tsx @@ -5,7 +5,7 @@ import { Section } from "./Section"; * 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.", + "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; diff --git a/apps/web/src/components/landing/GitHubStars.tsx b/apps/web/src/components/landing/GitHubStars.tsx index 2e54856..3a55ba8 100644 --- a/apps/web/src/components/landing/GitHubStars.tsx +++ b/apps/web/src/components/landing/GitHubStars.tsx @@ -40,7 +40,10 @@ export function GitHubStars() { className="size-3.5 transition-colors duration-micro group-hover:text-confidence-name" aria-hidden /> - +
) : null} diff --git a/apps/web/src/components/landing/Hero.tsx b/apps/web/src/components/landing/Hero.tsx index e137e15..752526a 100644 --- a/apps/web/src/components/landing/Hero.tsx +++ b/apps/web/src/components/landing/Hero.tsx @@ -1,6 +1,8 @@ +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. @@ -15,20 +17,32 @@ import { OpenAtlas } from "./OpenAtlas"; */ export function Hero() { return ( -
+

exact · name_match · unresolved

-

- A map of every call in a repository — and of where the map ends. + {/* 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. + 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.

@@ -36,8 +50,9 @@ export function Hero() {

- Public repositories. GitHub sign-in at read:user — - nothing is written to your account. + 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 index f3fd093..4b5e5a3 100644 --- a/apps/web/src/components/landing/HeroGraph.test.tsx +++ b/apps/web/src/components/landing/HeroGraph.test.tsx @@ -18,7 +18,9 @@ describe("HeroGraph", () => { // 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"))); + 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. diff --git a/apps/web/src/components/landing/HeroGraph.tsx b/apps/web/src/components/landing/HeroGraph.tsx index 847c32b..3201af7 100644 --- a/apps/web/src/components/landing/HeroGraph.tsx +++ b/apps/web/src/components/landing/HeroGraph.tsx @@ -67,7 +67,11 @@ export function HeroGraph({ className }: { className?: string }) { ) : null} - + {HERO_EDGES.map((edge) => (
-

{step.title}

-

{step.body}

+

+ {step.title} +

+

+ {step.body} +

))} diff --git a/apps/web/src/components/landing/Index.tsx b/apps/web/src/components/landing/Index.tsx index 29564f2..c3c1df2 100644 --- a/apps/web/src/components/landing/Index.tsx +++ b/apps/web/src/components/landing/Index.tsx @@ -23,7 +23,9 @@ import { Section } from "./Section"; */ function Count({ functions }: { functions: number }) { return ( - {functions} + + {functions} + ); } @@ -35,24 +37,27 @@ export function Index() { title="An atlas has an index. Here it is the file tree." lede="Directories before files, paths in monospace, and a function count on every one. Open a file and its card springs onto the canvas; open a function and the map branches out from it." > -
-
    +
    +
    • - 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. + 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. + ⌘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. + 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 @@ -63,13 +68,25 @@ export function Index() { web - }> + } + > App.tsx - }> + } + > Canvas.tsx - }> + } + > graph.ts @@ -79,7 +96,11 @@ export function Index() { api - }> + } + > routes.ts @@ -93,10 +114,18 @@ export function Index() { - }> + } + > resolver.go - }> + } + > extract.go diff --git a/apps/web/src/components/landing/Landing.test.tsx b/apps/web/src/components/landing/Landing.test.tsx index 96488fb..6aad6f9 100644 --- a/apps/web/src/components/landing/Landing.test.tsx +++ b/apps/web/src/components/landing/Landing.test.tsx @@ -10,7 +10,9 @@ import { Landing } from "./Landing"; vi.mock("../../lib/useSmoothScroll", () => ({ useSmoothScroll: () => {} })); function renderLanding() { - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); return render( @@ -27,11 +29,15 @@ 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")); + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockRejectedValue(new Error("offline")); renderLanding(); - const ours = fetchSpy.mock.calls.filter(([input]) => !String(input).includes("api.github.com")); + const ours = fetchSpy.mock.calls.filter( + ([input]) => !String(input).includes("api.github.com"), + ); expect(ours).toEqual([]); }); @@ -48,13 +54,17 @@ describe("the landing page", () => { it("names all three tiers and what each one means", () => { renderLanding(); - const tiers = screen.getByRole("heading", { name: /certainty is the product/i }).closest("section"); + 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(); + expect( + within(tiers as HTMLElement).getByText(meaning), + ).toBeInTheDocument(); } }); @@ -67,13 +77,17 @@ describe("the landing page", () => { // 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(); + 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"); + 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 @@ -88,7 +102,12 @@ describe("the landing page", () => { renderLanding(); - const links = await screen.findAllByRole("link", { name: /funcatlas on github/i }); - expect(links[0]).toHaveAttribute("href", expect.stringContaining("github.com")); + 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/LandingFooter.tsx b/apps/web/src/components/landing/LandingFooter.tsx index 57df780..9e4af44 100644 --- a/apps/web/src/components/landing/LandingFooter.tsx +++ b/apps/web/src/components/landing/LandingFooter.tsx @@ -1,10 +1,19 @@ +import { cn } from "../../lib/cn"; import { GITHUB_REPO_URL } from "../../lib/constants"; +import { LANDING_SHELL } from "./shell"; export function LandingFooter() { return ( -