diff --git a/TASKLIST.md b/TASKLIST.md index db60e3b..d802094 100644 --- a/TASKLIST.md +++ b/TASKLIST.md @@ -152,3 +152,35 @@ all clean. Still to do by hand at the gate: a real polyglot public repository charted end to end in real Chrome, checking the tree language labels, the per-node badge, and Shiki highlighting for each language. + +--- + +## The landing page (`landing-page`, not a phase) + +A second full surface that no phase's exit test touches — `docs/UI_GUIDE.md` §3.1 specified it +during Phase 3b and named it the next branch after that gate. Phases 4 and 5 went first. + +- [x] **Route.** `lib/router.tsx` over `pushState`; `/` is the landing page, `/app` is the canvas, + `APP_ROUTE` is shared so the OAuth callback stops returning people to the marketing page. +- [x] **Hero.** Plain SVG drawing itself under a mask sweep, never an animated `pathLength`, with a + ghost node at the map's edge. +- [x] **Sections.** Title block, hero, resolution, pipeline, index, coverage, closing, footer. Each + section rule is the confidence tier that is true of that section. +- [x] **Installed, not written.** animate-ui `effects/fade`, `texts/sliding-number` and + `components-base-files`; `lenis` for smooth scrolling, mounted from `Landing` alone. +- [x] **Palette.** Ember/Vellum replaced product-wide by Ultramarine/Letterpress. `UI_GUIDE.md` + §1.1 rewritten, §7.1 records the old one as shipped and replaced. +- [x] **Done when.** `make test`, `make lint`, `make typecheck`, `make go-vet` clean. + +**Verified by hand in real Chrome:** both themes, the full page, the finished graph, `/app` still +resolving its session. **Not verified, and stated as such in the PR:** the draw animation and the +mobile breakpoints — `requestAnimationFrame` ran at roughly one frame per half-second in the +available browser window and `resize_window` was ignored, so neither could be observed. + +## Next + +- [ ] **NFR-4 — `docker compose up`.** Four verified faults: `apps/web` has no Dockerfile; there is + no `worker` service, so a webhook enqueues a job nothing consumes; the API image runs `node + dist/index.js` against `packages/shared` exports that point at `.ts`; and the `parser` service + sets both `network_mode: none` and `depends_on` Postgres health. Do **not** resolve the last + by giving the parser a network — that undoes a Phase 1 guarantee. diff --git a/apps/api/src/auth/routes.test.ts b/apps/api/src/auth/routes.test.ts index d978e77..f3c5855 100644 --- a/apps/api/src/auth/routes.test.ts +++ b/apps/api/src/auth/routes.test.ts @@ -1,5 +1,6 @@ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import type { FastifyInstance } from "fastify"; +import { APP_ROUTE } from "@funcatlas/shared"; import { buildApp } from "../app.js"; import { env } from "../env.js"; import { cookieHeader } from "../test-helpers.js"; @@ -99,7 +100,10 @@ describe("GET /auth/callback", () => { }); expect(res.statusCode).toBe(302); - expect(res.headers.location).toBe(env.WEB_APP_URL); + // The canvas, not the web app's root -- the root is the marketing landing + // page, and pitching the product to someone who has just signed in to it + // is the failure this asserts against. + expect(res.headers.location).toBe(new URL(APP_ROUTE, env.WEB_APP_URL).toString()); // Reaching here proves the state in the redirect and the state in the // cookie are the same value -- nothing else compares them. diff --git a/apps/api/src/auth/routes.ts b/apps/api/src/auth/routes.ts index fd893dc..3ad1b1f 100644 --- a/apps/api/src/auth/routes.ts +++ b/apps/api/src/auth/routes.ts @@ -1,6 +1,6 @@ import { randomBytes, timingSafeEqual } from "node:crypto"; import type { FastifyInstance } from "fastify"; -import { oauthCallbackSchema } from "@funcatlas/shared"; +import { APP_ROUTE, oauthCallbackSchema } from "@funcatlas/shared"; import { env } from "../env.js"; import { OAUTH_SCOPES, @@ -74,9 +74,10 @@ export function registerAuth(app: FastifyInstance) { } setSessionCookie(reply, sessionId); - // The web app, not this one. Redirecting to the API's own origin lands a - // freshly signed-in user on a JSON endpoint. - return reply.redirect(env.WEB_APP_URL); + // The web app's canvas, not this API and not the web app's root. The root + // is the marketing landing page, so a bare origin here would sign someone + // in and then show them the pitch for the product they just signed in to. + return reply.redirect(new URL(APP_ROUTE, env.WEB_APP_URL).toString()); }); // POST, not GET: with SameSite=Lax a cross-site GET navigation still carries diff --git a/apps/web/components.json b/apps/web/components.json index c5f0814..06f5512 100644 --- a/apps/web/components.json +++ b/apps/web/components.json @@ -21,5 +21,7 @@ }, "menuColor": "default", "menuAccent": "subtle", - "registries": {} + "registries": { + "@animate-ui": "https://animate-ui.com/r/{name}.json" + } } diff --git a/apps/web/package.json b/apps/web/package.json index 03a1809..c9b4c44 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -22,11 +22,13 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "framer-motion": "^11.18.2", + "lenis": "^1.3.26", "lucide-react": "^0.469.0", "next-themes": "^0.4.6", "react": "^19.2.0", "react-dom": "^19.2.0", "react-resizable-panels": "^4.12.2", + "react-use-measure": "^2.1.7", "reactflow": "^11.11.4", "shadcn": "^4.18.0", "shiki": "^3.2.1", diff --git a/apps/web/src/App.test.tsx b/apps/web/src/App.test.tsx index 8ae3be0..4178531 100644 --- a/apps/web/src/App.test.tsx +++ b/apps/web/src/App.test.tsx @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { APP_ROUTE } from "@funcatlas/shared"; import App from "./App"; import { ApiError, api } from "./lib/api"; @@ -36,6 +37,30 @@ const SIGNED_OUT = new ApiError(401, "unauthorized"); beforeEach(() => { vi.clearAllMocks(); + // Every one of these is about the canvas route. At jsdom's default `/` the + // app renders the marketing page, which resolves no session at all. + window.history.replaceState(null, "", APP_ROUTE); +}); + +describe("routing", () => { + it("shows the landing page at the root, without resolving a session", async () => { + window.history.replaceState(null, "", "/"); + mocked.me.mockRejectedValue(SIGNED_OUT); + + renderApp(); + + expect(await screen.findByRole("heading", { level: 1 })).toHaveTextContent(/map of every call/i); + expect(mocked.me).not.toHaveBeenCalled(); + }); + + it("shows the canvas route for anything else", async () => { + window.history.replaceState(null, "", APP_ROUTE); + mocked.me.mockResolvedValue({ userId: 7, login: "octocat" }); + + renderApp(); + + expect(await screen.findByText("octocat")).toBeInTheDocument(); + }); }); describe("session states", () => { @@ -84,8 +109,16 @@ describe("session states", () => { renderApp(false); // A sign-in button here would point at an API that cannot answer. - expect(await screen.findByText(/api is not responding/i)).toBeInTheDocument(); + expect(await screen.findByText(/cannot reach its API/i)).toBeInTheDocument(); expect(screen.queryByRole("link", { name: /sign in with github/i })).not.toBeInTheDocument(); + + // On a deploy where only the web app is up, this screen is where a visitor + // lands. It has to offer a way onward rather than a command they cannot run. + expect(screen.getByRole("link", { name: "overview" })).toHaveAttribute("href", "/"); + expect(screen.getByRole("link", { name: "source" })).toHaveAttribute( + "href", + expect.stringContaining("github.com"), + ); }); }); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 01cdd64..258ed6f 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,18 +1,21 @@ import { useRef, useState } from "react"; -import type { SessionUser } from "@funcatlas/shared"; +import { APP_ROUTE, type SessionUser } from "@funcatlas/shared"; import type { PanelImperativeHandle } from "react-resizable-panels"; import { AppHeader } from "./components/AppHeader"; import { Canvas } from "./components/Canvas"; import { CommandPalette } from "./components/CommandPalette"; +import { Landing } from "./components/landing/Landing"; import { LoginScreen } from "./components/LoginScreen"; import { Sidebar } from "./components/Sidebar"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "./components/ui/resizable"; import { SidebarProvider } from "./components/ui/sidebar"; import { Skeleton } from "./components/ui/skeleton"; import { TooltipProvider } from "./components/ui/tooltip"; +import { Link, usePath } from "./lib/router"; import { useSession } from "./lib/session"; import { CANVAS_DEFAULT, + GITHUB_REPO_URL, SIDEBAR_DEFAULT, SIDEBAR_MAX, SIDEBAR_MIN, @@ -27,13 +30,26 @@ import { * back to an even split. */ +/** + * Two routes. Anything that is not the canvas is the landing page -- there is + * no 404, because there is nothing else to be. + * + * The split matters beyond tidiness: `useSession` lives inside `AppRoute`, so + * the landing page issues no request at all and renders with the API down. + * A marketing page that needs a backend to say what the product is is not a + * marketing page. + */ +export default function App() { + return usePath() === APP_ROUTE ? : ; +} + /** * Three states, and the server decides which: resolving, signed out, signed in. * * There is no local "is logged in" flag to drift out of sync -- the cookie is * HttpOnly, so `useSession` asking the API is the only honest answer. */ -export default function App() { +function AppRoute() { const session = useSession(); if (session.isPending) { @@ -149,14 +165,39 @@ function ResolvingSession() { /** * The API could not be reached at all -- which is different from being signed * out, and showing a sign-in button here would send the user to a dead link. + * + * Written for two readers, because both really arrive here. One is running the + * stack and has forgotten a process. The other followed a link to a deploy + * where only the web app is up, and "run `pnpm dev`" means nothing to them -- + * they need to know the page is not broken and where the source is. */ function SessionUnavailable() { return ( -
-
-

The API is not responding

-

- Start it with pnpm dev, then reload. +

+
+

The canvas cannot reach its API

+ +

+ Charting a repository needs the API, the parse worker, Postgres and Redis. None of them + answered, so there is nothing to draw. +

+ +

+ Running it locally? Start them with make start{" "} + and reload. Otherwise the{" "} + + source + {" "} + has the setup, and the{" "} + + overview + {" "} + explains what it does.

diff --git a/apps/web/src/components/ConfidenceLegend.tsx b/apps/web/src/components/ConfidenceLegend.tsx index 87a67f5..c11285a 100644 --- a/apps/web/src/components/ConfidenceLegend.tsx +++ b/apps/web/src/components/ConfidenceLegend.tsx @@ -1,5 +1,6 @@ import { cn } from "../lib/cn"; import { CONFIDENCE, CONFIDENCE_ORDER } from "../lib/confidence"; +import { ConfidenceRule } from "./ConfidenceRule"; import { Item, ItemContent, ItemDescription, ItemGroup, ItemMedia, ItemTitle } from "./ui/item"; /** @@ -15,33 +16,12 @@ export function ConfidenceLegend({ className }: { className?: string }) { return ( {CONFIDENCE_ORDER.map((tier) => { - const { label, meaning, strokeDasharray, textClass } = CONFIDENCE[tier]; + const { label, meaning, textClass } = CONFIDENCE[tier]; return ( - {/* The actual line, not a colour chip. A swatch would show the - hue but not the pattern, and the pattern is the part that - carries the meaning. currentColor, so it follows the theme. */} - - - + diff --git a/apps/web/src/components/ConfidenceRule.tsx b/apps/web/src/components/ConfidenceRule.tsx new file mode 100644 index 0000000..bb89f6e --- /dev/null +++ b/apps/web/src/components/ConfidenceRule.tsx @@ -0,0 +1,41 @@ +import type { ResolutionConfidence } from "@funcatlas/shared"; +import { cn } from "../lib/cn"; +import { CONFIDENCE } from "../lib/confidence"; + +/** + * One tier, drawn as the line the canvas draws it as. + * + * A colour swatch would show the hue and lose the pattern, and the pattern is + * the part that carries the meaning. `currentColor` through the tier's text + * class, so it follows the theme without reading it. + * + * Shared by the legend (fixed 28px) and the landing page (full width, as a + * section rule): the same three lines, drawn once. + */ +export function ConfidenceRule({ + tier, + className, +}: { + tier: ResolutionConfidence; + className?: string; +}) { + return ( + + + + ); +} diff --git a/apps/web/src/components/animate-ui/components/base/files.tsx b/apps/web/src/components/animate-ui/components/base/files.tsx new file mode 100644 index 0000000..233919d --- /dev/null +++ b/apps/web/src/components/animate-ui/components/base/files.tsx @@ -0,0 +1,150 @@ +// Generated by `npx shadcn add @animate-ui/components-base-files`, then +// edited: the upstream right-hand slot is a git status dot in hardcoded +// green/amber/red. This project has no diff to report and no colour outside +// tokens.ts, so the slot became `meta` and carries a function count. +// `add --overwrite` silently reverts this -- see docs/UI_GUIDE.md §2. + +import * as React from 'react'; +import { FolderIcon, FolderOpenIcon, FileIcon } from 'lucide-react'; + +import { + Files as FilesPrimitive, + FilesHighlight as FilesHighlightPrimitive, + FolderItem as FolderItemPrimitive, + FolderHeader as FolderHeaderPrimitive, + FolderTrigger as FolderTriggerPrimitive, + FolderHighlight as FolderHighlightPrimitive, + Folder as FolderPrimitive, + FolderIcon as FolderIconPrimitive, + FileLabel as FileLabelPrimitive, + FolderPanel as FolderPanelPrimitive, + FileHighlight as FileHighlightPrimitive, + File as FilePrimitive, + FileIcon as FileIconPrimitive, + type FilesProps as FilesPrimitiveProps, + type FolderItemProps as FolderItemPrimitiveProps, + type FolderPanelProps as FolderPanelPrimitiveProps, + type FileProps as FilePrimitiveProps, + type FileLabelProps as FileLabelPrimitiveProps, +} from '@/components/animate-ui/primitives/base/files'; +import { cn } from '@/lib/cn'; + +type FilesProps = FilesPrimitiveProps; + +function Files({ className, children, ...props }: FilesProps) { + return ( + + + {children} + + + ); +} + +type SubFilesProps = FilesProps; + +function SubFiles(props: SubFilesProps) { + return ; +} + +type FolderItemProps = FolderItemPrimitiveProps; + +function FolderItem(props: FolderItemProps) { + return ; +} + +type FolderTriggerProps = FileLabelPrimitiveProps & { + /** Replaces the upstream git-status dot: this project has no diff to + * report, and its hardcoded green/amber/red sit outside tokens.ts. */ + meta?: React.ReactNode; +}; + +function FolderTrigger({ + children, + className, + meta, + ...props +}: FolderTriggerProps) { + return ( + + + + +
+ } + 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/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/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/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/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/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..c270fa4 --- /dev/null +++ b/apps/web/src/components/landing/ClosingCta.tsx @@ -0,0 +1,33 @@ +import { OpenAtlas } from "./OpenAtlas"; +import { Section } from "./Section"; + +/** The limits, under a dotted rule, because that is what a dotted line means + * everywhere else in this product. */ +const LIMITS = [ + "Public repositories only. The OAuth scope is read:user, and the parser clones over public HTTPS.", + "Nothing is written to your GitHub account: no commits, no issues, no status checks.", + "A push updates the graph through a webhook, so what you are looking at is the current commit.", +] as const; + +export function ClosingCta() { + return ( +
+
+
    + {LIMITS.map((limit) => ( +
  • + {limit} +
  • + ))} +
+ + +
+
+ ); +} diff --git a/apps/web/src/components/landing/GitHubStars.tsx b/apps/web/src/components/landing/GitHubStars.tsx new file mode 100644 index 0000000..3a55ba8 --- /dev/null +++ b/apps/web/src/components/landing/GitHubStars.tsx @@ -0,0 +1,51 @@ +import { Github, Star } from "lucide-react"; +import { SlidingNumber } from "../animate-ui/primitives/texts/sliding-number"; +import { GITHUB_REPO_URL } from "../../lib/constants"; +import { useGitHubStars } from "../../lib/github"; +import { useMotionEnabled } from "../../lib/motion"; + +/** + * A link to the source, carrying the star count once it arrives. + * + * The count rolls up from zero rather than appearing, because it lands late + * and a number that pops in reads as a layout shift. Reduced motion gets the + * final figure with no roll. + * + * The link is the component and the count is an ornament on it: GitHub + * rate-limits anonymous callers, so `isSuccess` is the common failure and the + * control has to stay useful without it. + */ +export function GitHubStars() { + const stars = useGitHubStars(); + const animate = useMotionEnabled(); + + return ( + + + + {stars.isSuccess ? ( + + + + + ) : null} + + ); +} diff --git a/apps/web/src/components/landing/Hero.tsx b/apps/web/src/components/landing/Hero.tsx new file mode 100644 index 0000000..752526a --- /dev/null +++ b/apps/web/src/components/landing/Hero.tsx @@ -0,0 +1,64 @@ +import { cn } from "../../lib/cn"; +import { Bezel } from "./Bezel"; +import { HeroGraph } from "./HeroGraph"; +import { OpenAtlas } from "./OpenAtlas"; +import { LANDING_SHELL } from "./shell"; + +/** + * The thesis, stated twice: once in the headline and once as a drawing. + * + * Split left and right the way the app itself is -- index on the left, map on + * the right -- so the page's shape is already the product's shape before a + * reader signs in. + * + * The headline drives Bricolage Grotesque's width and optical-size axes, which + * is the whole reason the face was chosen (UI_GUIDE §1.2) and is otherwise + * left unused. + */ +export function Hero() { + return ( +
+
+

+ exact · name_match · unresolved +

+ + {/* Sized to land on three lines beside the graph. Bricolage's width + axis is driven down a little so a long line fits without dropping + the display size, which is the axis existing to be used. */} +

+ A map of every call in a repository, and of where the map ends. +

+ +

+ funcatlas clones a repository, extracts every function and call site + with tree-sitter, and resolves each call to the function it reaches. + Where it cannot tell which function that is, it says so instead of + guessing. +

+ +
+ +
+ +

+ Public repositories. GitHub sign-in at{" "} + read:user, and nothing is written + to your account. +

+
+ + + + +
+ ); +} diff --git a/apps/web/src/components/landing/HeroGraph.test.tsx b/apps/web/src/components/landing/HeroGraph.test.tsx new file mode 100644 index 0000000..4b5e5a3 --- /dev/null +++ b/apps/web/src/components/landing/HeroGraph.test.tsx @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { HERO_EDGES } from "../../lib/hero-graph"; +import { HeroGraph } from "./HeroGraph"; + +function edgePaths(): SVGPathElement[] { + return Array.from(document.querySelectorAll("g > path")); +} + +describe("HeroGraph", () => { + it("draws the three tiers as three different lines", () => { + render(); + + const paths = edgePaths(); + expect(paths).toHaveLength(HERO_EDGES.length); + + // The regression this exists for: Framer's `pathLength` writes an inline + // stroke-dasharray, which collapses solid, dashed and dotted into one + // pattern. The graph still animates, still looks fine, and has stopped + // saying the only thing it is there to say (PRD §8). + const patterns = new Set( + paths.map((path) => path.getAttribute("stroke-dasharray")), + ); + expect(patterns.size).toBe(3); + + // Solid is the absence of a pattern, not a pattern that looks solid. + expect(patterns).toContain(null); + }); + + it("describes itself for a reader who cannot see it", () => { + render(); + + const graph = screen.getByRole("img", { name: "A resolved call graph" }); + expect(graph).toHaveAccessibleDescription(/could not be resolved/i); + }); + + it("labels the unresolved callee instead of hiding it", () => { + render(); + + // The map shows its own boundary (UI_GUIDE §3.2). Dropping the ghost node + // would make the hero claim a completeness the parser never promised. + expect(screen.getByText("formatError")).toBeInTheDocument(); + expect(screen.getByText("Unresolved")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/landing/HeroGraph.tsx b/apps/web/src/components/landing/HeroGraph.tsx new file mode 100644 index 0000000..3201af7 --- /dev/null +++ b/apps/web/src/components/landing/HeroGraph.tsx @@ -0,0 +1,145 @@ +import { useId } from "react"; +import { motion } from "framer-motion"; +import { cn } from "../../lib/cn"; +import { CONFIDENCE } from "../../lib/confidence"; +import { EDGE_STAGGER_SECONDS } from "../../lib/constants"; +import { + HERO_ALT, + HERO_EDGES, + HERO_NODE, + HERO_NODES, + HERO_VIEWBOX, + heroEdgePath, +} from "../../lib/hero-graph"; +import { DURATION, EASE_DRAW, useMotionEnabled } from "../../lib/motion"; + +/** + * The hero: a call graph drawing itself, not a picture of one. + * + * Plain SVG rather than React Flow. React Flow wants a measured container and + * brings pan, zoom and handles that a hero has no use for, along with the + * `node.width` trap in `docs/CANVAS_DECISIONS.md` §4. A fixed viewBox scales + * on its own and cannot drop an edge in silence. + * + * **Do not animate `pathLength`.** Framer implements it by writing an inline + * `stroke-dasharray`, which overwrites the dash pattern that distinguishes the + * three tiers -- solid, dashed and dotted all come out identical, which is the + * one picture this product must never show (PRD §8). The draw is a mask + * sweeping across instead, so every path keeps the dash pattern it was given. + * + * The sweep runs left to right and the graph is laid out by depth, so the + * sweep *is* the stagger: one animated element for the whole orchestrated + * moment (UI_GUIDE §4). + */ +export function HeroGraph({ className }: { className?: string }) { + const animate = useMotionEnabled(); + const id = useId(); + const titleId = `${id}-title`; + const descId = `${id}-desc`; + const maskId = `${id}-reveal`; + + return ( + + 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/components/landing/HowItWorks.tsx b/apps/web/src/components/landing/HowItWorks.tsx new file mode 100644 index 0000000..c121d5c --- /dev/null +++ b/apps/web/src/components/landing/HowItWorks.tsx @@ -0,0 +1,57 @@ +import { Section } from "./Section"; + +/** + * Four steps, numbered because this genuinely is a sequence — each one takes + * the output of the one before it. Numbering anything else on this page would + * be decoration wearing a structural costume. + */ +const STEPS = [ + { + title: "Clone, in a box", + body: "The repository is cloned into a sandbox with no network, a read-only root filesystem, no capabilities and a non-root user. Symlinks fail the run rather than being followed; files over 1 MB are skipped.", + }, + { + title: "Extract", + body: "tree-sitter walks every file for function declarations and call sites. One pinned grammar per extension, never shared, because a mismatched grammar fails silently and drops every call in the file.", + }, + { + title: "Resolve", + body: "Each call site is matched against the symbol table and given a confidence tier. The table is partitioned by language, so no edge can cross a language boundary, and ambiguity resolves to unresolved.", + }, + { + title: "Explore", + body: "The graph opens on a canvas: the file tree as an index, a file card, a function mind-map branching from it, and the source inline. ⌘K jumps to any function by name.", + }, +] as const; + +export function HowItWorks() { + return ( +
+
    + {STEPS.map((step, index) => ( +
  1. + + {String(index + 1).padStart(2, "0")} + + +
    +

    + {step.title} +

    +

    + {step.body} +

    +
    +
  2. + ))} +
+
+ ); +} diff --git a/apps/web/src/components/landing/Index.tsx b/apps/web/src/components/landing/Index.tsx new file mode 100644 index 0000000..c3c1df2 --- /dev/null +++ b/apps/web/src/components/landing/Index.tsx @@ -0,0 +1,139 @@ +import { FileCode2 } from "lucide-react"; +import { + FileItem, + FolderItem, + FolderPanel, + FolderTrigger, + Files, + SubFiles, +} from "../animate-ui/components/base/files"; +import { Bezel } from "./Bezel"; +import { Section } from "./Section"; + +/** + * What the reader lands on: the index, not the map. + * + * An atlas has an index and the file tree is it (UI_GUIDE §3.2), so the page + * shows one rather than describing it. The counts are the point -- a file's + * worth on this canvas is how many functions it holds, and that is the number + * the sidebar puts beside every path. + * + * The tree is this repository's own shape. A made-up `src/components/Button.tsx` + * would say nothing about a tool built to read a polyglot monorepo. + */ +function Count({ functions }: { functions: number }) { + return ( + + {functions} + + ); +} + +export function Index() { + return ( +
+
+
    +
  • + One file card at a time, with as + many function branches off it as you open. Collapsing a branch hides + its subtree without forgetting it. +
  • +
  • + ⌘K jumps to any function by name, + across every file in the repository, and lands the canvas on it. +
  • +
  • + The selection survives a reload: + repository, file and every branch you opened. Rebuilding a map by + hand after a refresh is not exploring. +
  • +
+ + {/* Capped: a file tree stretched to the full shell puts a filename and + its count at opposite ends of the screen. */} + + + + apps + + + + + web + + + } + > + App.tsx + + } + > + Canvas.tsx + + } + > + graph.ts + + + + + + api + + + } + > + routes.ts + + + + + + + + + services + + + + } + > + resolver.go + + } + > + extract.go + + + + + + +
+
+ ); +} diff --git a/apps/web/src/components/landing/Landing.test.tsx b/apps/web/src/components/landing/Landing.test.tsx new file mode 100644 index 0000000..6aad6f9 --- /dev/null +++ b/apps/web/src/components/landing/Landing.test.tsx @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, within } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { APP_ROUTE } from "@funcatlas/shared"; +import { CONFIDENCE, CONFIDENCE_ORDER } from "../../lib/confidence"; +import { Landing } from "./Landing"; + +// Smooth scrolling has nothing to assert in a document with no layout, and +// Lenis reaches for scroll APIs jsdom does not implement. +vi.mock("../../lib/useSmoothScroll", () => ({ useSmoothScroll: () => {} })); + +function renderLanding() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + , + ); +} + +beforeEach(() => { + vi.restoreAllMocks(); + window.history.replaceState(null, "", "/"); +}); + +describe("the landing page", () => { + it("asks for nothing from our API", () => { + // The page explains the product. Needing the backend up to do that would + // make it fail exactly when someone most needs to read it. + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockRejectedValue(new Error("offline")); + + renderLanding(); + + const ours = fetchSpy.mock.calls.filter( + ([input]) => !String(input).includes("api.github.com"), + ); + expect(ours).toEqual([]); + }); + + it("sends every call to action to the canvas", () => { + renderLanding(); + + const ctas = screen.getAllByRole("link", { name: /open the atlas/i }); + expect(ctas.length).toBeGreaterThan(1); + for (const cta of ctas) { + expect(cta).toHaveAttribute("href", APP_ROUTE); + } + }); + + it("names all three tiers and what each one means", () => { + renderLanding(); + + const tiers = screen + .getByRole("heading", { name: /certainty is the product/i }) + .closest("section"); + expect(tiers).not.toBeNull(); + + for (const tier of CONFIDENCE_ORDER) { + const { label, meaning } = CONFIDENCE[tier]; + expect(within(tiers as HTMLElement).getByText(label)).toBeInTheDocument(); + expect( + within(tiers as HTMLElement).getByText(meaning), + ).toBeInTheDocument(); + } + }); + + it("states the limits rather than leaving them to the docs", () => { + renderLanding(); + + // Public-repositories-only is the constraint a reader hits first, and + // finding it out after signing in is the experience this prevents. + expect(screen.getByText(/public repositories only/i)).toBeInTheDocument(); + // Said in the hero and again at the closing call to action -- a reader who + // scrolls past the first one still meets it before signing in. + expect(screen.getAllByText(/read:user/).length).toBeGreaterThan(1); + expect( + screen.getByText(/extracted, resolved within a file/i), + ).toBeInTheDocument(); + }); + + it("shows the index with a function count on every file", () => { + renderLanding(); + + const index = screen + .getByRole("heading", { name: /an atlas has an index/i }) + .closest("section"); + expect(index).not.toBeNull(); + + // The count is what the sidebar puts beside a path, and it is the reason + // the tree is worth showing at all rather than describing. + const files = within(index as HTMLElement).getAllByText(/\.(tsx?|go)$/); + expect(files.length).toBeGreaterThan(3); + expect(within(index as HTMLElement).getByText("23")).toBeInTheDocument(); + }); + + it("links to the source even when GitHub will not answer", async () => { + vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("rate limited")); + + renderLanding(); + + const links = await screen.findAllByRole("link", { + name: /funcatlas on github/i, + }); + expect(links[0]).toHaveAttribute( + "href", + expect.stringContaining("github.com"), + ); + }); +}); diff --git a/apps/web/src/components/landing/Landing.tsx b/apps/web/src/components/landing/Landing.tsx new file mode 100644 index 0000000..dc226c1 --- /dev/null +++ b/apps/web/src/components/landing/Landing.tsx @@ -0,0 +1,42 @@ +import { useMotionEnabled } from "../../lib/motion"; +import { useSmoothScroll } from "../../lib/useSmoothScroll"; +import { ClosingCta } from "./ClosingCta"; +import { Hero } from "./Hero"; +import { HowItWorks } from "./HowItWorks"; +import { Index } from "./Index"; +import { LandingFooter } from "./LandingFooter"; +import { LandingHeader } from "./LandingHeader"; +import { Languages } from "./Languages"; +import { Tiers } from "./Tiers"; + +/** + * The marketing surface, at `/`. + * + * It calls no API. The session is resolved inside the canvas route, not here, + * so this page renders whether or not the backend is up -- which is the least + * a page whose job is to explain the product can do. + * + * Smooth scrolling is mounted here rather than at the app root: the canvas + * treats the wheel as zoom, and a scroll library at the root would take those + * gestures away from it. + */ +export function Landing() { + useSmoothScroll(useMotionEnabled()); + + return ( +
+ + +
+ + + + + + +
+ + +
+ ); +} diff --git a/apps/web/src/components/landing/LandingFooter.tsx b/apps/web/src/components/landing/LandingFooter.tsx new file mode 100644 index 0000000..9e4af44 --- /dev/null +++ b/apps/web/src/components/landing/LandingFooter.tsx @@ -0,0 +1,32 @@ +import { cn } from "../../lib/cn"; +import { GITHUB_REPO_URL } from "../../lib/constants"; +import { LANDING_SHELL } from "./shell"; + +export function LandingFooter() { + return ( +
+
+ + 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..ccf3198 --- /dev/null +++ b/apps/web/src/components/landing/LandingHeader.tsx @@ -0,0 +1,37 @@ +import { cn } from "../../lib/cn"; +import { ThemeToggle } from "../ThemeToggle"; +import { GitHubStars } from "./GitHubStars"; +import { OpenAtlas } from "./OpenAtlas"; +import { LANDING_SHELL } from "./shell"; + +/** + * A chart's title block, not a navigation bar. + * + * Deliberately not sticky and deliberately not a floating pill: there is + * nowhere to navigate to -- no in-page anchors, three controls -- so a bar + * that follows the reader down the page would be encoding nothing. The closing + * section carries the call to action instead. + */ +export function LandingHeader() { + return ( +
+ + funcatlas + + +
+ + + + {/* Hidden on the narrowest widths, where the hero's own call to action + is already on screen and a second one crowds the wordmark out. */} + +
+
+ ); +} diff --git a/apps/web/src/components/landing/Languages.tsx b/apps/web/src/components/landing/Languages.tsx new file mode 100644 index 0000000..f7a2438 --- /dev/null +++ b/apps/web/src/components/landing/Languages.tsx @@ -0,0 +1,60 @@ +import { Bezel } from "./Bezel"; +import { Section } from "./Section"; + +/** + * Eight languages, in the two groups that are actually true of them. + * + * The grouping is the honest footnote, which is why there is no asterisk: past + * the ECMAScript family the parser extracts functions and calls and resolves + * only within a file. Saying "eight languages" and leaving that in the docs + * would be the kind of claim this product exists not to make. + */ +const GROUPS = [ + { + heading: "Resolved across files", + detail: + "Imports are followed, so a call reaches a definition in another file.", + languages: ["TypeScript", "TSX", "JavaScript", "JSX"], + }, + { + heading: "Extracted, resolved within a file", + detail: + "Every function and call site is charted. Cross-file resolution is not built yet.", + languages: ["Go", "Rust", "Python", "Java"], + }, +] as const; + +export function Languages() { + return ( +
+
+ {GROUPS.map((group) => ( + +

+ {group.heading} +

+

+ {group.detail} +

+ +
    + {group.languages.map((language) => ( +
  • + {language} +
  • + ))} +
+
+ ))} +
+
+ ); +} diff --git a/apps/web/src/components/landing/OpenAtlas.tsx b/apps/web/src/components/landing/OpenAtlas.tsx new file mode 100644 index 0000000..c040553 --- /dev/null +++ b/apps/web/src/components/landing/OpenAtlas.tsx @@ -0,0 +1,40 @@ +import { APP_ROUTE } from "@funcatlas/shared"; +import { ArrowRight } from "lucide-react"; +import { cn } from "../../lib/cn"; +import { Link } from "../../lib/router"; +import { buttonVariants } from "../ui/button"; + +/** + * The page's only call to action, and it says the same thing in both places it + * appears — the hero and the closing section. One name for one action, kept + * through the flow (UI_GUIDE §3.4). + * + * It goes to `/app` rather than straight to the OAuth endpoint: the sign-in + * card is the surface that carries "Sign in with GitHub" (§3.1), and sending a + * reader through it is how they learn the confidence legend before the canvas + * uses it. + */ +export function OpenAtlas({ + size = "lg", + className, +}: { + size?: "sm" | "lg"; + className?: string; +}) { + return ( + + Open the atlas + {/* The arrow rides in its own well, so the control reads as a thing with + a moving part rather than as text with a glyph after it. */} + + + + + ); +} diff --git a/apps/web/src/components/landing/Reveal.tsx b/apps/web/src/components/landing/Reveal.tsx new file mode 100644 index 0000000..6b4e7dd --- /dev/null +++ b/apps/web/src/components/landing/Reveal.tsx @@ -0,0 +1,36 @@ +import type { ReactNode } from "react"; +import { Fade } from "../animate-ui/primitives/effects/fade"; +import { useMotionEnabled } from "../../lib/motion"; + +/** + * A section arriving as it is scrolled to. Opacity only. + * + * No slide, no blur, no scale: the hero is the page's one orchestrated moment + * (UI_GUIDE §4), and a section that also moves turns that into scattered + * effects -- which is the most reliable tell of a generated design. + * + * `Fade` does not check `prefers-reduced-motion` itself, so the branch is + * here: reduced motion renders the content with no wrapper at all rather than + * a wrapper that animates instantly. + */ +export function Reveal({ + children, + className, + delay, +}: { + children: ReactNode; + className?: string; + delay?: number; +}) { + const animate = useMotionEnabled(); + + if (!animate) { + return
{children}
; + } + + return ( + + {children} + + ); +} diff --git a/apps/web/src/components/landing/Section.tsx b/apps/web/src/components/landing/Section.tsx new file mode 100644 index 0000000..94f8821 --- /dev/null +++ b/apps/web/src/components/landing/Section.tsx @@ -0,0 +1,69 @@ +import type { ReactNode } from "react"; +import type { ResolutionConfidence } from "@funcatlas/shared"; +import { cn } from "../../lib/cn"; +import { ConfidenceRule } from "../ConfidenceRule"; +import { Reveal } from "./Reveal"; +import { LANDING_SHELL } from "./shell"; + +/** + * One section of the landing page: a rule, an eyebrow, a heading, a lede, and + * the content. + * + * The rule is the page's structural device and it is not decoration -- its + * dash pattern is a confidence tier, and each section takes the tier that is + * true of it. The tiers section is solid, languages is dashed because support + * genuinely is partial past the ECMAScript family, and the closing section is + * dotted because what it states are the product's limits. A reader who has + * scrolled the page has read the notation three times before ever reaching the + * canvas. + * + * The generous padding is deliberate and belongs to this surface only + * (UI_GUIDE §3.1): the canvas is dense by nature, and marketing whitespace on + * a file tree is how a tool starts feeling like a brochure. + */ +export function Section({ + tier, + eyebrow, + title, + lede, + children, + className, +}: { + tier: ResolutionConfidence; + eyebrow: string; + title: string; + lede?: string; + children: ReactNode; + className?: string; +}) { + return ( +
+
+ + + +

+ {eyebrow} +

+ +

+ {title} +

+ + {lede === undefined ? null : ( +

+ {lede} +

+ )} +
+ + {children} +
+
+ ); +} diff --git a/apps/web/src/components/landing/Tiers.tsx b/apps/web/src/components/landing/Tiers.tsx new file mode 100644 index 0000000..31650ae --- /dev/null +++ b/apps/web/src/components/landing/Tiers.tsx @@ -0,0 +1,68 @@ +import type { ResolutionConfidence } from "@funcatlas/shared"; +import { cn } from "../../lib/cn"; +import { CONFIDENCE, CONFIDENCE_ORDER } from "../../lib/confidence"; +import { ConfidenceRule } from "../ConfidenceRule"; +import { Bezel } from "./Bezel"; +import { Section } from "./Section"; + +/** + * What produces each tier, in a sentence a reader can check against their own + * codebase. The tier's name and meaning come from `lib/confidence`, which the + * canvas legend also reads -- so this page cannot end up describing a scale + * the product does not draw. + */ +const CAUSE: Record = { + exact: + "The import was followed to a declaration, and only one function could be the target.", + name_match: + "A function with that name is in scope. Another one elsewhere may be the function actually called.", + unresolved: + "The call is real and its target is ambiguous: a barrel re-export, a default import, a path alias.", +}; + +export function Tiers() { + return ( +
+
    + {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, + because a wrong edge is read as fact and costs more than the missing one + it replaced. +

+
+ ); +} diff --git a/apps/web/src/components/landing/shell.ts b/apps/web/src/components/landing/shell.ts new file mode 100644 index 0000000..774159f --- /dev/null +++ b/apps/web/src/components/landing/shell.ts @@ -0,0 +1,13 @@ +/** + * The landing page's one content width. + * + * Written once because four surfaces share it -- the title block, the hero, + * every section and the footer -- and when they each carried their own max + * width the wordmark did not line up with the headline under it. + * + * Wide on purpose. A 64rem measure centred on a modern display reads as a + * column floating in a field of ground rather than as a page. Prose inside + * still caps itself at a readable measure; it is the layout that fills the + * screen, not the paragraphs. + */ +export const LANDING_SHELL = "mx-auto w-full max-w-[104rem] px-6 sm:px-10 lg:px-16"; diff --git a/apps/web/src/hooks/use-controlled-state.tsx b/apps/web/src/hooks/use-controlled-state.tsx new file mode 100644 index 0000000..f806fad --- /dev/null +++ b/apps/web/src/hooks/use-controlled-state.tsx @@ -0,0 +1,33 @@ +import * as React from 'react'; + +interface CommonControlledStateProps { + value?: T; + defaultValue?: T; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function useControlledState( + props: CommonControlledStateProps & { + onChange?: (value: T, ...args: Rest) => void; + }, +): readonly [T, (next: T, ...args: Rest) => void] { + const { value, defaultValue, onChange } = props; + + const [state, setInternalState] = React.useState( + value !== undefined ? value : (defaultValue as T), + ); + + React.useEffect(() => { + if (value !== undefined) setInternalState(value); + }, [value]); + + const setState = React.useCallback( + (next: T, ...args: Rest) => { + setInternalState(next); + onChange?.(next, ...args); + }, + [onChange], + ); + + return [state, setState] as const; +} diff --git a/apps/web/src/hooks/use-is-in-view.tsx b/apps/web/src/hooks/use-is-in-view.tsx new file mode 100644 index 0000000..9554e47 --- /dev/null +++ b/apps/web/src/hooks/use-is-in-view.tsx @@ -0,0 +1,29 @@ +// Generated by `npx shadcn add @animate-ui/...`, then edited: the import was +// `motion/react`, which is a second copy of Framer Motion beside the +// `framer-motion` this project locks. Same API, so retargeting is enough. +// `add --overwrite` silently reverts this -- see docs/UI_GUIDE.md §2. +import * as React from 'react'; +import { useInView, type UseInViewOptions } from 'framer-motion'; + +interface UseIsInViewOptions { + inView?: boolean; + inViewOnce?: boolean; + inViewMargin?: UseInViewOptions['margin']; +} + +function useIsInView( + ref: React.Ref, + options: UseIsInViewOptions = {}, +) { + const { inView, inViewOnce = false, inViewMargin = '0px' } = options; + const localRef = React.useRef(null); + React.useImperativeHandle(ref, () => localRef.current as T); + const inViewResult = useInView(localRef, { + once: inViewOnce, + margin: inViewMargin, + }); + const isInView = !inView || inViewResult; + return { ref: localRef, isInView }; +} + +export { useIsInView, type UseIsInViewOptions }; diff --git a/apps/web/src/lib/confidence.ts b/apps/web/src/lib/confidence.ts index 5d9bb9e..530300a 100644 --- a/apps/web/src/lib/confidence.ts +++ b/apps/web/src/lib/confidence.ts @@ -42,6 +42,11 @@ export interface ConfidencePresentation { * a class name built by concatenation and would purge it. Resolves through * a CSS variable, so it follows the active theme on its own. */ textClass: string; + /** For SVG we draw ourselves -- the landing page's hero graph. Same reason + * the literal is complete, and the same reason it beats `confidenceColor` + * here: a class follows the theme without the component subscribing to it. + * The canvas still needs the raw value; see `confidenceColor`. */ + strokeClass: string; /** What the tier is called in the interface. */ label: string; /** What it actually means, shown in the canvas legend. */ @@ -53,6 +58,7 @@ export const CONFIDENCE: Record = style: CONFIDENCE_STYLE.exact, strokeDasharray: DASH_ARRAY[CONFIDENCE_STYLE.exact], textClass: "text-confidence-exact", + strokeClass: "stroke-confidence-exact", label: "Exact", meaning: "Matched to this function.", }, @@ -60,6 +66,7 @@ export const CONFIDENCE: Record = style: CONFIDENCE_STYLE.name_match, strokeDasharray: DASH_ARRAY[CONFIDENCE_STYLE.name_match], textClass: "text-confidence-name", + strokeClass: "stroke-confidence-name", label: "Name match", meaning: "A function with this name is in scope, but it may not be the one called.", }, @@ -67,6 +74,7 @@ export const CONFIDENCE: Record = style: CONFIDENCE_STYLE.unresolved, strokeDasharray: DASH_ARRAY[CONFIDENCE_STYLE.unresolved], textClass: "text-confidence-unresolved", + strokeClass: "stroke-confidence-unresolved", label: "Unresolved", meaning: "The call is real, but which function it reaches could not be determined.", }, diff --git a/apps/web/src/lib/constants.ts b/apps/web/src/lib/constants.ts index 459c967..00b0576 100644 --- a/apps/web/src/lib/constants.ts +++ b/apps/web/src/lib/constants.ts @@ -28,6 +28,14 @@ export const THEME_STORAGE_KEY = "funcatlas-theme"; * complaint that put this here. */ export const UI_STORAGE_KEY = "funcatlas-ui"; +// --- Landing page --------------------------------------------------------- + +/** The repository the landing page links to and reads a star count from. + * Only the web app needs it, so it stays here rather than in the shared + * package. */ +export const GITHUB_REPO = "ARCoder181105/funcatlas"; +export const GITHUB_REPO_URL = `https://github.com/${GITHUB_REPO}`; + // --- Motion --------------------------------------------------------------- /** Milliseconds. Page-level motion in UI_GUIDE §4 is 400-600ms, and this moves diff --git a/apps/web/src/lib/get-strict-context.tsx b/apps/web/src/lib/get-strict-context.tsx new file mode 100644 index 0000000..be139dc --- /dev/null +++ b/apps/web/src/lib/get-strict-context.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; + +function getStrictContext( + name?: string, +): readonly [ + ({ + value, + children, + }: { + value: T; + children?: React.ReactNode; + }) => React.JSX.Element, + () => T, +] { + const Context = React.createContext(undefined); + + const Provider = ({ + value, + children, + }: { + value: T; + children?: React.ReactNode; + }) => {children}; + + const useSafeContext = () => { + const ctx = React.useContext(Context); + if (ctx === undefined) { + throw new Error(`useContext must be used within ${name ?? 'a Provider'}`); + } + return ctx; + }; + + return [Provider, useSafeContext] as const; +} + +export { getStrictContext }; diff --git a/apps/web/src/lib/github.ts b/apps/web/src/lib/github.ts new file mode 100644 index 0000000..4a8eb04 --- /dev/null +++ b/apps/web/src/lib/github.ts @@ -0,0 +1,31 @@ +import { useQuery } from "@tanstack/react-query"; +import { GITHUB_REPO } from "./constants"; + +/** + * The repository's star count. + * + * A bare `fetch` rather than `lib/api.ts`'s `request`: that helper carries + * our credentials and our error shape, and this is a public endpoint on + * someone else's origin. Sending a session cookie to GitHub would be the bug. + * + * Not retried and not required. GitHub rate-limits unauthenticated callers + * hard, so a failure here is ordinary -- the header falls back to a plain link + * to the repository, which is the part that actually matters. + */ +export function useGitHubStars() { + return useQuery({ + queryKey: ["github-stars", GITHUB_REPO], + retry: false, + staleTime: Infinity, + queryFn: async (): Promise => { + const res = await fetch(`https://api.github.com/repos/${GITHUB_REPO}`); + if (!res.ok) throw new Error(`github responded ${res.status}`); + + const body: unknown = await res.json(); + const count = (body as { stargazers_count?: unknown }).stargazers_count; + if (typeof count !== "number") throw new Error("github sent no star count"); + + return count; + }, + }); +} diff --git a/apps/web/src/lib/hero-graph.test.ts b/apps/web/src/lib/hero-graph.test.ts new file mode 100644 index 0000000..1b08ed5 --- /dev/null +++ b/apps/web/src/lib/hero-graph.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { CONFIDENCE_STYLE } from "@funcatlas/shared"; +import { HERO_ALT, HERO_EDGES, HERO_NODES, heroEdgePath, heroNode } from "./hero-graph"; + +describe("the hero fixture", () => { + it("names an existing node at both ends of every edge", () => { + const ids = new Set(HERO_NODES.map((node) => node.id)); + + for (const edge of HERO_EDGES) { + expect(ids).toContain(edge.from); + expect(ids).toContain(edge.to); + } + }); + + it("shows all three tiers", () => { + // The hero exists to teach the scale. Losing a tier to an edit would leave + // it teaching two thirds of it, and nothing else would fail. + const tiers = new Set(HERO_EDGES.map((edge) => edge.tier)); + + expect(tiers).toEqual(new Set(Object.keys(CONFIDENCE_STYLE))); + }); + + it("puts the one ghost node at the end of the unresolved edge", () => { + const ghosts = HERO_NODES.filter((node) => node.ghost === true); + const unresolved = HERO_EDGES.filter((edge) => edge.tier === "unresolved"); + + expect(ghosts).toHaveLength(1); + expect(unresolved.map((edge) => edge.to)).toEqual(ghosts.map((node) => node.id)); + }); + + it("draws a callee to the right of its caller", () => { + // The reveal sweeps left to right and is the only thing staggering the + // draw, so a callee placed left of its caller would appear before the call + // that reaches it. + for (const edge of HERO_EDGES) { + expect(heroNode(edge.to).depth).toBeGreaterThan(heroNode(edge.from).depth); + } + }); + + it("describes every tier it draws, for a reader who cannot see it", () => { + expect(HERO_ALT).toMatch(/exact/i); + expect(HERO_ALT).toMatch(/name match/i); + expect(HERO_ALT).toMatch(/resolved/i); + }); +}); + +describe("heroEdgePath", () => { + it("runs from the caller's right edge to the callee's left edge", () => { + const path = heroEdgePath({ from: "handleRequest", to: "parseBody", tier: "exact" }); + + // 20 + 132 wide, centred on 116 + 18; target starts at 214, centred on 42. + expect(path).toBe("M 152 134 C 183 134, 183 42, 214 42"); + }); + + it("refuses a node it does not have rather than drawing from NaN", () => { + expect(() => heroEdgePath({ from: "handleRequest", to: "nope", tier: "exact" })).toThrow( + /no node nope/, + ); + }); +}); diff --git a/apps/web/src/lib/hero-graph.ts b/apps/web/src/lib/hero-graph.ts new file mode 100644 index 0000000..db1b61e --- /dev/null +++ b/apps/web/src/lib/hero-graph.ts @@ -0,0 +1,110 @@ +import type { ResolutionConfidence } from "@funcatlas/shared"; + +/** + * The graph the landing page draws, and the geometry it is drawn with. + * + * A fixture, not a screenshot and not a fetch: the landing page talks to no + * API (see `App.tsx`). It is still a real drawing -- the same three edge + * styles, the same rule that ambiguity resolves to `unresolved`, and the ghost + * node from `docs/UI_GUIDE.md` §3.2 marking the edge of what was charted. + * + * The shape is a request handler, because that is the code a reader already + * has a mental model of. Every tier here is one a real repository produces: + * `logger.info` is a name match because more than one `info` is in scope, and + * `formatError` is unresolved because it arrives through a barrel re-export, + * which `docs/PARSING_STRATEGY.md` lists as a limit we do not guess past. + * + * Data and coordinates live here rather than in the component so the component + * is markup and this is testable. + */ + +export interface HeroNode { + id: string; + label: string; + /** Column index. Also the stagger index: the graph draws outward, so a + * node's depth is when it appears. */ + depth: number; + /** Top-left, in viewBox units. */ + x: number; + y: number; + /** + * A callee the resolver could not reach -- drawn at the map's edge, dotted + * and faded, labelled with the name the parser actually saw. Not an error + * and not hidden: the map showing its own boundary (UI_GUIDE §3.2). + */ + ghost?: boolean; +} + +export interface HeroEdge { + from: string; + to: string; + tier: ResolutionConfidence; +} + +/** + * Wide rather than tall: the hero sits beside the headline, and the graph + * reads left to right because that is the direction calls run. + * + * The height is measured off the drawing rather than picked -- 268 is the + * lowest node's baseline plus the ghost's caption plus a margin. A taller box + * scales the whole graph down to fit its own empty space. + */ +export const HERO_VIEWBOX = { width: 560, height: 268 } as const; + +/** One size for every node. Measured off `handleRequest` at mono 12px, which + * is the longest label here; a card narrower than its own name is the bug + * `graph-constants.ts` exists to avoid on the canvas. */ +export const HERO_NODE = { width: 132, height: 36, radius: 8 } as const; + +const COLUMN = [20, 214, 408] as const; + +export const HERO_NODES: HeroNode[] = [ + { id: "handleRequest", label: "handleRequest", depth: 0, x: COLUMN[0], y: 116 }, + { id: "parseBody", label: "parseBody", depth: 1, x: COLUMN[1], y: 24 }, + { id: "validate", label: "validate", depth: 1, x: COLUMN[1], y: 116 }, + { id: "loggerInfo", label: "logger.info", depth: 1, x: COLUMN[1], y: 208 }, + { id: "readStream", label: "readStream", depth: 2, x: COLUMN[2], y: 24 }, + { id: "formatError", label: "formatError", depth: 2, x: COLUMN[2], y: 170, ghost: true }, +]; + +export const HERO_EDGES: HeroEdge[] = [ + { from: "handleRequest", to: "parseBody", tier: "exact" }, + { from: "handleRequest", to: "validate", tier: "exact" }, + { from: "parseBody", to: "readStream", tier: "exact" }, + { from: "handleRequest", to: "loggerInfo", tier: "name_match" }, + { from: "validate", to: "formatError", tier: "unresolved" }, +]; + +/** What the graph says, for anyone who cannot see it. The tiers are named + * because they are the point, not the decoration. */ +export const HERO_ALT = + "A call graph of a request handler. handleRequest calls parseBody and validate as exact " + + "matches, and logger.info as a name match. validate calls formatError, which could not be " + + "resolved and is drawn at the edge of the map."; + +const byId = new Map(HERO_NODES.map((node) => [node.id, node])); + +export function heroNode(id: string): HeroNode { + const node = byId.get(id); + if (node === undefined) throw new Error(`hero graph has no node ${id}`); + return node; +} + +/** + * A cubic from the right edge of the caller to the left edge of the callee, + * with both control points on the horizontal midline. The same shape React + * Flow's bezier edge draws, so the hero and the canvas agree about what a call + * looks like. + */ +export function heroEdgePath(edge: HeroEdge): string { + const from = heroNode(edge.from); + const to = heroNode(edge.to); + + const x1 = from.x + HERO_NODE.width; + const y1 = from.y + HERO_NODE.height / 2; + const x2 = to.x; + const y2 = to.y + HERO_NODE.height / 2; + const mid = x1 + (x2 - x1) / 2; + + return `M ${x1} ${y1} C ${mid} ${y1}, ${mid} ${y2}, ${x2} ${y2}`; +} diff --git a/apps/web/src/lib/motion.ts b/apps/web/src/lib/motion.ts index 032fbf5..aa08b7d 100644 --- a/apps/web/src/lib/motion.ts +++ b/apps/web/src/lib/motion.ts @@ -17,6 +17,15 @@ export const DURATION = { page: 0.5, } as const; +/** + * A heavy decelerate, for the one thing on the landing page that draws itself. + * + * Not `easeOut`: a symmetric built-in curve reads as a tween, and the hero is + * meant to read as a pen being drawn across a chart -- fast away from rest, + * settling slowly. + */ +export const EASE_DRAW = [0.32, 0.72, 0, 1] as const; + /** Cards and edges use spring physics; routes and fades use easing (§4). */ const SPRING: Transition = { type: "spring", stiffness: 240, damping: 26 }; diff --git a/apps/web/src/lib/router.test.tsx b/apps/web/src/lib/router.test.tsx new file mode 100644 index 0000000..f3de814 --- /dev/null +++ b/apps/web/src/lib/router.test.tsx @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Link, navigate, usePath } from "./router"; + +function Path() { + return

at {usePath()}

; +} + +beforeEach(() => { + window.history.replaceState(null, "", "/"); +}); + +describe("usePath", () => { + it("follows a programmatic navigation", async () => { + render(); + expect(screen.getByText("at /")).toBeInTheDocument(); + + navigate("/app"); + + // pushState raises no event of its own, so this only passes because + // navigate() dispatches one. + expect(await screen.findByText("at /app")).toBeInTheDocument(); + }); + + it("follows the back button", async () => { + render(); + navigate("/app"); + await screen.findByText("at /app"); + + // jsdom moves history but does not fire popstate for it, so the event is + // raised here the way a browser would. + window.history.replaceState(null, "", "/"); + window.dispatchEvent(new PopStateEvent("popstate")); + + expect(await screen.findByText("at /")).toBeInTheDocument(); + }); +}); + +describe("Link", () => { + it("carries a real href, so it can be copied and opened in a new tab", () => { + render(Open the atlas); + + expect(screen.getByRole("link", { name: "Open the atlas" })).toHaveAttribute("href", "/app"); + }); + + it("handles a plain click itself, without a page load", async () => { + render( + <> + Open the atlas + + , + ); + + await userEvent.click(screen.getByRole("link", { name: "Open the atlas" })); + + expect(screen.getByText("at /app")).toBeInTheDocument(); + }); + + it("leaves a modifier-click to the browser", async () => { + render( + <> + Open the atlas + + , + ); + + // `setup()`, not the bare `userEvent.click`: each bare call is a fresh + // instance and forgets the held key, so the modifier never reaches the + // handler and the assertion passes without testing anything. + const user = userEvent.setup(); + + // Meta-click means "open in a new tab". Calling preventDefault here is the + // classic hand-rolled-router bug: the link stops working as a link. + await user.keyboard("{Meta>}"); + await user.click(screen.getByRole("link", { name: "Open the atlas" })); + await user.keyboard("{/Meta}"); + + expect(screen.getByText("at /")).toBeInTheDocument(); + }); + + it("still calls a handler the caller passed", async () => { + const onClick = vi.fn(); + render( + + Open the atlas + , + ); + + await userEvent.click(screen.getByRole("link", { name: "Open the atlas" })); + + expect(onClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/src/lib/router.tsx b/apps/web/src/lib/router.tsx new file mode 100644 index 0000000..3d23c74 --- /dev/null +++ b/apps/web/src/lib/router.tsx @@ -0,0 +1,70 @@ +import { useSyncExternalStore, type ComponentPropsWithoutRef, type MouseEvent } from "react"; + +/** + * Two routes, so this is the router. + * + * `/` is the landing page and `/app` is the canvas -- no parameters, no nested + * layouts, no data loading. TanStack Query already owns everything from the + * server and Zustand owns the canvas, so a router library would arrive with a + * data layer that has nothing to do and a config about as long as this file. + * + * The part worth getting right is `Link`: a hand-rolled one usually swallows + * the modifier-click that means "open this in a new tab". This one does not. + */ + +/** `pushState` raises no event of its own, so `navigate` raises this one. + * `popstate` covers the back and forward buttons; nothing covers our own + * pushes. */ +const NAVIGATED = "funcatlas:navigated"; + +function subscribe(onChange: () => void): () => void { + window.addEventListener("popstate", onChange); + window.addEventListener(NAVIGATED, onChange); + return () => { + window.removeEventListener("popstate", onChange); + window.removeEventListener(NAVIGATED, onChange); + }; +} + +function currentPath(): string { + return window.location.pathname; +} + +/** The active path. Read from `location` every time rather than mirrored into + * state, which is the copy that drifts when the back button moves one and not + * the other. */ +export function usePath(): string { + return useSyncExternalStore(subscribe, currentPath); +} + +export function navigate(to: string): void { + if (to === currentPath()) return; + + window.history.pushState(null, "", to); + // A push is a new page, not a new position on this one. Going back is left + // alone: the browser restores that scroll itself. + window.scrollTo(0, 0); + window.dispatchEvent(new Event(NAVIGATED)); +} + +type LinkProps = Omit, "href"> & { to: string }; + +/** + * A real anchor with a real `href`, so it is copyable, middle-clickable and + * announced as a link. The click handler claims only a plain left click -- + * anything holding a modifier means "somewhere else", and the browser is + * better at that than we are. + */ +export function Link({ to, onClick, ...rest }: LinkProps) { + const handleClick = (event: MouseEvent) => { + onClick?.(event); + + if (event.defaultPrevented || event.button !== 0) return; + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + + event.preventDefault(); + navigate(to); + }; + + return ; +} diff --git a/apps/web/src/lib/tokens.ts b/apps/web/src/lib/tokens.ts index 78b0981..825f489 100644 --- a/apps/web/src/lib/tokens.ts +++ b/apps/web/src/lib/tokens.ts @@ -35,68 +35,76 @@ export interface Palette { } /** - * Dark — "Ember". A near-black ground against a cool accent, which is the - * pairing the alternatives did not make: certainty reads as temperature, from - * cyan through apricot to a warm slate that is barely chromatic at all. + * The scale is a two-colour press: two spot inks and a neutral. * - * The ground was warmer — `#141210` on `#1d1a17` — and read as brown rather - * than as dark. It is near-black now, with the warmth kept as a trace (the - * hue is still there, the saturation is not) so the apricot and the slate - * still belong to it. Everything expressive stays in the confidence colours, - * which is where the meaning is. + * A printer with two plates and paper has exactly three things to say, which + * is exactly how many answers resolution has. Ultramarine is the fact, fired + * clay is the report, and the ash is what neither plate covered. The order is + * read as ink weight rather than as temperature, so it survives being drawn as + * a hairline on a canvas the reader is zoomed out of. + * + * This replaced Ember/Vellum, which ran cyan through apricot to a warm slate. + * The reasoning there was sound and the execution was fine; it was changed + * because cyan-on-near-black is the single most common developer-tool accent + * there is, and §7 of `docs/UI_GUIDE.md` is about not landing on the look + * every tool in this category already has. */ -const EMBER: Palette = { + +/** Dark — "Ultramarine". Near-black with a blue cast, so both inks sit on a + * ground that belongs to the same press run. */ +const ULTRAMARINE: Palette = { surface: { - DEFAULT: "#0a0a0b", - raised: "#131315", - border: "#26262a", + DEFAULT: "#0a0b10", + raised: "#12141c", + border: "#242839", }, ink: { - DEFAULT: "#f3ede5", - muted: "#9d9388", + DEFAULT: "#eceefa", + muted: "#8b90a8", }, confidence: { - exact: "#4cc9f0", - name: "#f2a154", + exact: "#6b8cff", + name: "#e0885a", /** - * A warm slate at about 9% saturation. Deliberately not red: an - * unresolved call is an honest admission that resolution could not reach - * the callee, not a failure -- colouring it as an error tells the user the - * opposite of what PRD §8 promises. It shares the ground's hue family, so - * it recedes into the map rather than standing out of it. + * Ash at about 11% saturation, in the ground's own hue family so it + * recedes into the map rather than standing out of it. Deliberately not + * red: an unresolved call is an honest admission that resolution could not + * reach the callee, not a failure -- colouring it as an error tells the + * user the opposite of what PRD §8 promises. */ - unresolved: "#8a7f73", + unresolved: "#767c92", }, - onAccent: "#04161c", + onAccent: "#080a14", }; /** - * Light — "Vellum". A drawing on cool paper rather than cream; cream with a - * serif display is the most common generated look there is, and the cool grey - * stays out of it. + * Light — "Letterpress". Cool paper rather than cream; cream with a serif + * display is the most common generated look there is, and the cool grey stays + * out of it. Both inks darken rather than changing hue, which is what a press + * would actually do on white stock. */ -const VELLUM: Palette = { +const LETTERPRESS: Palette = { surface: { - DEFAULT: "#f2f5f7", + DEFAULT: "#f1f2f7", raised: "#ffffff", - border: "#d9e1e8", + border: "#d8dbe6", }, ink: { - DEFAULT: "#0f1a24", - muted: "#566573", + DEFAULT: "#12141f", + muted: "#5a5f74", }, confidence: { - exact: "#0d7c6b", - name: "#b26a00", - /** Cool slate, dark enough to stay legible as a hairline on paper. */ - unresolved: "#75838f", + exact: "#2a44c4", + name: "#a55424", + /** Cool ash, dark enough to hold as a hairline on paper. */ + unresolved: "#71768a", }, onAccent: "#ffffff", }; export const PALETTE: Record = { - dark: EMBER, - light: VELLUM, + dark: ULTRAMARINE, + light: LETTERPRESS, }; /** The mode used when the browser states no preference. */ diff --git a/apps/web/src/lib/useSmoothScroll.ts b/apps/web/src/lib/useSmoothScroll.ts new file mode 100644 index 0000000..bcce8b7 --- /dev/null +++ b/apps/web/src/lib/useSmoothScroll.ts @@ -0,0 +1,21 @@ +import { useEffect } from "react"; +import Lenis from "lenis"; + +/** + * Smooth scrolling, on the landing page only. + * + * Mounted from `Landing` rather than from the app root on purpose: on the + * canvas the wheel already means zoom, and handing those events to a scroll + * library would fight React Flow for every gesture. + * + * `autoRaf` lets Lenis own its own frame loop, which is one less thing here to + * get wrong on unmount. + */ +export function useSmoothScroll(enabled: boolean): void { + useEffect(() => { + if (!enabled) return; + + const lenis = new Lenis({ autoRaf: true }); + return () => lenis.destroy(); + }, [enabled]); +} diff --git a/apps/web/src/test-setup.ts b/apps/web/src/test-setup.ts index 67885c1..c224c7c 100644 --- a/apps/web/src/test-setup.ts +++ b/apps/web/src/test-setup.ts @@ -53,6 +53,28 @@ if (typeof globalThis.ResizeObserver === "undefined") { } as unknown as typeof ResizeObserver; } +// The landing page's scroll reveals ask Framer whether an element is in view, +// and jsdom has no IntersectionObserver at all -- without this, mounting the +// page throws from inside an effect. +// +// A no-op that never reports an intersection, which is the honest answer: this +// document has no viewport for anything to be inside. The revealed content is +// still in the DOM the whole time, only at opacity 0, so presence is testable +// here and whether it actually appears is a browser check. +if (typeof globalThis.IntersectionObserver === "undefined") { + globalThis.IntersectionObserver = class { + readonly root = null; + readonly rootMargin = ""; + readonly thresholds: readonly number[] = []; + observe() {} + unobserve() {} + disconnect() {} + takeRecords(): IntersectionObserverEntry[] { + return []; + } + } as unknown as typeof IntersectionObserver; +} + // React Flow reads the pane's transform through DOMMatrixReadOnly when it // measures a node, and jsdom has no implementation. It only started throwing // once `lib/graph.ts` stopped pre-declaring node dimensions -- before that diff --git a/docs/UI_GUIDE.md b/docs/UI_GUIDE.md index 28d744b..765b961 100644 --- a/docs/UI_GUIDE.md +++ b/docs/UI_GUIDE.md @@ -25,39 +25,45 @@ Every value lives in `apps/web/src/lib/tokens.ts`, once per theme. Nothing is ha component — `grep -rE '#[0-9a-fA-F]{6}' apps/web/src --include=*.tsx` returns nothing, and that grep is the check. -**Dark — "Ember."** A near-black ground against a cool accent, which is the pairing the alternatives -did not make. +**The scale is a two-colour press.** Two spot inks and a neutral: a printer with two plates and +paper has exactly three things to say, which is exactly how many answers resolution has. Ultramarine +is the fact, fired clay is the report, and the ash is what neither plate covered. Read as ink weight +rather than as temperature, so it survives being drawn as a hairline on a canvas the reader is +zoomed out of. -**Revised in B8.** The ground was `#141210` on `#1d1a17` — a coffee undertone that read as brown -rather than as dark, which is not what a reader means when they ask for dark mode. It is near-black -now, with the warmth kept as a trace of hue and none of the saturation, so the apricot and the slate -still belong to the ground they sit on. Everything expressive stays in the confidence colours, which -is where the meaning is. +**Revised after the landing-page branch.** This replaced Ember/Vellum, which ran cyan through +apricot to a warm slate. The reasoning there was sound and the execution was fine; it was changed +because cyan-on-near-black is the single most common developer-tool accent there is, and §7 is about +not landing on the look every tool in this category already has. + +**Dark — "Ultramarine."** Near-black with a blue cast, so both inks sit on a ground that belongs to +the same press run. | Role | Token | Value | Why | |---|---|---|---| -| Ground | `surface` | `#0a0a0b` | Near-black. A trace of hue, no saturation to speak of. | -| Raised | `surface.raised` | `#131315` | Panels, cards, the sidebar. | -| Rule | `surface.border` | `#26262a` | Hairlines and card edges. | -| Ink | `ink` | `#f3ede5` | Warm off-white, matched to the ground's temperature. | -| Ink, quiet | `ink.muted` | `#9d9388` | Secondary labels, counts. | -| `exact` | `confidence.exact` | `#4cc9f0` | Cool cyan against a warm ground — the strongest separation available. | -| `name_match` | `confidence.name` | `#f2a154` | Apricot. Reported, not verified. | -| `unresolved` | `confidence.unresolved` | `#8a7f73` | Warm slate at ~9% saturation. Shares the ground's hue family, so it recedes into the map. | - -**Light — "Vellum."** A drawing on cool paper. Deliberately not cream: cream with a serif display is -the single most common generated look there is (§7). +| Ground | `surface` | `#0a0b10` | Near-black, a trace of blue, no saturation to speak of. | +| Raised | `surface.raised` | `#12141c` | Panels, cards, the sidebar. | +| Rule | `surface.border` | `#242839` | Hairlines and card edges. | +| Ink | `ink` | `#eceefa` | Off-white, matched to the ground's cast. | +| Ink, quiet | `ink.muted` | `#8b90a8` | Secondary labels, counts. | +| `exact` | `confidence.exact` | `#6b8cff` | Ultramarine. The first plate: verified. | +| `name_match` | `confidence.name` | `#e0885a` | Fired clay. The second plate: reported, not verified. | +| `unresolved` | `confidence.unresolved` | `#767c92` | Ash at ~11% saturation. Shares the ground's hue family, so it recedes into the map. | + +**Light — "Letterpress."** Cool paper, deliberately not cream: cream with a serif display is the +single most common generated look there is (§7). Both inks darken rather than changing hue, which is +what a press would do on white stock. | Role | Token | Value | Why | |---|---|---|---| -| Ground | `surface` | `#f2f5f7` | Cool paper. | +| Ground | `surface` | `#f1f2f7` | Cool paper. | | Raised | `surface.raised` | `#ffffff` | Panels, cards, the sidebar. | -| Rule | `surface.border` | `#d9e1e8` | Hairlines and card edges. | -| Ink | `ink` | `#0f1a24` | Near-black with a blue cast, matched to the paper. | -| Ink, quiet | `ink.muted` | `#566573` | Secondary labels, counts. | -| `exact` | `confidence.exact` | `#0d7c6b` | Deep teal — the cool end of the same scale. | -| `name_match` | `confidence.name` | `#b26a00` | Burnt amber. | -| `unresolved` | `confidence.unresolved` | `#75838f` | Cool slate, dark enough to hold as a hairline on paper. | +| Rule | `surface.border` | `#d8dbe6` | Hairlines and card edges. | +| Ink | `ink` | `#12141f` | Near-black with a blue cast, matched to the paper. | +| Ink, quiet | `ink.muted` | `#5a5f74` | Secondary labels, counts. | +| `exact` | `confidence.exact` | `#2a44c4` | Ultramarine, darkened for white stock. | +| `name_match` | `confidence.name` | `#a55424` | Fired clay, darkened the same way. | +| `unresolved` | `confidence.unresolved` | `#71768a` | Cool ash, dark enough to hold as a hairline on paper. | Two rules bind both palettes, and `confidence.test.ts` enforces them: @@ -167,16 +173,45 @@ A single centred card: wordmark, one line saying what the tool does, the confide The legend earns its place here where a background texture did not: it is the notation the canvas is about to use, and reading it once beats decoding it later. -**The marketing landing page gets its own PR, still unopened.** It is a second full surface that no -phase's exit test touches, so it has never belonged in a phase review. It was requested during 3b -and named the next branch after that gate; Phases 4 and 5 went first, so it is now overdue rather -than upcoming. It follows §1 like everything else. - -The landing page is the one surface that takes the maximal spatial treatment: section padding at -`py-24` and above, nested double-bezel cards, and a hero that is a live drawing graph rather than a -screenshot. The canvas is dense by nature and does not; matching complexity to the surface is the -point, and applying marketing whitespace to a file tree is how a tool starts feeling like a -brochure. +**The marketing landing page shipped on the `landing-page` branch.** It is a second full surface +that no phase's exit test touches, so it never belonged in a phase review. Requested during 3b and +named the next branch after that gate; Phases 4 and 5 went first. + +It is the one surface that takes the maximal spatial treatment: section padding at `py-24` and +above, nested double-bezel cards, and a hero that is a live drawing graph rather than a screenshot. +The canvas is dense by nature and does not; matching complexity to the surface is the point, and +applying marketing whitespace to a file tree is how a tool starts feeling like a brochure. + +### 3.1a Landing page (`/`) · the `landing-page` branch + +`apps/web/src/components/landing/`. Title block, hero, tiers, pipeline, index, coverage, closing +call to action, footer. + +- **Routing.** `/` is the landing page and `/app` is the canvas; anything else falls to the landing + page, and there is no 404. `lib/router.tsx` is thirty-odd lines over `pushState` rather than a + router library: two static routes, no parameters, no loaders. `useSession` lives behind `/app`, so + **the landing page issues no request to our API and renders with the backend down.** +- **The structural device is the product's notation.** Each section rule is a confidence tier's dash + pattern, and each section takes the tier that is true of it — solid over resolution and the + pipeline, dashed over coverage because support past the ECMAScript family genuinely is partial, + dotted over the limits. `ConfidenceRule` draws it and the legend uses the same component. +- **One colour rule.** No colour appears that does not carry its canvas meaning. The accent is the + hue that already means "known"; clay appears only on a name match, ash only on unresolved. +- **The hero is plain SVG, not React Flow**, and the draw is a mask sweeping across rather than an + animated `pathLength` — that writes an inline `stroke-dasharray` and flattens all three tiers into + one pattern. `HeroGraph.test.tsx` asserts three distinct dash values for exactly that reason. + The graph carries a ghost node: the signature from §3.2, leading with what the tool cannot do. +- **Smooth scrolling is mounted from `Landing` only** (Lenis). At the app root it would take the + wheel away from the canvas, where the wheel means zoom. +- **What it deliberately is not:** no sticky bar and no floating glass pill (there is nowhere to + navigate to), no bento grid, no gradient mesh, no glow, no backdrop blur. §1.3 and §7. + +**Installed, not written.** Three animate-ui pieces via the shadcn CLI — `effects/fade` for the +section reveals, `texts/sliding-number` for the star count, `components-base-files` for the index +tree. Every generated file was edited and says so at the top, because `add --overwrite` reverts it +silently: they ship importing `motion/react` and `@base-ui-components/react`, which are second +copies of the `framer-motion` and `@base-ui/react` this project locks, and the files component's +git-status slot carried hardcoded green/amber/red that became a function count instead. ### 3.2 Canvas explorer (authenticated) - **Sidebar — the index.** An atlas has an index, and the file tree is it. Collapsible, directories @@ -244,7 +279,7 @@ away. - Desktop-first (it's a power-user tool). The canvas is not a phone surface. - Canvas toolbars collapse to icon-only on narrow widths. -- The sign-in card and, when it exists, the landing page must work on mobile. +- The sign-in card and the landing page must work on mobile. ## 5.1 Quality floor @@ -259,8 +294,8 @@ reachable tab order through tree, palette and canvas, and `prefers-reduced-motio - Custom theming UI, saved layouts/perspectives. **No longer deferred.** The light theme shipped in Phase 3b alongside the dark one — both palettes -are in §1.1 and both are enforced by `confidence.test.ts`. The marketing landing page moved from -"post-MVP" to a branch of its own, which has not been opened yet (§3.1). +are in §1.1 and both are enforced by `confidence.test.ts`. The marketing landing page shipped on the +`landing-page` branch (§3.1a). ## 7. What this must not look like @@ -268,7 +303,7 @@ Kept explicit, because the failure mode here is converging on a look that reads regardless of subject. Three clusters to stay out of: 1. Warm cream ground (near `#F4F1EA`), high-contrast serif display, terracotta accent. **This is why - the light theme is cool paper `#f2f5f7` and not cream.** + the light theme is cool paper `#f1f2f7` and not cream.** 2. Near-black ground with one bright acid-green or violet accent. **The tokens this project shipped with — `#0b0d12` plus `#7c5cff` — were exactly this.** 3. Broadsheet layout: hairline rules, zero border-radius, dense newspaper columns. @@ -289,3 +324,9 @@ Recorded so they are not re-proposed as if new, and not treated as mistakes. which was cut in B8. - **Space Grotesk** as the display face. Dropped because it appears on every "reads as AI-generated" list, this document's §7 included. +- **Ember / Vellum** (near-black + cyan `#4cc9f0` / apricot / warm slate; cool paper + deep teal). + Shipped through Phases 3b to 5 and replaced on the landing-page branch. Nothing was wrong with it + in isolation: it passed both palette rules and read clearly. It went because cyan on near-black is + the accent every developer tool already uses, which §7 exists to keep us off. The *structure* it + established survived intact -- three tiers, one accent doing double duty, `unresolved` quiet and + achromatic -- and Ultramarine/Letterpress only changed the inks. diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index e2c47d2..fc12ebe 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -1,5 +1,15 @@ /** Every shared literal used by both the API and the web app. */ +/** + * Where the canvas lives in the web app. + * + * Shared because both sides act on it: the web app routes on it, and the OAuth + * callback redirects there rather than to `/`, which is the marketing landing + * page -- a freshly signed-in user landing on marketing copy is the bug this + * constant exists to prevent. + */ +export const APP_ROUTE = "/app"; + /** Mirrors the CHECK constraint on edges.resolution_confidence. */ export const RESOLUTION_CONFIDENCE = ["exact", "name_match", "unresolved"] as const; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 00f18ee..3c3fefe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -117,6 +117,9 @@ importers: framer-motion: specifier: ^11.18.2 version: 11.18.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + lenis: + specifier: ^1.3.26 + version: 1.3.26(react@19.2.7) lucide-react: specifier: ^0.469.0 version: 0.469.0(react@19.2.7) @@ -132,6 +135,9 @@ importers: react-resizable-panels: specifier: ^4.12.2 version: 4.12.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react-use-measure: + specifier: ^2.1.7 + version: 2.1.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7) reactflow: specifier: ^11.11.4 version: 11.11.4(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -3208,6 +3214,20 @@ packages: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} + lenis@1.3.26: + resolution: {integrity: sha512-s/xTCZCxTFvHbAN1OzuhNaN5YPJH2ail0XAkctKW1b+RUAG4nUL5UHLXwNko1h8aEeT2jspBXegMgPJd8zcuag==} + peerDependencies: + '@nuxt/kit': '>=3.0.0' + react: '>=17.0.0' + vue: '>=3.0.0' + peerDependenciesMeta: + '@nuxt/kit': + optional: true + react: + optional: true + vue: + optional: true + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -3786,6 +3806,15 @@ packages: '@types/react': optional: true + react-use-measure@2.1.7: + resolution: {integrity: sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==} + peerDependencies: + react: '>=16.13' + react-dom: '>=16.13' + peerDependenciesMeta: + react-dom: + optional: true + react@19.2.7: resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} engines: {node: '>=0.10.0'} @@ -7477,6 +7506,10 @@ snapshots: kleur@4.1.5: {} + lenis@1.3.26(react@19.2.7): + optionalDependencies: + react: 19.2.7 + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -8024,6 +8057,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + react-use-measure@2.1.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + react: 19.2.7 + optionalDependencies: + react-dom: 19.2.7(react@19.2.7) + react@19.2.7: {} reactflow@11.11.4(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):