From 57f3e54dfca92ac84b7ad1a9b96362aa7985163f Mon Sep 17 00:00:00 2001 From: Aditya Rana Date: Sat, 29 Aug 2026 10:21:15 +0530 Subject: [PATCH 1/9] correct the parser isolation claim `repos/register.ts` runs the parser with execFile from the queue worker. It is a plain child process carrying the worker's network, filesystem and user. `--network none`, the read-only rootfs and the dropped capabilities apply under `make parser-isolated` and nowhere else. Five documents said otherwise. docs/SECURITY.md ticked "clone/parse runs in an isolated container" and "parser has no outbound network access" from Phase 1 onward. CLAUDE.md said isolation "was built in Phase 1, not deferred". ARCHITECTURE.md's diagram labelled the parser box "network none". PLAN.md said Phase 1 built it to avoid a later retrofit. The landing page told readers their repository is cloned into a sandbox with no network and no capabilities -- and that page is about to be deployed. The claim is corrected rather than the code. Both fixes cost more than the gap: shelling out to `docker run` needs the Docker socket mounted into the worker, which grants root-equivalent host control and is a worse property than the one it buys; namespaces, seccomp or bubblewrap avoids that but is Linux-only and a project of its own. R38 records the choice and what would change it. What survives is enforced on every path, because it lives in the parser rather than around it: symlinks hard-fail, files over 1 MB are skipped, file count and depth are capped, the clone is depth-one with credential prompts disabled, no repo script is ever invoked, and the checkout is removed on the failure path. The landing page now leads with the strongest claim that is actually true -- nothing in the repository is executed -- instead of the one that was not. Two things worth keeping from how this hid for four phases. A checkbox ticked against a harness says nothing about the product, and the harness runs with `--no-deps`, which is also why nobody hit the `parser` service declaring both `network_mode: none` and a `depends_on` on Postgres. And the TOCTOU deferral in the same checklist had leaned on this claim inside its own justification, so one false statement had already propagated into a second decision. --- CLAUDE.md | 13 ++++-- PLAN.md | 4 ++ README.md | 5 ++- .../web/src/components/landing/HowItWorks.tsx | 4 +- docs/ARCHITECTURE.md | 8 ++-- docs/RISKS.md | 6 +++ docs/SECURITY.md | 44 ++++++++++++++++--- 7 files changed, 69 insertions(+), 15 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c1f7903..26a8249 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,9 +110,16 @@ No GORM. No Prisma. No full ORM on the Go side. ## Locked product decisions GitHub OAuth from day one. Webhook-driven incremental updates in the MVP. Function-name search in -the MVP. Excalidraw annotation and LSP resolution are out. Parser isolation was built in Phase 1, -not deferred: `--network none`, read-only rootfs, non-root, no capabilities, symlinks hard-fail, -files over 1 MB skipped. +the MVP. Excalidraw annotation and LSP resolution are out. + +**Parser isolation: the harness was built in Phase 1, the product does not use it.** `--network +none`, read-only rootfs, non-root and dropped capabilities apply under `make parser-isolated` and +nowhere else — `repos/register.ts` runs the binary with `execFile` from the queue worker, so on +`make start` and in any composed stack it is a plain child process. What *is* enforced on every +path, because it lives in the parser rather than around it: symlinks hard-fail, files over 1 MB +skipped, file-count and depth caps, a `--depth 1` clone with credential prompts disabled, and no +repo scripts ever invoked. See R38 and `docs/SECURITY.md` before quoting the sandbox as a +guarantee. ## Conventions that bite if ignored diff --git a/PLAN.md b/PLAN.md index 15e9748..dedfb48 100644 --- a/PLAN.md +++ b/PLAN.md @@ -33,6 +33,10 @@ Given a local repo path, emit a correct intermediate representation for TypeScri no UI. Isolation is built here rather than deferred, because retrofitting a sandbox around a parser that already assumes host filesystem access is far more expensive than building it in. +> **Corrected later.** What Phase 1 built was the isolation *harness* — the `parser` compose service +> and `make parser-isolated`. The product spawns the binary with `execFile` and never runs it inside +> that container, so the retrofit this paragraph set out to avoid is still owed. R38. + Delivered: - `internal/clone` — local path, or `git clone --depth 1`; never runs the repo's install or build scripts. diff --git a/README.md b/README.md index 598ea96..edc2057 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ actually changed. | Phase | Scope | State | |---|---|---| | 0 | Monorepo bootstrap, migrations, CI | done | -| 1 | Go + tree-sitter parser, sandbox hardening | done | +| 1 | Go + tree-sitter parser, input hardening | done | | 2 | Postgres persistence, call resolution | done | | 3a | GitHub OAuth, Redis sessions, the graph API | done | | 3b | React Flow canvas, search UI | done | @@ -30,7 +30,8 @@ Phase-by-phase detail is in [`PLAN.md`](PLAN.md); the current chunk list is in ## How it works -1. **Clone** the target repo into an isolated, network-less, read-only container. +1. **Clone** the target repo, depth one, over public HTTPS. Nothing in it is ever executed — no + install, build or test scripts — because parsing only reads text. 2. **Parse** every `.ts`/`.tsx` file with tree-sitter; extract function definitions, call sites, and imports into a Go-native intermediate representation. 3. **Resolve** each call to a definition — same file, then imported symbol, then package fallback — diff --git a/apps/web/src/components/landing/HowItWorks.tsx b/apps/web/src/components/landing/HowItWorks.tsx index c121d5c..0d1dc97 100644 --- a/apps/web/src/components/landing/HowItWorks.tsx +++ b/apps/web/src/components/landing/HowItWorks.tsx @@ -7,8 +7,8 @@ import { Section } from "./Section"; */ 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: "Clone, without running anything", + body: "Nothing from the repository is executed: no install, no build, no test scripts. Parsing only reads text. The clone is depth-one over public HTTPS, symlinks fail the run rather than being followed, files over 1 MB are skipped, and the checkout is deleted afterwards, including when the parse fails.", }, { title: "Extract", diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4cfce1f..40bc429 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -7,9 +7,9 @@ GitHub repo URL │ ▼ ┌──────────────┐ ┌──────────────────┐ ┌──────────────────┐ -│ Clone step │──▶ │ Parser Worker │──▶ │ Resolver │ -│ (network on, │ │ (Go+tree-sitter, │ │ (name and scope │ -│ isolated) │ │ network none) │ │ matching) │ +│ Clone step │──▶ │ Parser │──▶ │ Resolver │ +│ (depth one, │ │ (Go+tree-sitter, │ │ (name and scope │ +│ no scripts) │ │ read only) │ │ matching) │ └──────────────┘ └──────────────────┘ └──────────────────┘ │ ▼ @@ -85,6 +85,8 @@ Job queue ──▶ diff changed files ──▶ re-parse only those ──▶ r - **The worker is a Node process that spawns the Go binary**, exactly as registration used to. The parser gains no Redis dependency and stays a CLI that takes a path and writes to Postgres — which is also what keeps `make go-run` and the container's `--network none` check working unchanged. + Note what that does *not* say: the worker spawns the binary with `execFile`, so the container's + constraints apply to `make parser-isolated` and not to the running product. R38. An earlier draft of this document said the worker "communicates only through the job queue and Postgres"; it does not, and reimplementing BullMQ's job protocol in Go to make that true would have bought nothing. diff --git a/docs/RISKS.md b/docs/RISKS.md index 6f4a45c..c54ff2e 100644 --- a/docs/RISKS.md +++ b/docs/RISKS.md @@ -62,6 +62,12 @@ Nothing outstanding -- R19 through R22 and R26 through R29 all closed; see Decid | **R36** | **A test can measure the boundary with the function that defines it.** The Phase 5 exit test first compared `ResolutionGroup(caller)` with `ResolutionGroup(callee)` -- and passed with `ResolutionGroup` returning a constant, because both sides moved together. | Found by breaking the function on purpose and watching nothing fail. The assertions now compare language names from a literal set written in the test file. The same shape is worth suspecting anywhere a test derives its expectation from the code under test -- and the fixture had a second version of the problem: every file defined `helper`, so ambiguity answered `unresolved` whether the partition worked or not. It now also carries names defined in exactly one language. | | **R37** | **Six languages share one resolver, and only TypeScript's rules are modelled.** Same-file resolution is language-agnostic and stays `exact`; everything else outside the ECMAScript family is `name_match` or `unresolved`. | Deliberate, and the reason Phase 5 was scoped to extraction (`PLAN.md`). The risk is drift: a later change that widens a rule for one language widens it for all six, because there is one `resolve`. The guard is `TestPolyglot_CrossFileIsNeverExactOutsideECMAScript`, which fails the moment any non-ECMAScript language starts answering confidently across files. | +### Found while planning NFR-4 + +| | Risk | Notes | +|---|---|---| +| **R38** | **The parser sandbox is real, tested, and not used by the product.** `docs/SECURITY.md` ticked "clone/parse runs in an isolated container" and "parser has no outbound network access" from Phase 1 onward, `CLAUDE.md` said isolation "was built in Phase 1, not deferred", and the landing page told readers a repository is cloned into a sandbox with no network and no capabilities. All true of `make parser-isolated` and of nothing else: `repos/register.ts` runs the parser with `execFile(env.PARSER_BIN, ...)` from the queue worker, so on `make start` and in any composed stack it is a plain child process carrying the worker's network, filesystem and user. | Found by reading the spawn rather than the checklist. **The claim is corrected rather than the code**, because both fixes cost more than the gap: shelling out to `docker run` needs the Docker socket mounted into the worker, which grants root-equivalent host control — a worse property than the one it buys — and namespaces/seccomp/bubblewrap avoids that but is Linux-only and a project of its own. What survives is enforced everywhere, because it lives *in* the parser rather than around it: symlinks hard-fail, size/count/depth caps, a `--depth 1` clone with credential prompts disabled, no repo scripts executed, and the clone removed on the failure path too. Two lessons worth more than the fix. A checkbox ticked against a harness says nothing about the product — the harness passes `--no-deps`, which is also why nobody hit the `parser` service's `network_mode: none` / `depends_on` contradiction. And the deferral in the TOCTOU checklist item had quietly leaned on this claim inside its own justification, so one false statement had already propagated into a second decision. | + --- ## Deferred until after the MVP diff --git a/docs/SECURITY.md b/docs/SECURITY.md index d9d9ba5..b6b7af5 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -7,10 +7,44 @@ This project clones and reads arbitrary user-supplied repositories. That's a rea - **Parsing never requires execution.** Tree-sitter reads file contents and produces a syntax tree — it does not need `npm install`, `pip install`, build scripts, or any other code from the target repo to run. The clone/parse pipeline should never invoke the repo's own install/build/test scripts. - **Isolate the clone + parse step.** Even without deliberately executing the repo's code, git hooks and certain tooling can trigger unexpected behavior on clone/checkout. Run cloning and parsing inside a container at minimum; a microVM (e.g. Firecracker-style isolation) is a stronger option if handling many untrusted third-party repos at scale. - **Mount only what's needed.** The parser process should only have access to the cloned repo's directory — never the host filesystem, credentials, or other users' cloned repos. -- **No network access from the parse step.** The parser doesn't need outbound network access to do its job; blocking it removes a whole class of potential exfiltration or supply-chain risk from a malicious repo. This is enforced as a **hard runtime constraint** (`docker run --network none`, read-only mount, dropped capabilities) — not just a note. +- **No network access from the parse step.** The parser doesn't need outbound network access once the clone is done; blocking it removes a whole class of potential exfiltration or supply-chain risk from a malicious repo. **This is a goal, not something the running product currently enforces** — see "Where the isolation actually applies" below before relying on it. - **Auth.** GitHub OAuth for user login. The scope is `read:user`, not `repo` -- GitHub OAuth apps have no read-only repository scope, and `repo` grants write access to every private repository the user can reach (R26). Private repositories are therefore out of scope until a phase needs them. Webhook payloads must be signature-verified (GitHub's HMAC signature) before being trusted and enqueued. - **Tenant isolation.** If this ever serves more than one user, cloned repos and parse jobs must not share state, disk, or memory across users/repos — treat each clone as its own isolated workspace, cleaned up after use. +## Where the isolation actually applies + +Read this before quoting anything above as a guarantee. **The container constraints are real and +tested, and the running product does not use them.** + +There are two ways the parser runs, and only one is sandboxed: + +| Path | How it runs | Sandboxed? | +|---|---|---| +| `make parser-isolated` | `docker compose run --rm --no-deps parser` against the `parser` service, which sets `network_mode: "none"`, `read_only: true`, `cap_drop: [ALL]` and a non-root user | **Yes.** This is the harness the constraints were written for. | +| The product, on every path | `apps/api/src/repos/register.ts` calls `execFile(env.PARSER_BIN, [...])` from the queue worker | **No.** A plain child process of the worker, with the worker's network, filesystem and user. | + +So `--network none`, the read-only rootfs and the dropped capabilities apply when you deliberately +invoke the harness, and at no other time. `make start` does not use them, and neither would a +composed stack — `docker-compose.yml`'s `parser` service is the harness, which is why it can carry +`network_mode: "none"` and a `depends_on` on Postgres without anyone noticing the contradiction. + +**What is genuinely enforced on every path**, because it lives in the parser rather than around it +(`services/parser/internal/security`, covered by `security` package tests): + +- Symlinks hard-fail the run; every path is bounded to the clone root. +- Files over `PARSER_MAX_FILE_BYTES` (1 MB) are skipped, and `PARSER_MAX_FILES` / + `PARSER_MAX_DEPTH` / `PARSER_SKIP_PATHS` cap the walk. +- The clone is `git clone --depth 1` with `GIT_TERMINAL_PROMPT=0` and empty `GIT_ASKPASS` / + `SSH_ASKPASS`, so a private or missing repository fails immediately instead of blocking on a + credential prompt (R32). +- The repo's own install, build and test scripts are never invoked. Parsing does not execute code. +- The clone is removed afterwards, on the failure path too. + +Closing the gap is **R38**. The two routes both cost something: having the worker shell out to +`docker run` means mounting the Docker socket into it, which grants root-equivalent control of the +host and trades one security property for a worse one; doing it with namespaces, seccomp or +bubblewrap avoids that but is Linux-only and a project in its own right. + ## Concrete input hardening (enforced in the clone/parse step) - **Symlink / path-traversal guard.** Never follow symlinks. Resolve every file path and reject it unless it is strictly inside the clone root (rejects `../../etc/passwd` and symlinks pointing outside the repo). This applies to both parsing and to serving raw source. @@ -26,10 +60,10 @@ This project clones and reads arbitrary user-supplied repositories. That's a rea ## Checklist before handling real users' private repos -- [x] Clone/parse runs in an isolated container, not the host running the API -- [x] No install/build scripts from the target repo are ever invoked -- [x] Parser process has no outbound network access (`--network none`, read-only mount, dropped caps) -- [ ] Symlink / path-traversal escapes are checked — **path validation done; descriptor-based TOCTOU protection deferred again in Phase 4.** `Walk` hard-fails on any symlink and `ContainsRoot` bounds every path, so an escape needs a directory swapped between the check and the open. That is a real race and a narrow one: the parser reads a checkout it made itself, seconds earlier, in a container with no network and no capabilities. Revisit when it parses a tree someone else can write to. +- [ ] Clone/parse runs in an isolated container, not the host running the API — **the container exists and is tested; the product does not use it.** `make parser-isolated` runs the parser under `network_mode: "none"`, a read-only rootfs, `cap_drop: ALL` and a non-root user. The queue worker runs the same binary through `execFile`, in its own process space. Ticked from Phase 1 until the landing-page branch, on the strength of the harness rather than of the running path. R38. +- [x] No install/build scripts from the target repo are ever invoked — parsing reads file contents into a syntax tree and nothing else. This one holds on every path, container or not, because it is a property of tree-sitter rather than of the sandbox. +- [ ] Parser process has no outbound network access (`--network none`, read-only mount, dropped caps) — **only under `make parser-isolated`.** Same gap as the box above, and note the parser needs network for `git clone` in the first place, so a real sandbox has to admit a clone stage before it drops the interface. +- [ ] Symlink / path-traversal escapes are checked — **path validation done; descriptor-based TOCTOU protection deferred again in Phase 4.** `Walk` hard-fails on any symlink and `ContainsRoot` bounds every path, so an escape needs a directory swapped between the check and the open. That is a real race and a narrow one: the parser reads a checkout it made itself, seconds earlier, from a `--depth 1` clone of a public repository. **The original wording justified this partly by "in a container with no network and no capabilities", which R38 shows is not true of the running path** — the deferral still stands on the rest, but it stands on less than it appeared to. Revisit when it parses a tree someone else can write to. - [x] File-count, per-file size (>1MB), and depth caps are enforced; binary/`node_modules`/`.git` skipped - [x] Webhook signatures are verified, replay-protected, and per-repo throttled — HMAC-SHA256 over the **raw bytes** (`routes/webhook.ts`), delivery ids held in Redis with `SET NX`, and a per-hook rate limit. Replay protection is by delivery id rather than a timestamp window: GitHub sends no timestamp, and the id is what a retry repeats. - [x] All graph endpoints are session-gated — one `preHandler` on one encapsulated scope (`routes/index.ts`), asserted route by route in `routes/graph.test.ts`. Shipped in Phase 3a; the box was never ticked. The webhook is deliberately outside that gate and is authenticated by its signature instead. From 9e45acbf5cb603f7067a4dfb68265d02dc28a516 Mon Sep 17 00:00:00 2001 From: Aditya Rana Date: Sat, 29 Aug 2026 10:28:44 +0530 Subject: [PATCH 2/9] add a showcase build for a deploy with no API There is no hosted instance, so the public face is the web app on its own. "Open the atlas" would send every visitor of that build to an error screen. VITE_SHOWCASE flips the one component that defines the call to action: `OpenAtlas` renders a link to the repository labelled "Run it yourself" instead. The label changes with the destination, because a button still reading "Open the atlas" that opened GitHub would be the same lie in a friendlier voice. Build-time rather than a runtime probe, deliberately: the landing page makes no request to our API, which is what lets it render when the backend is absent, and asking whether the API is up would give that away for a fact the build already knows. The limits copy now says the thing that is true in both builds -- this is a tool you run yourself, there is no instance to sign in to but your own. The canvas route is lazy. Statically imported, React Flow and the panels sat in the main bundle and every landing-page visitor downloaded a canvas they never opened; on a showcase build it can never be reached at all. Splitting `AppRoute` into its own module takes the entry chunk from 323 kB to 170 kB gzipped and drops react-flow out of it entirely. `ResolvingSession` moves to its own file because both sides need it and importing it from `AppRoute` would pull that chunk straight back into the entry. vercel.json carries the monorepo build and, more importantly, a rewrite of everything to index.html -- without it a deep link or a refresh on /app is a 404, since there is no server-side route for it. Verified against the built bundle under `vite preview`, which is the only local thing with the same fallback behaviour: all three calls to action read "Run it yourself" and point at the repository, and /app answers 200 and renders the unreachable-API screen rather than a 404. Three things left unverified in #33 are now measured rather than assumed. The draw animation runs -- the mask transform steps -374px, -62px, -5.7px, then is removed, with node opacities staggering 0.32, 0.81, 1 behind it. The breakpoints hold: 375px is single column with no horizontal overflow and the header's second call to action correctly hidden, 768px shows it. The earlier "requestAnimationFrame is dead" reading was a stale tab, not the page. --- apps/web/src/App.tsx | 208 ++---------------- apps/web/src/AppRoute.tsx | 176 +++++++++++++++ apps/web/src/components/ResolvingSession.tsx | 33 +++ .../web/src/components/landing/ClosingCta.tsx | 3 +- apps/web/src/components/landing/Hero.tsx | 2 +- .../src/components/landing/OpenAtlas.test.tsx | 51 +++++ apps/web/src/components/landing/OpenAtlas.tsx | 58 +++-- apps/web/src/lib/constants.ts | 14 ++ apps/web/src/vite-env.d.ts | 4 + vercel.json | 8 + 10 files changed, 352 insertions(+), 205 deletions(-) create mode 100644 apps/web/src/AppRoute.tsx create mode 100644 apps/web/src/components/ResolvingSession.tsx create mode 100644 apps/web/src/components/landing/OpenAtlas.test.tsx create mode 100644 vercel.json diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 258ed6f..a98cb68 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,205 +1,41 @@ -import { useRef, useState } from "react"; -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 { Suspense, lazy } from "react"; +import { APP_ROUTE } from "@funcatlas/shared"; 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, -} from "./lib/constants"; +import { ResolvingSession } from "./components/ResolvingSession"; +import { usePath } from "./lib/router"; /** - * Percentages of the group. Strings with an explicit unit, because this library - * reads a bare number as pixels and the difference is invisible until it is - * wrong. + * The canvas and everything under it -- React Flow, the resizable panels, the + * command palette, Shiki -- fetched only when someone actually goes to `/app`. * - * Both panels declare a size: given only one, the library ignores it and falls - * back to an even split. + * Statically imported, all of that landed in the main bundle and every visitor + * to the landing page downloaded a canvas they had not asked for. On a showcase + * build it is worse than waste: there is no API behind `/app`, so the chunk can + * never be used at all. + * + * `Landing` stays static. It is what `/` renders and what most visitors will + * only ever see, so making it wait on a second round trip would be paying the + * cost in the one place it is least worth paying. */ +const AppRoute = lazy(() => import("./AppRoute")); /** * 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 + * 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. - */ -function AppRoute() { - const session = useSession(); - - if (session.isPending) { - return ; - } - - if (session.isError) { - return ; - } - - if (session.data === null) { - return ; + if (usePath() !== APP_ROUTE) { + return ; } return ( - - {/* The provider exists because the sidebar's menu components read it; - width and collapse are owned by the resizable panel below, which is - the thing the reader actually drags. - - Its wrapper ships as `min-h-svh`, which grows past the viewport - instead of clipping -- the document then scrolls, and the tree and - the canvas move together as one page. Pinned to the viewport here so - each pane owns its own overflow. */} - - - - - ); -} - -function Explorer({ user }: { user: SessionUser }) { - const panel = useRef(null); - const [collapsed, setCollapsed] = useState(false); - - /** - * One rule, everywhere: collapsed is whatever the panel says it is. - * - * Dragging the separator past `minSize` collapses the panel too, so the - * button cannot own this state -- it would keep offering to hide a tree that - * is already hidden. Called from both the group's layout callback and the - * toggle, because neither fires for the other's path. - */ - const sync = () => setCollapsed(panel.current?.isCollapsed() ?? false); - - const toggle = () => { - const handle = panel.current; - if (handle === null) return; - - if (handle.isCollapsed()) { - handle.expand(); - } else { - handle.collapse(); - } - sync(); - }; - - return ( -
- - - {/* Mounted with the explorer rather than inside the sidebar: ⌘K is a - window-level shortcut, and a palette that only exists while the tree - is open would stop answering when the tree is collapsed. */} - - - - - - - - - - -
- -
-
-
-
- ); -} - -/** The shape of the app, not a spinner (UI_GUIDE §3.3). */ -function ResolvingSession() { - return ( -
-
- - -
-
-
- - - -
-
-
-
- ); -} - -/** - * 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 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/AppRoute.tsx b/apps/web/src/AppRoute.tsx new file mode 100644 index 0000000..744add6 --- /dev/null +++ b/apps/web/src/AppRoute.tsx @@ -0,0 +1,176 @@ +import { useRef, useState } from "react"; +import 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 { LoginScreen } from "./components/LoginScreen"; +import { ResolvingSession } from "./components/ResolvingSession"; +import { Sidebar } from "./components/Sidebar"; +import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "./components/ui/resizable"; +import { SidebarProvider } from "./components/ui/sidebar"; +import { TooltipProvider } from "./components/ui/tooltip"; +import { Link } from "./lib/router"; +import { useSession } from "./lib/session"; +import { + CANVAS_DEFAULT, + GITHUB_REPO_URL, + SIDEBAR_DEFAULT, + SIDEBAR_MAX, + SIDEBAR_MIN, +} from "./lib/constants"; + +/** + * Percentages of the group. Strings with an explicit unit, because this library + * reads a bare number as pixels and the difference is invisible until it is + * wrong. + * + * Both panels declare a size: given only one, the library ignores it and falls + * back to an even split. + */ + +/** + * The canvas route, and everything only it needs. + * + * Its own module so `App.tsx` can load it lazily. React Flow alone is most of + * the main bundle, and the landing page cannot use any of it -- on a showcase + * build nobody ever reaches this route at all, so none of this is fetched. + * + * 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 AppRoute() { + const session = useSession(); + + if (session.isPending) { + return ; + } + + if (session.isError) { + return ; + } + + if (session.data === null) { + return ; + } + + return ( + + {/* The provider exists because the sidebar's menu components read it; + width and collapse are owned by the resizable panel below, which is + the thing the reader actually drags. + + Its wrapper ships as `min-h-svh`, which grows past the viewport + instead of clipping -- the document then scrolls, and the tree and + the canvas move together as one page. Pinned to the viewport here so + each pane owns its own overflow. */} + + + + + ); +} + +function Explorer({ user }: { user: SessionUser }) { + const panel = useRef(null); + const [collapsed, setCollapsed] = useState(false); + + /** + * One rule, everywhere: collapsed is whatever the panel says it is. + * + * Dragging the separator past `minSize` collapses the panel too, so the + * button cannot own this state -- it would keep offering to hide a tree that + * is already hidden. Called from both the group's layout callback and the + * toggle, because neither fires for the other's path. + */ + const sync = () => setCollapsed(panel.current?.isCollapsed() ?? false); + + const toggle = () => { + const handle = panel.current; + if (handle === null) return; + + if (handle.isCollapsed()) { + handle.expand(); + } else { + handle.collapse(); + } + sync(); + }; + + return ( +
+ + + {/* Mounted with the explorer rather than inside the sidebar: ⌘K is a + window-level shortcut, and a palette that only exists while the tree + is open would stop answering when the tree is collapsed. */} + + + + + + + + + + +
+ +
+
+
+
+ ); +} + +/** + * 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 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/ResolvingSession.tsx b/apps/web/src/components/ResolvingSession.tsx new file mode 100644 index 0000000..6ea82d4 --- /dev/null +++ b/apps/web/src/components/ResolvingSession.tsx @@ -0,0 +1,33 @@ +import { Skeleton } from "./ui/skeleton"; + +/** + * The shape of the app, not a spinner (UI_GUIDE §3.3). + * + * Its own module because two things need it and they must not import each + * other: `AppRoute` shows it while the session resolves, and `App` uses it as + * the Suspense fallback while the route's chunk downloads. Importing it from + * `AppRoute` would pull that whole chunk -- React Flow included -- back into + * the main bundle and undo the split. + * + * The same skeleton for both is not a shortcut. From the reader's side the two + * waits are one wait, and swapping one placeholder for another mid-load would + * show them a seam that means nothing to them. + */ +export function ResolvingSession() { + return ( +
+
+ + +
+
+
+ + + +
+
+
+
+ ); +} diff --git a/apps/web/src/components/landing/ClosingCta.tsx b/apps/web/src/components/landing/ClosingCta.tsx index c270fa4..fda45d4 100644 --- a/apps/web/src/components/landing/ClosingCta.tsx +++ b/apps/web/src/components/landing/ClosingCta.tsx @@ -4,6 +4,7 @@ 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 = [ + "There is no hosted instance. You clone the repository and run it, so the graph and the database are yours and stay on your machine.", "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.", @@ -15,7 +16,7 @@ export function ClosingCta() { tier="unresolved" eyebrow="Before you start" title="Chart a repository." - lede="Sign in with GitHub, paste a repository URL, and walk the graph it produces." + lede="Clone it, bring it up, sign in with GitHub, and paste a repository URL. The README has the setup, and it is one command once the prerequisites are there." >
    diff --git a/apps/web/src/components/landing/Hero.tsx b/apps/web/src/components/landing/Hero.tsx index 752526a..0c162d1 100644 --- a/apps/web/src/components/landing/Hero.tsx +++ b/apps/web/src/components/landing/Hero.tsx @@ -50,7 +50,7 @@ export function Hero() {

- Public repositories. GitHub sign-in at{" "} + A tool you run yourself, on public repositories. GitHub sign-in at{" "} read:user, and nothing is written to your account.

diff --git a/apps/web/src/components/landing/OpenAtlas.test.tsx b/apps/web/src/components/landing/OpenAtlas.test.tsx new file mode 100644 index 0000000..d92a81e --- /dev/null +++ b/apps/web/src/components/landing/OpenAtlas.test.tsx @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { APP_ROUTE } from "@funcatlas/shared"; + +/** + * `SHOWCASE` is read from `import.meta.env` once, at module load, because it is + * a build-time fact rather than a runtime one. So each case has to reset the + * module graph and import again -- stubbing the env after the import would + * leave the already-evaluated constant behind and both cases would pass + * against the same build. + */ +async function renderCta(showcase: boolean) { + vi.resetModules(); + vi.stubEnv("VITE_SHOWCASE", showcase ? "true" : "false"); + + const { OpenAtlas } = await import("./OpenAtlas"); + render(); +} + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("OpenAtlas", () => { + it("opens the canvas in a normal build", async () => { + await renderCta(false); + + const cta = screen.getByRole("link", { name: /open the atlas/i }); + expect(cta).toHaveAttribute("href", APP_ROUTE); + }); + + it("sends readers to the repository when there is no API behind it", async () => { + await renderCta(true); + + // `/app` on a web-only deploy reaches nothing, so pointing at it would send + // every visitor to an error screen. + const cta = screen.getByRole("link", { name: /run it yourself/i }); + expect(cta).toHaveAttribute("href", expect.stringContaining("github.com")); + expect(screen.queryByRole("link", { name: /open the atlas/i })).not.toBeInTheDocument(); + }); + + it("changes the label with the destination, not just the href", async () => { + // A button still reading "Open the atlas" that opened GitHub would be the + // same lie in a friendlier voice (UI_GUIDE §3.4: an action keeps its name + // through the flow, so a different flow needs a different name). + await renderCta(true); + + const cta = screen.getByRole("link", { name: /run it yourself/i }); + expect(cta).not.toHaveAttribute("href", APP_ROUTE); + }); +}); diff --git a/apps/web/src/components/landing/OpenAtlas.tsx b/apps/web/src/components/landing/OpenAtlas.tsx index c040553..ec784b1 100644 --- a/apps/web/src/components/landing/OpenAtlas.tsx +++ b/apps/web/src/components/landing/OpenAtlas.tsx @@ -1,18 +1,28 @@ +import type { ReactNode } from "react"; import { APP_ROUTE } from "@funcatlas/shared"; import { ArrowRight } from "lucide-react"; import { cn } from "../../lib/cn"; +import { GITHUB_REPO_URL, SHOWCASE } from "../../lib/constants"; 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 + * 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. + * Two destinations, because there are two kinds of build. + * + * Normally 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. + * + * In a showcase build there is no API behind `/app`, so sending anyone there + * would be sending them to an error. It points at the repository instead and + * says so: **the label changes with the destination.** A button that still read + * "Open the atlas" and opened GitHub would be the same lie in a friendlier + * voice. */ export function OpenAtlas({ size = "lg", @@ -21,20 +31,34 @@ export function OpenAtlas({ size?: "sm" | "lg"; className?: string; }) { + const classes = cn(buttonVariants({ size }), "group", className); + + if (SHOWCASE) { + return ( + + Run it yourself + + + ); + } + 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. */} - - - + ); } + +/** 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. */ +function Well(): ReactNode { + return ( + + + + ); +} diff --git a/apps/web/src/lib/constants.ts b/apps/web/src/lib/constants.ts index 00b0576..af3c861 100644 --- a/apps/web/src/lib/constants.ts +++ b/apps/web/src/lib/constants.ts @@ -36,6 +36,20 @@ export const UI_STORAGE_KEY = "funcatlas-ui"; export const GITHUB_REPO = "ARCoder181105/funcatlas"; export const GITHUB_REPO_URL = `https://github.com/${GITHUB_REPO}`; +/** + * This build has no API behind it. + * + * A deploy of the web app on its own -- the public face of a tool you run + * yourself. `/app` would reach nothing, so the landing page sends readers to + * the repository instead of to an error screen. + * + * Build-time rather than a runtime probe, and deliberately so: the landing page + * makes no request to our API at all, which is what lets it render when the + * backend is absent. Asking whether the API is up would give that away for a + * fact the build already knows. + */ +export const SHOWCASE = import.meta.env.VITE_SHOWCASE === "true"; + // --- Motion --------------------------------------------------------------- /** Milliseconds. Page-level motion in UI_GUIDE §4 is 400-600ms, and this moves diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index 29c29a1..ddc541a 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -2,6 +2,10 @@ interface ImportMetaEnv { readonly VITE_API_URL: string; + /** "true" in a build with no API behind it. See SHOWCASE in lib/constants.ts. + * A string, not a boolean: Vite substitutes the literal from the + * environment, and `VITE_SHOWCASE=false` would otherwise be truthy. */ + readonly VITE_SHOWCASE?: string; } interface ImportMeta { diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..f1ff046 --- /dev/null +++ b/vercel.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "buildCommand": "pnpm --filter web build", + "installCommand": "pnpm install --frozen-lockfile", + "outputDirectory": "apps/web/dist", + "framework": null, + "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] +} From b6b79227a0e780b660909128786fed88dd22aa2d Mon Sep 17 00:00:00 2001 From: Aditya Rana Date: Sat, 29 Aug 2026 16:45:17 +0530 Subject: [PATCH 3/9] ease the file card's selected row, and keep its badge visible Two faults in one row. Selecting a function swapped the background with no transition, so it read as a repaint rather than as an answer to the click -- everything else on this canvas moves under one. The second was hiding inside the first. The line-number badge is `variant ="outline"`, bordered with `--border`, and `--accent` resolves to the same token; on the selected row the pill's edge was drawn in exactly the colour of the row beneath it and disappeared. Every other row had a pill and the selected one did not, which read as the badge being removed on selection. It keeps its own ground there now. Both transitions are under `motion-safe:`. No test: this is two class strings, and asserting the class names back would be a change detector rather than a check. Verified in a browser against pmndrs/zustand. --- apps/web/src/components/FileCard.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/FileCard.tsx b/apps/web/src/components/FileCard.tsx index e49ffc1..0520d24 100644 --- a/apps/web/src/components/FileCard.tsx +++ b/apps/web/src/components/FileCard.tsx @@ -237,6 +237,11 @@ function FunctionList({ // and made the row taller than the card was measured for. // The card is sized to the longest name instead. "nodrag cursor-pointer flex-nowrap text-left", + // Selection eases in rather than snapping. Everything else + // on this canvas moves under a transition, so a row that + // changed instantly read as a repaint rather than as an + // answer to the click. + "motion-safe:transition-colors motion-safe:duration-micro", active ? "bg-accent text-accent-foreground" : "hover:bg-muted", @@ -257,9 +262,17 @@ function FunctionList({ + {/* The outline badge is bordered with --border, and --accent + is the same token, so on the selected row the pill's edge + was drawn in the colour of the row underneath it and + vanished. It keeps its own ground there instead. */} {fn.startLine} From 0d3698da41cafea805d33ca031669003dbfe76c4 Mon Sep 17 00:00:00 2001 From: Aditya Rana Date: Sat, 29 Aug 2026 16:47:01 +0530 Subject: [PATCH 4/9] give the card's rows the palette's hover and selected treatment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⌘K marks a result with `bg-accent` and `ring-1 ring-primary/40`. The card's rows had the accent ground and no ring, so the same object -- a function you can pick -- looked like two different things depending on which surface you found it on. They match now, with a lighter ring on hover so the row answers the pointer before it is clicked. box-shadow is named in the transition because that is what a ring is; `transition-colors` alone eased the fill and snapped the outline. --- apps/web/src/components/FileCard.tsx | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/FileCard.tsx b/apps/web/src/components/FileCard.tsx index 0520d24..b7796ad 100644 --- a/apps/web/src/components/FileCard.tsx +++ b/apps/web/src/components/FileCard.tsx @@ -237,14 +237,18 @@ function FunctionList({ // and made the row taller than the card was measured for. // The card is sized to the longest name instead. "nodrag cursor-pointer flex-nowrap text-left", - // Selection eases in rather than snapping. Everything else - // on this canvas moves under a transition, so a row that - // changed instantly read as a repaint rather than as an - // answer to the click. - "motion-safe:transition-colors motion-safe:duration-micro", + // The same treatment ⌘K gives a result: accent ground and a + // ring in the hue that means "known". Both surfaces are + // offering the same thing -- a function you can pick -- so + // picking one should look the same in either. + // + // box-shadow is in the transition because that is what a + // ring is; `transition-colors` alone would ease the fill and + // snap the outline. + "motion-safe:transition-[background-color,box-shadow] motion-safe:duration-micro", active - ? "bg-accent text-accent-foreground" - : "hover:bg-muted", + ? "bg-accent text-accent-foreground ring-1 ring-primary/40" + : "hover:bg-muted hover:ring-1 hover:ring-primary/20", )} render={ Date: Sat, 29 Aug 2026 16:48:55 +0530 Subject: [PATCH 5/9] rewrite the README for someone arriving cold It was stale in ways that would waste a new contributor's afternoon. The phase table said Phase 5 "not started"; it merged in 4e240ed with eight languages. "How it works" said `.ts`/`.tsx` only. And the setup instructions told people to sign in with "Continue as a local dev user" and to POST to `/auth/dev-login` -- a route deleted in Phase 4 (R30). Following the README as written got you a 404 and a login screen with no such button. Rewritten around the reader who has just cloned it and wants it running. The confidence tiers are the first thing on the page, with a table of what each one means, because that is the product rather than a detail of it. The GitHub OAuth app now has its three exact field values written out, since that is the one step nothing can do for you, along with why the scope is `read:user` and why that limits it to public repositories. A "what it does not do" section states the limits in the README instead of leaving them to be discovered: public repositories only, no hosted instance, the incremental re-parse that is only incremental in its write (R35), and the parser sandbox being a harness rather than the running path (R38). The `docker compose up` gap is called out as not working rather than quietly omitted, since it is the next piece of work. Every link in the document was checked to resolve. --- README.md | 272 ++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 173 insertions(+), 99 deletions(-) diff --git a/README.md b/README.md index edc2057..593272b 100644 --- a/README.md +++ b/README.md @@ -1,142 +1,216 @@ # funcatlas -An interactive visual map of a codebase. Point it at a repository and it parses every function, -links call sites into a graph, and renders it as an explorable canvas — click a file to get a card, -click the card to get a mind-map of its functions, click a function to read its code. - -Built for one problem: large codebases don't fit in your head, and grep or go-to-definition only -ever shows you one file at a time — never the shape of the whole thing. - -**Status:** in development, and usable. Log in, point it at a public TypeScript repository, and -explore the call graph on the canvas — file tree to card to function mind-map to highlighted source, -with ⌘K to find a function by name. Registering returns immediately and the parse runs on a queue; -point a GitHub webhook at it and the graph follows your pushes, rewriting only the rows a commit -actually changed. - -## Progress - -| Phase | Scope | State | -|---|---|---| -| 0 | Monorepo bootstrap, migrations, CI | done | -| 1 | Go + tree-sitter parser, input hardening | done | -| 2 | Postgres persistence, call resolution | done | -| 3a | GitHub OAuth, Redis sessions, the graph API | done | -| 3b | React Flow canvas, search UI | done | -| 4 | Webhooks, job queue, security hardening | done | -| 5 | Go, Rust and Python — extraction only | not started | - -Phase-by-phase detail is in [`PLAN.md`](PLAN.md); the current chunk list is in -[`TASKLIST.md`](TASKLIST.md). +An interactive visual map of a codebase. Point it at a public GitHub repository and it clones it, +extracts every function and call site with tree-sitter, resolves each call to the function it +reaches, and draws the result as a graph you can walk: file tree → file card → function mind-map → +highlighted source. -## How it works +**The part that matters: it tells you what it does not know.** Every call gets one of three +answers, and each is drawn as a different line. -1. **Clone** the target repo, depth one, over public HTTPS. Nothing in it is ever executed — no - install, build or test scripts — because parsing only reads text. -2. **Parse** every `.ts`/`.tsx` file with tree-sitter; extract function definitions, call sites, - and imports into a Go-native intermediate representation. -3. **Resolve** each call to a definition — same file, then imported symbol, then package fallback — - and tag the resulting edge `exact`, `name_match`, or `unresolved`. -4. **Store** functions and edges in Postgres, keyed so an incremental re-parse never orphans an edge. -5. **Serve** the graph behind a GitHub login — every endpoint is session-gated, and sessions are - opaque ids in Redis rather than anything the browser can read. -6. **Explore** the graph on a React Flow canvas, where edge style reflects resolution confidence. +| | Tier | Drawn | Means | +|---|---|---|---| +| ─── | `exact` | solid | The import was followed to a declaration, and only one function could be the target. | +| ─ ─ ─ | `name_match` | dashed | A function with that name is in scope. Another one elsewhere may be the one actually called. | +| · · · | `unresolved` | dotted | The call is real and its target is ambiguous: a barrel re-export, a default import, a path alias. | -The confidence tag is the point of the design: a guess is never drawn as a fact. +Ambiguity resolves to `unresolved`, never to a guess, and an unresolved call is never coloured as +an error. A tool that guesses is worse than one that stops, because a wrong edge is read as fact +and costs more than the missing one it replaced. Unresolved callees are drawn as ghost nodes at the +edge of the map, labelled with the name the parser saw — the map showing its own boundary. -## Stack +Built for one problem: large codebases do not fit in your head, and grep or go-to-definition only +ever shows you one file at a time, never the shape of the whole thing. -| Layer | Choice | +## Status + +Phases 0 through 5 are done and merged. It works and it is usable: sign in, chart a public +repository, and explore it. Registering returns immediately and the parse runs on a queue; point a +GitHub webhook at it and the graph follows your pushes, rewriting only the rows a commit changed. + +**There is no hosted instance.** You run it yourself, so the database and the graphs are yours and +stay on your machine. + +## Languages + +Eight, at two different depths. Support is not uniform, so it is not presented as though it were. + +| Depth | Languages | |---|---| -| Parser | Go + tree-sitter, pgx, zap | -| Database | Postgres (edge tables + recursive CTEs) | -| API | Fastify + Drizzle + postgres.js + Zod | -| Frontend | Vite + React + React Flow + Tailwind + shadcn/ui | -| Queue | Redis + BullMQ | -| Auth | GitHub OAuth (arctic), opaque sessions in Redis | -| Monorepo | pnpm workspaces + Turborepo | +| **Resolved across files** — imports are followed, so a call reaches a definition in another file | TypeScript, TSX, JavaScript, JSX | +| **Extracted, resolved within a file** — every function and call site is charted, cross-file resolution is not built | Go, Rust, Python, Java | -Reasoning for each pick is in [`docs/TECH_STACK.md`](docs/TECH_STACK.md). +Per-language limits are pinned by assertions rather than described, because a parser that quietly +produces *less* reads as one that worked. See [`docs/PARSING_STRATEGY.md`](docs/PARSING_STRATEGY.md). -## Layout +## Running it + +### Prerequisites + +- **Node 20+** and **pnpm** (`npm i -g pnpm`) +- **Go 1.24+** with a C toolchain (`gcc`) — tree-sitter uses cgo +- **Docker** and **Docker Compose** — for Postgres and Redis +- **golang-migrate** CLI — or the `migrate/migrate` Docker image, as CI does +- A **GitHub OAuth App** — the app has no other way to know who you are +> `docker compose up` does **not** work yet and is the next piece of work: `apps/web` has no +> Dockerfile, there is no worker service, and the API image cannot start. Until then the four +> prerequisites above are all required. See NFR-4 in [`PRD.md`](PRD.md). + +### Register a GitHub OAuth App + + → **New OAuth App**. The exact values: + +| Field | Value | +|---|---| +| Application name | anything — `funcatlas (local)` | +| Homepage URL | `http://localhost:5173` | +| Authorization callback URL | `http://localhost:3000/auth/callback` | + +Generate a client secret and keep both values for the next step. The scope requested is +`read:user` and nothing more — funcatlas never writes to your account, and the token is read once +for your username and then never used again. It clones over public HTTPS, which is why only public +repositories work: the only scope that reads private ones is `repo`, and that also grants **write** +access to every private repository you can reach. + +### Set up and start + +```bash +git clone https://github.com/ARCoder181105/funcatlas.git +cd funcatlas +pnpm install +cp .env.example .env ``` -/packages/shared Drizzle schema + Zod schemas, shared by api and web -/apps/api Fastify — auth, repo registration, graph endpoints -/apps/web Vite + React — the canvas -/services/parser Go — clone, parse, resolve, write to Postgres -/docs architecture, data model, security, risks + +Then edit `.env` and fill in four values: + +```bash +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= +GITHUB_WEBHOOK_SECRET=$(openssl rand -hex 32) # unused locally, but required +SESSION_SECRET=$(openssl rand -hex 32) ``` -## Running it +Create the test database once, or `make test` truncates the one you develop against: ```bash -pnpm install -cp .env.example .env # fill in DATABASE_URL and REDIS_URL docker compose up -d postgres redis -migrate -path services/parser/migrations -database "$DATABASE_URL" up -make go-build-bin # the binary the API spawns +docker compose exec postgres psql -U funcatlas -d postgres \ + -c "CREATE DATABASE funcatlas_test OWNER funcatlas;" ``` -Parse a fixture without touching the API: +Then bring the whole thing up — infra, migrations, the parser binary, the API, the web app and the +parse worker: ```bash -make go-run REPO=./services/parser/testdata/sample # emits out.json +make start # then open http://localhost:5173 ``` -Or bring the whole thing up and use it — infra, migrations, parser binary, API and web in one: +Sign in with GitHub, paste a public repository URL, and explore it. `⌘K` finds any function by +name. + +### Without the app + +Parse a repository straight to stdout, no database and no API: ```bash -make start # then open http://localhost:5173 +make go-run REPO=./services/parser/testdata/polyglot +cd services/parser && go run ./cmd/parser --repo ./testdata/resolve --format summary ``` -Sign in with **Continue as a local dev user**, chart a public repository, and explore it. +## How it works + +1. **Clone** the repository, depth one, over public HTTPS. Nothing in it is ever executed — no + install, build or test scripts — because parsing only reads text. Symlinks fail the run rather + than being followed, files over 1 MB are skipped, and file count and depth are capped. +2. **Extract** every function declaration and call site with tree-sitter, into a Go-native + intermediate representation. One pinned grammar per extension, never shared: a mismatched + grammar fails *silently* and drops every call in the file. +3. **Resolve** each call against a symbol table partitioned by language, so no edge can cross a + language boundary, and tag it `exact`, `name_match` or `unresolved`. +4. **Store** functions and edges in Postgres, keyed so an incremental re-parse never orphans an + edge. +5. **Serve** the graph behind a GitHub login. Every `/api` route is session-gated, and sessions are + opaque ids in Redis rather than anything the browser can read. +6. **Explore** it on a React Flow canvas, where the edge style is the resolution confidence. + +## What it does not do -Or drive it over HTTP instead. `/auth/dev-login` exists outside production so this works -without a GitHub OAuth app: +Stated here rather than discovered later: + +- **Public repositories only.** See the OAuth section above for why. +- **No hosted instance.** You run it. +- **An incremental re-parse still re-parses everything.** The *write* is scoped to changed files; + the clone, extract and resolve are not. Resolution is whole-repo, and a partial symbol table + would emit a confident edge where the whole repository would correctly say ambiguous + ([`docs/RISKS.md`](docs/RISKS.md) R35). +- **The parser sandbox is a harness, not the running path.** `--network none`, a read-only rootfs + and dropped capabilities apply under `make parser-isolated`; the queue worker spawns the binary + as a plain subprocess. The input hardening in step 1 above is enforced everywhere. R38 and + [`docs/SECURITY.md`](docs/SECURITY.md). +- **One file card at a time**, with as many function branches off it as you open. +- **Barrel re-export chains, default imports and `tsconfig` path aliases** all resolve to + `unresolved`. Deliberately. + +## Working on it ```bash -cd apps/api && pnpm dev - -curl -c jar -X POST localhost:3000/auth/dev-login -curl -b jar -X POST localhost:3000/api/repos \ - -H 'content-type: application/json' \ - -d '{"githubUrl":"https://github.com/ARCoder181105/funcatlas"}' -curl -b jar localhost:3000/api/repos/1/tree -curl -b jar 'localhost:3000/api/repos/1/search?query=parse' +make start # everything, then open :5173 +make test # TypeScript AND Go — `pnpm -r test` silently skips the parser +make lint && make typecheck +make go-vet # not part of `make test` +make help # every target +``` + +Integration tests read `TEST_DATABASE_URL` and skip when it is unset, so a green run that never +touched Postgres is possible — check that you created the test database above. + +[`CLAUDE.md`](CLAUDE.md) is the load-bearing summary of how this repository is organised and which +conventions bite if ignored; it is worth reading before a first change even though it is addressed +to an assistant. [`DEVELOPMENT.md`](DEVELOPMENT.md) has the daily loop. + +## Layout + +``` +/apps/api Fastify — auth, repo registration, graph endpoints, the parse worker +/apps/web Vite + React — the landing page and the canvas +/packages/shared Drizzle schema + Zod schemas, shared by api and web +/services/parser Go — clone, extract, resolve, write to Postgres +/docs architecture, data model, parsing, security, risks, UI ``` -Every `/api` route answers 401 without that cookie. Full setup, prerequisites, and the daily loop -are in [`DEVELOPMENT.md`](DEVELOPMENT.md). +## Stack + +| Layer | Choice | +|---|---| +| Parser | Go + tree-sitter, pgx with explicit SQL, zap | +| Database | Postgres — edge tables and recursive CTEs | +| API | Fastify + Drizzle + postgres.js + Zod | +| Frontend | Vite + React 19 + React Flow + Tailwind v4 + shadcn/ui on Base UI | +| Queue | Redis + BullMQ, consumed by a Node worker that spawns the Go binary | +| Auth | GitHub OAuth (arctic/oslo), opaque sessions in Redis | +| Monorepo | pnpm workspaces + Turborepo | + +Reasoning for each pick, and the rejected alternatives, is in +[`docs/TECH_STACK.md`](docs/TECH_STACK.md). ## Documentation | Document | Owns | |---|---| -| [`PRD.md`](PRD.md) | What we're building and why — the product contract | -| [`PLAN.md`](PLAN.md) | The 4-phase execution plan | -| [`TASKLIST.md`](TASKLIST.md) | The live task list for the current phase | -| [`DEVELOPMENT.md`](DEVELOPMENT.md) | Setup, dev loop, conventions | +| [`PRD.md`](PRD.md) | What we are building and what counts as done | +| [`PLAN.md`](PLAN.md) | The phase order and what closed each one | +| [`TASKLIST.md`](TASKLIST.md) | The live task list | +| [`CLAUDE.md`](CLAUDE.md) | Conventions, and what bites if ignored | +| [`DEVELOPMENT.md`](DEVELOPMENT.md) | Setup and the daily loop | | [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) | Components and how they connect | -| [`docs/TECH_STACK.md`](docs/TECH_STACK.md) | Stack decisions and rejected alternatives | | [`docs/DATA_MODEL.md`](docs/DATA_MODEL.md) | Postgres schema | -| [`docs/PARSING_STRATEGY.md`](docs/PARSING_STRATEGY.md) | Extraction and call resolution, with limits | -| [`docs/SECURITY.md`](docs/SECURITY.md) | Parsing untrusted repos safely | +| [`docs/PARSING_STRATEGY.md`](docs/PARSING_STRATEGY.md) | Extraction, resolution, per-language limits | +| [`docs/SECURITY.md`](docs/SECURITY.md) | Parsing untrusted repositories, and where the isolation actually applies | | [`docs/RISKS.md`](docs/RISKS.md) | Open decisions and tracked risks | -| [`docs/UI_GUIDE.md`](docs/UI_GUIDE.md) | Visual direction for the canvas | - -## Scope - -**In:** TypeScript for the full pipeline. Public repositories via GitHub OAuth — the scope is -`read:user`, since GitHub offers no read-only repository scope and `repo` would grant write access -to every private repository you can reach. Name/scope call resolution with confidence tags. -Webhook-driven incremental updates. Function-name search. - -**Planned:** Go, Rust and Python in Phase 5, extraction only — per-language call resolution stays -cut. - -**Out (post-MVP):** LSP-based resolution, freehand annotation layer, Neo4j, saved canvas layouts, -multi-tenancy, private repositories. +| [`docs/UI_GUIDE.md`](docs/UI_GUIDE.md) | Visual direction, and what this must not look like | +| [`docs/CANVAS_DECISIONS.md`](docs/CANVAS_DECISIONS.md) | Why the canvas behaves the way it does | ## License -MIT — see [`LICENSE`](LICENSE). +MIT. See [`LICENSE`](LICENSE). From 2c909512eaf986650dc4d6527328d9ead9dc7b99 Mon Sep 17 00:00:00 2001 From: Aditya Rana Date: Sat, 29 Aug 2026 16:57:55 +0530 Subject: [PATCH 6/9] stop App.test flaking on the lazy canvas chunk Splitting `/app` behind React.lazy made its tests wait on a dynamic import that really does pull React Flow and the rest of the canvas. Alone that lands well inside Testing Library's 1s default; in a full run, with eighteen other files competing for the same threads, it does not, and the route test failed intermittently. A longer ceiling for this file rather than a mock of the lazy boundary: the wait is a real property of the route, and every assertion still fails if it never renders. Confirmed over three consecutive full runs. `configure` is imported from @testing-library/react, not @testing-library/dom -- the latter is only a transitive dependency and does not resolve from this package. --- apps/web/src/App.test.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/web/src/App.test.tsx b/apps/web/src/App.test.tsx index 4178531..2010b45 100644 --- a/apps/web/src/App.test.tsx +++ b/apps/web/src/App.test.tsx @@ -1,11 +1,22 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; +// `configure` comes from here rather than @testing-library/dom, which is only +// a transitive dependency and does not resolve from this package. +import { configure, 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"; +// `/app` is behind `React.lazy`, and resolving that chunk really does pull +// React Flow and the rest of the canvas. Alone it lands well inside the 1s +// default; in a full run, with every other file competing for the same +// threads, it does not, and this file failed intermittently on the route test. +// +// A longer ceiling rather than a mock: the wait is a real property of the +// route, and the assertions still fail if it never renders at all. +configure({ asyncUtilTimeout: 5000 }); + // The real module is kept for ApiError -- session.ts branches on // `instanceof`, so a stubbed class would make the 401 path silently dead. vi.mock("./lib/api", async (importOriginal) => { From c9ad0a29f51d15223f28cebc339cd4c70832d660 Mon Sep 17 00:00:00 2001 From: Aditya Rana Date: Sat, 29 Aug 2026 17:18:07 +0530 Subject: [PATCH 7/9] give the landing page a third ink, a type scale and a language strip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three complaints, one cause: the page was flat. Two hues across three thousand pixels, 30px headings sitting too close to 16px body for the eye to rank them, and eight languages listed as text. **A third ink.** `spot` -- #2ad4c4 dark, #0b7268 light. Named for what it is rather than as a tier, because it is the only colour in tokens.ts that carries no canvas meaning. A map is drawn in three inks and two of ours were already spoken for. It sits at 174°, 53° off `exact` and 153° off `name`, so it cannot be misread as either, and it never appears on the canvas -- there a colour meaning nothing would compete with three that mean something. Eyebrows, section detail, hover. Light is #0b7268 rather than the #0d7d72 that reads better, because that one measured 4.48:1 on paper and text wants 4.5. Worth recording: the palette database consulted for this returns slate plus #22C55E for every developer tool in it, which is UI_GUIDE §7's second cluster verbatim. It confirmed the default rather than escaping it, so its colour answer was not taken. **A type scale.** Section headings 40-48px against 18px ledes, and Bricolage's weight axis driven to 560 on headings against 400 in prose -- the axis was the reason the face was chosen and it had been sitting unused. **A language strip.** Eight marks drifting behind a mask that fades both ends, a CSS keyframe over `transform` with the set held twice and translated by exactly half, so the loop closes on itself. Reduced motion gets the same strip standing still, because the content is the point and the drift is decoration. The cards below keep the honest distinction and lose their logos: repeating all eight on one screen said it twice, and the cards carry the caveat rather than the names. Their language lists take the tier colour each group actually produces across files. Marks are vendored from simple-icons (CC0) rather than imported -- the package has no subpath exports, so using it means pulling three thousand icons to keep seven. Java is drawn here instead: Oracle's cup is a trademark simple-icons does not ship, OpenJDK's duke is legally clean but reads as nothing, so this is a plain coffee cup and a copy of no one's logo. A favicon, finally, which the app never had: the product's own notation at 16px, one caller reaching three callees along a solid, a dashed and a dotted edge, the last of them hollow. --- apps/web/index.html | 7 ++ apps/web/public/favicon.svg | 31 +++++++ .../web/src/components/landing/CanvasShot.tsx | 49 +++++++++++ .../components/landing/LanguageMarquee.tsx | 83 +++++++++++++++++++ apps/web/src/components/landing/Languages.tsx | 44 +++++----- apps/web/src/components/landing/Section.tsx | 13 ++- apps/web/src/components/landing/Tiers.tsx | 69 ++++++++------- apps/web/src/index.css | 15 ++++ apps/web/src/lib/language-marks.ts | 56 +++++++++++++ apps/web/src/lib/tokens.ts | 17 ++++ apps/web/tailwind.config.ts | 3 + docs/UI_GUIDE.md | 7 ++ 12 files changed, 339 insertions(+), 55 deletions(-) create mode 100644 apps/web/public/favicon.svg create mode 100644 apps/web/src/components/landing/CanvasShot.tsx create mode 100644 apps/web/src/components/landing/LanguageMarquee.tsx create mode 100644 apps/web/src/lib/language-marks.ts diff --git a/apps/web/index.html b/apps/web/index.html index 92a6f94..03c58d5 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -4,6 +4,13 @@ funcatlas + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/src/components/landing/CanvasShot.tsx b/apps/web/src/components/landing/CanvasShot.tsx new file mode 100644 index 0000000..ec4698c --- /dev/null +++ b/apps/web/src/components/landing/CanvasShot.tsx @@ -0,0 +1,49 @@ +import { Bezel } from "./Bezel"; +import { Section } from "./Section"; + +/** + * The product, photographed. + * + * `UI_GUIDE.md` §3.1 forbids the *hero* being a screenshot, and it stays a + * live drawing. This is a different argument: there is no hosted instance, so + * a visitor cannot reach the canvas by clicking anything. Showing it is the + * only way they ever see what the tool actually does, and the reason the + * walkthrough section was cut -- "they can just go and use it" -- stopped being + * true when the deploy became web-only. + * + * A real capture rather than a drawing, because a drawing of your own product + * is a claim and a screenshot is evidence. + * + * Two files and a ``, not one image with a filter: the two themes are + * genuinely different palettes (`lib/tokens.ts`), and a dark screenshot + * inverted is neither of them. `media` here is the OS preference rather than + * the in-app toggle, which is a known limit -- switching the theme by hand does + * not switch this image. Wiring it to the toggle would mean reading theme state + * into a component that otherwise needs none, for a picture most readers scroll + * past once. + */ +export function CanvasShot() { + return ( +
+ + + + The funcatlas canvas: a file tree on the left, a file card on the canvas, and a mind-map of its functions branching to the right with solid, dashed and dotted edges. + + +
+ ); +} diff --git a/apps/web/src/components/landing/LanguageMarquee.tsx b/apps/web/src/components/landing/LanguageMarquee.tsx new file mode 100644 index 0000000..3cf1e82 --- /dev/null +++ b/apps/web/src/components/landing/LanguageMarquee.tsx @@ -0,0 +1,83 @@ +import { LANGUAGE_MARK, type LanguageMark } from "../../lib/language-marks"; +import { useMotionEnabled } from "../../lib/motion"; + +/** + * The eight languages, as a strip that drifts. + * + * The two groups below this say which of them resolve across files; this is + * only the roll call, so it can be one continuous line. Motion earns its place + * by doing something static markup cannot: a strip that moves reads as a list + * with no end, which is the impression "eight and counting" wants. + * + * A CSS keyframe over `transform`, not a scroll library. The track holds the + * set twice and translates by exactly half its width, so the seam lands where + * the first copy ends and the loop is invisible. `motion-safe:` means reduced + * motion gets the same strip standing still, not an empty one -- the content is + * the point and the drift is decoration. + */ +const LANGUAGES: { name: string; mark: LanguageMark }[] = [ + { name: "TypeScript", mark: LANGUAGE_MARK.typescript }, + { name: "TSX", mark: LANGUAGE_MARK.react }, + { name: "JavaScript", mark: LANGUAGE_MARK.javascript }, + { name: "JSX", mark: LANGUAGE_MARK.react }, + { name: "Go", mark: LANGUAGE_MARK.go }, + { name: "Rust", mark: LANGUAGE_MARK.rust }, + { name: "Python", mark: LANGUAGE_MARK.python }, + { name: "Java", mark: LANGUAGE_MARK.java }, +]; + +export function LanguageMarquee() { + const animate = useMotionEnabled(); + + return ( + // The mask fades both ends into the ground, so items enter and leave + // rather than being clipped mid-glyph against a hard edge. +
+
+ {(animate ? [...LANGUAGES, ...LANGUAGES] : LANGUAGES).map((language, index) => ( +
+
+ ); +} + +function Mark({ + name, + mark, + duplicate, +}: { + name: string; + mark: LanguageMark; + duplicate: boolean; +}) { + return ( +
+ + + + {name} +
+ ); +} diff --git a/apps/web/src/components/landing/Languages.tsx b/apps/web/src/components/landing/Languages.tsx index f7a2438..6423867 100644 --- a/apps/web/src/components/landing/Languages.tsx +++ b/apps/web/src/components/landing/Languages.tsx @@ -1,4 +1,6 @@ +import { cn } from "../../lib/cn"; import { Bezel } from "./Bezel"; +import { LanguageMarquee } from "./LanguageMarquee"; import { Section } from "./Section"; /** @@ -8,19 +10,26 @@ import { Section } from "./Section"; * 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. + * + * The strip above the cards is the roll call and the cards are the caveat. The + * marks live only in the strip: repeating all eight logos inside the cards + * would say the same thing twice on one screen, and the cards are carrying the + * distinction rather than the names. */ const GROUPS = [ { heading: "Resolved across files", detail: - "Imports are followed, so a call reaches a definition in another file.", - languages: ["TypeScript", "TSX", "JavaScript", "JSX"], + "Imports are followed, so a call in one file reaches a definition in another and the edge is drawn exact.", + languages: "TypeScript · TSX · JavaScript · JSX", + tone: "text-confidence-exact", }, { 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"], + "Every function and call site is charted, and a call inside its own file still resolves. Reaching across files is not built yet.", + languages: "Go · Rust · Python · Java", + tone: "text-confidence-name", }, ] as const; @@ -32,26 +41,21 @@ export function Languages() { title="Eight languages, and two different depths." lede="Support is not uniform, so it is not presented as though it were. The rule above this heading is dashed for the same reason the canvas draws a name match dashed: reported, not verified." > -
+ + +
{GROUPS.map((group) => ( - -

+ +

{group.heading}

-

- {group.detail} -

-
    - {group.languages.map((language) => ( -
  • - {language} -
  • - ))} -
+

{group.detail}

+ + {/* Coloured by the tier each group's calls actually produce across + files: exact for the first, name match for the second. The one + place in this section where a colour is a claim. */} +

{group.languages}

))}

diff --git a/apps/web/src/components/landing/Section.tsx b/apps/web/src/components/landing/Section.tsx index 94f8821..6a10750 100644 --- a/apps/web/src/components/landing/Section.tsx +++ b/apps/web/src/components/landing/Section.tsx @@ -47,16 +47,23 @@ export function Section({ -

+ {/* The page's third ink lands here: an eyebrow is a label, not a + claim, so it is the one place colour can be spent without + competing with the three that mean something. */} +

{eyebrow}

-

+ {/* 40-48px against 16px body. The old 30px heading sat too close to + its own lede for the eye to rank them, which is most of why the + page read flat. Bricolage's weight axis carries the rest: 560 + here against 400 in prose. */} +

{title}

{lede === undefined ? null : ( -

+

{lede}

)} diff --git a/apps/web/src/components/landing/Tiers.tsx b/apps/web/src/components/landing/Tiers.tsx index 31650ae..cdbd5a4 100644 --- a/apps/web/src/components/landing/Tiers.tsx +++ b/apps/web/src/components/landing/Tiers.tsx @@ -1,7 +1,9 @@ import type { ResolutionConfidence } from "@funcatlas/shared"; import { cn } from "../../lib/cn"; import { CONFIDENCE, CONFIDENCE_ORDER } from "../../lib/confidence"; +import { useMotionEnabled } from "../../lib/motion"; import { ConfidenceRule } from "../ConfidenceRule"; +import { Fades } from "../animate-ui/primitives/effects/fade"; import { Bezel } from "./Bezel"; import { Section } from "./Section"; @@ -12,8 +14,7 @@ import { Section } from "./Section"; * the product does not draw. */ const CAUSE: Record = { - exact: - "The import was followed to a declaration, and only one function could be the target.", + 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: @@ -21,6 +22,26 @@ const CAUSE: Record = { }; export function Tiers() { + const animate = useMotionEnabled(); + + const cards = CONFIDENCE_ORDER.map((tier) => { + const { label, meaning, textClass } = CONFIDENCE[tier]; + + return ( +
  • + + + +

    {label}

    + +

    {meaning}

    + +

    {CAUSE[tier]}

    +
    +
  • + ); + }); + return (
    + {/* Staggered in tier order, so the three arrive most certain first and + the scale is read in the direction it means something. One gesture + for the set rather than three cards each deciding for themselves -- + the same treatment the coverage chips get. */}
      - {CONFIDENCE_ORDER.map((tier) => { - const { label, meaning, textClass } = CONFIDENCE[tier]; - - return ( -
    • - - - -

      - {label} -

      - -

      {meaning}

      - -

      - {CAUSE[tier]} -

      -
      -
    • - ); - })} + {animate ? ( + + {cards} + + ) : ( + cards + )}

    - 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. + 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/index.css b/apps/web/src/index.css index 248f386..1b68a04 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -25,6 +25,7 @@ --confidence-name: theme("palette.light.confidence.name"); --confidence-unresolved: theme("palette.light.confidence.unresolved"); --on-accent: theme("palette.light.onAccent"); + --spot: theme("palette.light.spot"); /* A genuinely destructive action is red. Deliberately not the unresolved tier, which is a neutral slate because an unresolved call is an honest @@ -45,6 +46,7 @@ --confidence-name: theme("palette.dark.confidence.name"); --confidence-unresolved: theme("palette.dark.confidence.unresolved"); --on-accent: theme("palette.dark.onAccent"); + --spot: theme("palette.dark.spot"); --destructive: oklch(0.68 0.19 25); } @@ -166,6 +168,19 @@ body, user-select: none; } +/* The landing page's language strip. The track holds the set twice and moves + by exactly half its width, so the loop closes on itself invisibly. Applied + through an arbitrary `animate-[...]` utility, which needs the keyframe to + exist in plain CSS. */ +@keyframes marquee { + from { + transform: translateX(0); + } + to { + transform: translateX(-50%); + } +} + @layer base { * { @apply border-border outline-ring/50; diff --git a/apps/web/src/lib/language-marks.ts b/apps/web/src/lib/language-marks.ts new file mode 100644 index 0000000..83da6d4 --- /dev/null +++ b/apps/web/src/lib/language-marks.ts @@ -0,0 +1,56 @@ +/** + * Brand marks for the languages funcatlas reads. + * + * The path data is vendored from simple-icons (CC0-1.0) rather than imported: + * the package has no subpath exports, so using it means pulling a module of + * three thousand icons through the bundler to keep seven. These are static + * strings and they change about once a decade. + * + * Drawn monochrome, inheriting the text colour. Brand colours would break the + * one rule the landing page holds to -- no colour appears that does not carry + * its canvas meaning (UI_GUIDE §1.3) -- and eight logos in eight brand palettes + * beside three confidence tiers is exactly the noise that rule exists to stop. + * The silhouettes stay recognisable without it. + * + * TSX and JSX take React's mark, which is what they are. + * + * Java is drawn here rather than vendored. Oracle's cup is a trademark and + * simple-icons does not ship it; OpenJDK's duke is legally clean but nobody + * reads it as "Java". This is a plain coffee cup -- the thing people actually + * recognise, and not a copy of anyone's logo. + */ +export interface LanguageMark { + title: string; + path: string; +} + +export const LANGUAGE_MARK = { + typescript: { + title: "TypeScript", + path: "M1.125 0C.502 0 0 .502 0 1.125v21.75C0 23.498.502 24 1.125 24h21.75c.623 0 1.125-.502 1.125-1.125V1.125C24 .502 23.498 0 22.875 0zm17.363 9.75c.612 0 1.154.037 1.627.111a6.38 6.38 0 0 1 1.306.34v2.458a3.95 3.95 0 0 0-.643-.361 5.093 5.093 0 0 0-.717-.26 5.453 5.453 0 0 0-1.426-.2c-.3 0-.573.028-.819.086a2.1 2.1 0 0 0-.623.242c-.17.104-.3.229-.393.374a.888.888 0 0 0-.14.49c0 .196.053.373.156.529.104.156.252.304.443.444s.423.276.696.41c.273.135.582.274.926.416.47.197.892.407 1.266.628.374.222.695.473.963.753.268.279.472.598.614.957.142.359.214.776.214 1.253 0 .657-.125 1.21-.373 1.656a3.033 3.033 0 0 1-1.012 1.085 4.38 4.38 0 0 1-1.487.596c-.566.12-1.163.18-1.79.18a9.916 9.916 0 0 1-1.84-.164 5.544 5.544 0 0 1-1.512-.493v-2.63a5.033 5.033 0 0 0 3.237 1.2c.333 0 .624-.03.872-.09.249-.06.456-.144.623-.25.166-.108.29-.234.373-.38a1.023 1.023 0 0 0-.074-1.089 2.12 2.12 0 0 0-.537-.5 5.597 5.597 0 0 0-.807-.444 27.72 27.72 0 0 0-1.007-.436c-.918-.383-1.602-.852-2.053-1.405-.45-.553-.676-1.222-.676-2.005 0-.614.123-1.141.369-1.582.246-.441.58-.804 1.004-1.089a4.494 4.494 0 0 1 1.47-.629 7.536 7.536 0 0 1 1.77-.201zm-15.113.188h9.563v2.166H9.506v9.646H6.789v-9.646H3.375z", + }, + react: { + title: "React", + path: "M14.23 12.004a2.236 2.236 0 0 1-2.235 2.236 2.236 2.236 0 0 1-2.236-2.236 2.236 2.236 0 0 1 2.235-2.236 2.236 2.236 0 0 1 2.236 2.236zm2.648-10.69c-1.346 0-3.107.96-4.888 2.622-1.78-1.653-3.542-2.602-4.887-2.602-.41 0-.783.093-1.106.278-1.375.793-1.683 3.264-.973 6.365C1.98 8.917 0 10.42 0 12.004c0 1.59 1.99 3.097 5.043 4.03-.704 3.113-.39 5.588.988 6.38.32.187.69.275 1.102.275 1.345 0 3.107-.96 4.888-2.624 1.78 1.654 3.542 2.603 4.887 2.603.41 0 .783-.09 1.106-.275 1.374-.792 1.683-3.263.973-6.365C22.02 15.096 24 13.59 24 12.004c0-1.59-1.99-3.097-5.043-4.032.704-3.11.39-5.587-.988-6.38-.318-.184-.688-.277-1.092-.278zm-.005 1.09v.006c.225 0 .406.044.558.127.666.382.955 1.835.73 3.704-.054.46-.142.945-.25 1.44-.96-.236-2.006-.417-3.107-.534-.66-.905-1.345-1.727-2.035-2.447 1.592-1.48 3.087-2.292 4.105-2.295zm-9.77.02c1.012 0 2.514.808 4.11 2.28-.686.72-1.37 1.537-2.02 2.442-1.107.117-2.154.298-3.113.538-.112-.49-.195-.964-.254-1.42-.23-1.868.054-3.32.714-3.707.19-.09.4-.127.563-.132zm4.882 3.05c.455.468.91.992 1.36 1.564-.44-.02-.89-.034-1.345-.034-.46 0-.915.01-1.36.034.44-.572.895-1.096 1.345-1.565zM12 8.1c.74 0 1.477.034 2.202.093.406.582.802 1.203 1.183 1.86.372.64.71 1.29 1.018 1.946-.308.655-.646 1.31-1.013 1.95-.38.66-.773 1.288-1.18 1.87-.728.063-1.466.098-2.21.098-.74 0-1.477-.035-2.202-.093-.406-.582-.802-1.204-1.183-1.86-.372-.64-.71-1.29-1.018-1.946.303-.657.646-1.313 1.013-1.954.38-.66.773-1.286 1.18-1.868.728-.064 1.466-.098 2.21-.098zm-3.635.254c-.24.377-.48.763-.704 1.16-.225.39-.435.782-.635 1.174-.265-.656-.49-1.31-.676-1.947.64-.15 1.315-.283 2.015-.386zm7.26 0c.695.103 1.365.23 2.006.387-.18.632-.405 1.282-.66 1.933-.2-.39-.41-.783-.64-1.174-.225-.392-.465-.774-.705-1.146zm3.063.675c.484.15.944.317 1.375.498 1.732.74 2.852 1.708 2.852 2.476-.005.768-1.125 1.74-2.857 2.475-.42.18-.88.342-1.355.493-.28-.958-.646-1.956-1.1-2.98.45-1.017.81-2.01 1.085-2.964zm-13.395.004c.278.96.645 1.957 1.1 2.98-.45 1.017-.812 2.01-1.086 2.964-.484-.15-.944-.318-1.37-.5-1.732-.737-2.852-1.706-2.852-2.474 0-.768 1.12-1.742 2.852-2.476.42-.18.88-.342 1.356-.494zm11.678 4.28c.265.657.49 1.312.676 1.948-.64.157-1.316.29-2.016.39.24-.375.48-.762.705-1.158.225-.39.435-.788.636-1.18zm-9.945.02c.2.392.41.783.64 1.175.23.39.465.772.705 1.143-.695-.102-1.365-.23-2.006-.386.18-.63.406-1.282.66-1.933zM17.92 16.32c.112.493.2.968.254 1.423.23 1.868-.054 3.32-.714 3.708-.147.09-.338.128-.563.128-1.012 0-2.514-.807-4.11-2.28.686-.72 1.37-1.536 2.02-2.44 1.107-.118 2.154-.3 3.113-.54zm-11.83.01c.96.234 2.006.415 3.107.532.66.905 1.345 1.727 2.035 2.446-1.595 1.483-3.092 2.295-4.11 2.295-.22-.005-.406-.05-.553-.132-.666-.38-.955-1.834-.73-3.703.054-.46.142-.944.25-1.438zm4.56.64c.44.02.89.034 1.345.034.46 0 .915-.01 1.36-.034-.44.572-.895 1.095-1.345 1.565-.455-.47-.91-.993-1.36-1.565z", + }, + javascript: { + title: "JavaScript", + path: "M0 0h24v24H0V0zm22.034 18.276c-.175-1.095-.888-2.015-3.003-2.873-.736-.345-1.554-.585-1.797-1.14-.091-.33-.105-.51-.046-.705.15-.646.915-.84 1.515-.66.39.12.75.42.976.9 1.034-.676 1.034-.676 1.755-1.125-.27-.42-.404-.601-.586-.78-.63-.705-1.469-1.065-2.834-1.034l-.705.089c-.676.165-1.32.525-1.71 1.005-1.14 1.291-.811 3.541.569 4.471 1.365 1.02 3.361 1.244 3.616 2.205.24 1.17-.87 1.545-1.966 1.41-.811-.18-1.26-.586-1.755-1.336l-1.83 1.051c.21.48.45.689.81 1.109 1.74 1.756 6.09 1.666 6.871-1.004.029-.09.24-.705.074-1.65l.046.067zm-8.983-7.245h-2.248c0 1.938-.009 3.864-.009 5.805 0 1.232.063 2.363-.138 2.711-.33.689-1.18.601-1.566.48-.396-.196-.597-.466-.83-.855-.063-.105-.11-.196-.127-.196l-1.825 1.125c.305.63.75 1.172 1.324 1.517.855.51 2.004.675 3.207.405.783-.226 1.458-.691 1.811-1.411.51-.93.402-2.07.397-3.346.012-2.054 0-4.109 0-6.179l.004-.056z", + }, + go: { + title: "Go", + path: "M1.811 10.231c-.047 0-.058-.023-.035-.059l.246-.315c.023-.035.081-.058.128-.058h4.172c.046 0 .058.035.035.07l-.199.303c-.023.036-.082.07-.117.07zM.047 11.306c-.047 0-.059-.023-.035-.058l.245-.316c.023-.035.082-.058.129-.058h5.328c.047 0 .07.035.058.07l-.093.28c-.012.047-.058.07-.105.07zm2.828 1.075c-.047 0-.059-.035-.035-.07l.163-.292c.023-.035.07-.07.117-.07h2.337c.047 0 .07.035.07.082l-.023.28c0 .047-.047.082-.082.082zm12.129-2.36c-.736.187-1.239.327-1.963.514-.176.046-.187.058-.34-.117-.174-.199-.303-.327-.548-.444-.737-.362-1.45-.257-2.115.175-.795.514-1.204 1.274-1.192 2.22.011.935.654 1.706 1.577 1.835.795.105 1.46-.175 1.987-.77.105-.13.198-.27.315-.434H10.47c-.245 0-.304-.152-.222-.35.152-.362.432-.97.596-1.274a.315.315 0 01.292-.187h4.253c-.023.316-.023.631-.07.947a4.983 4.983 0 01-.958 2.29c-.841 1.11-1.94 1.8-3.33 1.986-1.145.152-2.209-.07-3.143-.77-.865-.655-1.356-1.52-1.484-2.595-.152-1.274.222-2.419.993-3.424.83-1.086 1.928-1.776 3.272-2.02 1.098-.2 2.15-.07 3.096.571.62.41 1.063.97 1.356 1.648.07.105.023.164-.117.2m3.868 6.461c-1.064-.024-2.034-.328-2.852-1.029a3.665 3.665 0 01-1.262-2.255c-.21-1.32.152-2.489.947-3.529.853-1.122 1.881-1.706 3.272-1.95 1.192-.21 2.314-.095 3.33.595.923.63 1.496 1.484 1.648 2.605.198 1.578-.257 2.863-1.344 3.962-.771.783-1.718 1.273-2.805 1.495-.315.06-.63.07-.934.106zm2.78-4.72c-.011-.153-.011-.27-.034-.387-.21-1.157-1.274-1.81-2.384-1.554-1.087.245-1.788.935-2.045 2.033-.21.912.234 1.835 1.075 2.21.643.28 1.285.244 1.905-.07.923-.48 1.425-1.228 1.484-2.233z", + }, + rust: { + title: "Rust", + path: "M23.8346 11.7033l-1.0073-.6236a13.7268 13.7268 0 00-.0283-.2936l.8656-.8069a.3483.3483 0 00-.1154-.578l-1.1066-.414a8.4958 8.4958 0 00-.087-.2856l.6904-.9587a.3462.3462 0 00-.2257-.5446l-1.1663-.1894a9.3574 9.3574 0 00-.1407-.2622l.49-1.0761a.3437.3437 0 00-.0274-.3361.3486.3486 0 00-.3006-.154l-1.1845.0416a6.7444 6.7444 0 00-.1873-.2268l.2723-1.153a.3472.3472 0 00-.417-.4172l-1.1532.2724a14.0183 14.0183 0 00-.2278-.1873l.0415-1.1845a.3442.3442 0 00-.49-.328l-1.076.491c-.0872-.0476-.1742-.0952-.2623-.1407l-.1903-1.1673A.3483.3483 0 0016.256.955l-.9597.6905a8.4867 8.4867 0 00-.2855-.086l-.414-1.1066a.3483.3483 0 00-.5781-.1154l-.8069.8666a9.2936 9.2936 0 00-.2936-.0284L12.2946.1683a.3462.3462 0 00-.5892 0l-.6236 1.0073a13.7383 13.7383 0 00-.2936.0284L9.9803.3374a.3462.3462 0 00-.578.1154l-.4141 1.1065c-.0962.0274-.1903.0567-.2855.086L7.744.955a.3483.3483 0 00-.5447.2258L7.009 2.348a9.3574 9.3574 0 00-.2622.1407l-1.0762-.491a.3462.3462 0 00-.49.328l.0416 1.1845a7.9826 7.9826 0 00-.2278.1873L3.8413 3.425a.3472.3472 0 00-.4171.4171l.2713 1.1531c-.0628.075-.1255.1509-.1863.2268l-1.1845-.0415a.3462.3462 0 00-.328.49l.491 1.0761a9.167 9.167 0 00-.1407.2622l-1.1662.1894a.3483.3483 0 00-.2258.5446l.6904.9587a13.303 13.303 0 00-.087.2855l-1.1065.414a.3483.3483 0 00-.1155.5781l.8656.807a9.2936 9.2936 0 00-.0283.2935l-1.0073.6236a.3442.3442 0 000 .5892l1.0073.6236c.008.0982.0182.1964.0283.2936l-.8656.8079a.3462.3462 0 00.1155.578l1.1065.4141c.0273.0962.0567.1914.087.2855l-.6904.9587a.3452.3452 0 00.2268.5447l1.1662.1893c.0456.088.0922.1751.1408.2622l-.491 1.0762a.3462.3462 0 00.328.49l1.1834-.0415c.0618.0769.1235.1528.1873.2277l-.2713 1.1541a.3462.3462 0 00.4171.4161l1.153-.2713c.075.0638.151.1255.2279.1863l-.0415 1.1845a.3442.3442 0 00.49.327l1.0761-.49c.087.0486.1741.0951.2622.1407l.1903 1.1662a.3483.3483 0 00.5447.2268l.9587-.6904a9.299 9.299 0 00.2855.087l.414 1.1066a.3452.3452 0 00.5781.1154l.8079-.8656c.0972.0111.1954.0203.2936.0294l.6236 1.0073a.3472.3472 0 00.5892 0l.6236-1.0073c.0982-.0091.1964-.0183.2936-.0294l.8069.8656a.3483.3483 0 00.578-.1154l.4141-1.1066a8.4626 8.4626 0 00.2855-.087l.9587.6904a.3452.3452 0 00.5447-.2268l.1903-1.1662c.088-.0456.1751-.0931.2622-.1407l1.0762.49a.3472.3472 0 00.49-.327l-.0415-1.1845a6.7267 6.7267 0 00.2267-.1863l1.1531.2713a.3472.3472 0 00.4171-.416l-.2713-1.1542c.0628-.0749.1255-.1508.1863-.2278l1.1845.0415a.3442.3442 0 00.328-.49l-.49-1.076c.0475-.0872.0951-.1742.1407-.2623l1.1662-.1893a.3483.3483 0 00.2258-.5447l-.6904-.9587.087-.2855 1.1066-.414a.3462.3462 0 00.1154-.5781l-.8656-.8079c.0101-.0972.0202-.1954.0283-.2936l1.0073-.6236a.3442.3442 0 000-.5892zm-6.7413 8.3551a.7138.7138 0 01.2986-1.396.714.714 0 11-.2997 1.396zm-.3422-2.3142a.649.649 0 00-.7715.5l-.3573 1.6685c-1.1035.501-2.3285.7795-3.6193.7795a8.7368 8.7368 0 01-3.6951-.814l-.3574-1.6684a.648.648 0 00-.7714-.499l-1.473.3158a8.7216 8.7216 0 01-.7613-.898h7.1676c.081 0 .1356-.0141.1356-.088v-2.536c0-.074-.0536-.0881-.1356-.0881h-2.0966v-1.6077h2.2677c.2065 0 1.1065.0587 1.394 1.2088.0901.3533.2875 1.5044.4232 1.8729.1346.413.6833 1.2381 1.2685 1.2381h3.5716a.7492.7492 0 00.1296-.0131 8.7874 8.7874 0 01-.8119.9526zM6.8369 20.024a.714.714 0 11-.2997-1.396.714.714 0 01.2997 1.396zM4.1177 8.9972a.7137.7137 0 11-1.304.5791.7137.7137 0 011.304-.579zm-.8352 1.9813l1.5347-.6824a.65.65 0 00.33-.8585l-.3158-.7147h1.2432v5.6025H3.5669a8.7753 8.7753 0 01-.2834-3.348zm6.7343-.5437V8.7836h2.9601c.153 0 1.0792.1772 1.0792.8697 0 .575-.7107.7815-1.2948.7815zm10.7574 1.4862c0 .2187-.008.4363-.0243.651h-.9c-.09 0-.1265.0586-.1265.1477v.413c0 .973-.5487 1.1846-1.0296 1.2382-.4576.0517-.9648-.1913-1.0275-.4717-.2704-1.5186-.7198-1.8436-1.4305-2.4034.8817-.5599 1.799-1.386 1.799-2.4915 0-1.1936-.819-1.9458-1.3769-2.3153-.7825-.5163-1.6491-.6195-1.883-.6195H5.4682a8.7651 8.7651 0 014.907-2.7699l1.0974 1.151a.648.648 0 00.9182.0213l1.227-1.1743a8.7753 8.7753 0 016.0044 4.2762l-.8403 1.8982a.652.652 0 00.33.8585l1.6178.7188c.0283.2875.0425.577.0425.8717zm-9.3006-9.5993a.7128.7128 0 11.984 1.0316.7137.7137 0 01-.984-1.0316zm8.3389 6.71a.7107.7107 0 01.9395-.3625.7137.7137 0 11-.9405.3635z", + }, + python: { + title: "Python", + path: "M14.25.18l.9.2.73.26.59.3.45.32.34.34.25.34.16.33.1.3.04.26.02.2-.01.13V8.5l-.05.63-.13.55-.21.46-.26.38-.3.31-.33.25-.35.19-.35.14-.33.1-.3.07-.26.04-.21.02H8.77l-.69.05-.59.14-.5.22-.41.27-.33.32-.27.35-.2.36-.15.37-.1.35-.07.32-.04.27-.02.21v3.06H3.17l-.21-.03-.28-.07-.32-.12-.35-.18-.36-.26-.36-.36-.35-.46-.32-.59-.28-.73-.21-.88-.14-1.05-.05-1.23.06-1.22.16-1.04.24-.87.32-.71.36-.57.4-.44.42-.33.42-.24.4-.16.36-.1.32-.05.24-.01h.16l.06.01h8.16v-.83H6.18l-.01-2.75-.02-.37.05-.34.11-.31.17-.28.25-.26.31-.23.38-.2.44-.18.51-.15.58-.12.64-.1.71-.06.77-.04.84-.02 1.27.05zm-6.3 1.98l-.23.33-.08.41.08.41.23.34.33.22.41.09.41-.09.33-.22.23-.34.08-.41-.08-.41-.23-.33-.33-.22-.41-.09-.41.09zm13.09 3.95l.28.06.32.12.35.18.36.27.36.35.35.47.32.59.28.73.21.88.14 1.04.05 1.23-.06 1.23-.16 1.04-.24.86-.32.71-.36.57-.4.45-.42.33-.42.24-.4.16-.36.09-.32.05-.24.02-.16-.01h-8.22v.82h5.84l.01 2.76.02.36-.05.34-.11.31-.17.29-.25.25-.31.24-.38.2-.44.17-.51.15-.58.13-.64.09-.71.07-.77.04-.84.01-1.27-.04-1.07-.14-.9-.2-.73-.25-.59-.3-.45-.33-.34-.34-.25-.34-.16-.33-.1-.3-.04-.25-.02-.2.01-.13v-5.34l.05-.64.13-.54.21-.46.26-.38.3-.32.33-.24.35-.2.35-.14.33-.1.3-.06.26-.04.21-.02.13-.01h5.84l.69-.05.59-.14.5-.21.41-.28.33-.32.27-.35.2-.36.15-.36.1-.35.07-.32.04-.28.02-.21V6.07h2.09l.14.01zm-6.47 14.25l-.23.33-.08.41.08.41.23.33.33.23.41.08.41-.08.33-.23.23-.33.08-.41-.08-.41-.23-.33-.33-.23-.41-.08-.41.08z", + }, + java: { + title: "Java", + path: "M8.1 2.2c.75.9.75 1.85 0 2.75-.6.72-.6 1.35 0 2.05H6.6c-.75-.9-.75-1.85 0-2.75.6-.72.6-1.35 0-2.05zm3.9 0c.75.9.75 1.85 0 2.75-.6.72-.6 1.35 0 2.05h-1.5c-.75-.9-.75-1.85 0-2.75.6-.72.6-1.35 0-2.05zm3.9 0c.75.9.75 1.85 0 2.75-.6.72-.6 1.35 0 2.05h-1.5c-.75-.9-.75-1.85 0-2.75.6-.72.6-1.35 0-2.05zM3.6 9.1h13.2v6.4a4.6 4.6 0 0 1-4.6 4.6H8.2a4.6 4.6 0 0 1-4.6-4.6V9.1zM17.9 10.2h.9a3.2 3.2 0 0 1 0 6.4h-1.4a6 6 0 0 0 .38-1.9h1.02a1.3 1.3 0 0 0 0-2.6h-.9v-1.9zM2.4 21.1h15.6v1.8H2.4z", + }, +} as const satisfies Record; diff --git a/apps/web/src/lib/tokens.ts b/apps/web/src/lib/tokens.ts index 825f489..f2ae8ab 100644 --- a/apps/web/src/lib/tokens.ts +++ b/apps/web/src/lib/tokens.ts @@ -32,6 +32,20 @@ export interface Palette { }; /** Text drawn on top of `confidence.exact`, which doubles as the accent. */ onAccent: string; + /** + * A third ink, and the only colour in here that carries no canvas meaning. + * + * The two spot inks plus a neutral say everything resolution has to say, but + * on a page three thousand pixels long they say it in two hues and the page + * reads flat. A map is drawn in three inks: land, route, and water. This is + * the water. + * + * **Landing page only.** It appears on eyebrows, section detail and hover + * states, and never on the canvas -- there, a colour that means nothing would + * be competing with three that mean something. It sits at 174°, a clear 53° + * off `exact` and 153° off `name`, so it cannot be misread as either. + */ + spot: string; } /** @@ -75,6 +89,7 @@ const ULTRAMARINE: Palette = { unresolved: "#767c92", }, onAccent: "#080a14", + spot: "#2ad4c4", }; /** @@ -100,6 +115,8 @@ const LETTERPRESS: Palette = { unresolved: "#71768a", }, onAccent: "#ffffff", + /** Darker than the dark theme's, to clear 4.5:1 as text on paper. */ + spot: "#0b7268", }; export const PALETTE: Record = { diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts index 2c18eb2..36a59a9 100644 --- a/apps/web/tailwind.config.ts +++ b/apps/web/tailwind.config.ts @@ -26,6 +26,9 @@ export default { DEFAULT: "var(--ink)", muted: "var(--ink-muted)", }, + // The landing page's third ink. Not a confidence tier, and deliberately + // named so it cannot be mistaken for one. + spot: "var(--spot)", confidence: { exact: "var(--confidence-exact)", name: "var(--confidence-name)", diff --git a/docs/UI_GUIDE.md b/docs/UI_GUIDE.md index 765b961..7c81bd8 100644 --- a/docs/UI_GUIDE.md +++ b/docs/UI_GUIDE.md @@ -65,6 +65,13 @@ what a press would do on 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. | +**A third ink, `spot`, for the landing page only** — `#2ad4c4` dark, `#0b7268` light. It carries no +canvas meaning, which is why it is named `spot` and not a tier: on a page three thousand pixels long +two hues read flat, and a map is drawn in three inks rather than two. It sits at 174°, a clear 53° +off `exact` and 153° off `name`, so it cannot be misread as either, and it never appears on the +canvas — there a colour that means nothing would compete with three that mean something. Used on +eyebrows, section detail and hover states. + Two rules bind both palettes, and `confidence.test.ts` enforces them: 1. **`unresolved` is never a chromatic red.** Colouring an honest admission as a failure tells the From e732b1abfaf489688136e4d7c069f10fa6c4d12c Mon Sep 17 00:00:00 2001 From: Aditya Rana Date: Sat, 29 Aug 2026 17:19:30 +0530 Subject: [PATCH 8/9] close the language strip's loop The strip visibly restarted once a cycle. `gap-14` puts space *between* items, so a 16-item track carries 15 gaps: half its width is one set plus seven and a half gaps, while a seamless loop needs one set plus a whole one. Translating -50% therefore landed 28px short -- half a gap -- and the jump back was the reset. Each item carries its own trailing space now, so the two halves measure identically and -50% is exactly one set. Verified rather than eyeballed: both halves 1072.8px of a 2145.6px track, drift 0. --- .../components/landing/LanguageMarquee.tsx | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/landing/LanguageMarquee.tsx b/apps/web/src/components/landing/LanguageMarquee.tsx index 3cf1e82..ad4c46b 100644 --- a/apps/web/src/components/landing/LanguageMarquee.tsx +++ b/apps/web/src/components/landing/LanguageMarquee.tsx @@ -1,3 +1,4 @@ +import { cn } from "../../lib/cn"; import { LANGUAGE_MARK, type LanguageMark } from "../../lib/language-marks"; import { useMotionEnabled } from "../../lib/motion"; @@ -37,10 +38,17 @@ export function LanguageMarquee() { role="list" aria-label="Languages funcatlas reads" > + {/* The spacing is per-item padding, not `gap`, and that is the whole + trick. `gap` sits *between* items, so a 16-item track has 15 of them: + half the width is one set plus seven and a half gaps, while a + seamless loop needs one set plus a whole one. Translating -50% then + lands half a gap short and the strip visibly jumps every cycle. With + each item carrying its own trailing space the two halves are exactly + equal and -50% is exactly one set. */}
    @@ -49,6 +57,7 @@ export function LanguageMarquee() { key={`${language.name}-${index}`} name={language.name} mark={language.mark} + spaced={animate} // The second copy exists only to make the loop seamless, so it is // hidden from the list a screen reader walks. duplicate={index >= LANGUAGES.length} @@ -63,16 +72,23 @@ function Mark({ name, mark, duplicate, + spaced, }: { name: string; mark: LanguageMark; duplicate: boolean; + /** Trailing space carried by the item rather than by a `gap`, so the two + * halves of the looping track measure identically. */ + spaced: boolean; }) { return (
    From e5cc795d73d411052b1be2874b334cfc5d8cbc9e Mon Sep 17 00:00:00 2001 From: Aditya Rana Date: Sat, 29 Aug 2026 17:20:03 +0530 Subject: [PATCH 9/9] drop the unwired canvas section It points at two screenshots that do not exist and nothing renders it. Dead code should not merge, and rebuilding it is ten lines once there is an image to put in it -- `git show c9ad0a2` has the version with the reasoning in it. --- .../web/src/components/landing/CanvasShot.tsx | 49 ------------------- 1 file changed, 49 deletions(-) delete mode 100644 apps/web/src/components/landing/CanvasShot.tsx diff --git a/apps/web/src/components/landing/CanvasShot.tsx b/apps/web/src/components/landing/CanvasShot.tsx deleted file mode 100644 index ec4698c..0000000 --- a/apps/web/src/components/landing/CanvasShot.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { Bezel } from "./Bezel"; -import { Section } from "./Section"; - -/** - * The product, photographed. - * - * `UI_GUIDE.md` §3.1 forbids the *hero* being a screenshot, and it stays a - * live drawing. This is a different argument: there is no hosted instance, so - * a visitor cannot reach the canvas by clicking anything. Showing it is the - * only way they ever see what the tool actually does, and the reason the - * walkthrough section was cut -- "they can just go and use it" -- stopped being - * true when the deploy became web-only. - * - * A real capture rather than a drawing, because a drawing of your own product - * is a claim and a screenshot is evidence. - * - * Two files and a ``, not one image with a filter: the two themes are - * genuinely different palettes (`lib/tokens.ts`), and a dark screenshot - * inverted is neither of them. `media` here is the OS preference rather than - * the in-app toggle, which is a known limit -- switching the theme by hand does - * not switch this image. Wiring it to the toggle would mean reading theme state - * into a component that otherwise needs none, for a picture most readers scroll - * past once. - */ -export function CanvasShot() { - return ( -
    - - - -
    The funcatlas canvas: a file tree on the left, a file card on the canvas, and a mind-map of its functions branching to the right with solid, dashed and dotted edges. - - - - ); -}