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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
- [System Architecture](#system-architecture)
- [Production DevOps Platform](#production-devops-platform)
- [Frontend Architecture](#frontend-architecture)
- [Landing Page 3D Experience](#landing-page-3d-experience)
- [Multi-Format Ingestion & Rendering](#multi-format-ingestion--rendering)
- [Backend Architecture](#backend-architecture)
- [Document Storage & Data Model](#document-storage--data-model)
Expand Down Expand Up @@ -368,6 +369,74 @@ graph TB

---

## Landing Page 3D Experience

The marketing landing page (`frontend/src/pages/LandingPage.js`) is a cinematic, **scroll-reactive 3D experience** rendered with **Three.js** through **React Three Fiber (R3F)** and **Drei**. The scene lives in `frontend/src/components/three/HeroExperience.js` and is lazy-loaded (via `React.lazy`) so the first paint of the hero copy never blocks on the Three.js bundle (~240 kB gzipped, split into its own chunk).

### Design goals

- **One continuous background, not two boxes.** A single WebGL canvas sits behind the *entire* page and the camera travels through it as the user scrolls — replacing the earlier approach of two isolated canvases (hero + CTA).
- **Readable content over a moving scene.** Content surfaces are **solid dark panels**; the 3D shows through the open areas (hero, CTA, inter-section gaps), keeping copy crisp.
- **Zero binary assets.** Every shape is primitive geometry, lighting is rendered into an in-memory cube with `<Lightformer>` panels (no `.hdr`), and particles use `<Sparkles>`. Nothing extra ships in the bundle.
- **Graceful degradation** for reduced-motion, no-WebGL, and low-power devices.

### Compositing: sticky canvas + negative-margin overlay

The page can't use `position: fixed` for the background because the app's route wrapper (`.page-transition`) applies `will-change: transform`, which creates a containing block that would trap a fixed child. Instead the canvas is pinned with `position: sticky`:

```
LandingPage root (position: relative; overflow-x: clip) ← clip, NOT hidden,
│ so it isn't a scroll
│ container (sticky-safe)
├── 3D background (position: sticky; top: 0; height: 100svh; z-index: 0)
│ └── <HeroExperience /> ← single full-viewport WebGL canvas
│ └── vignette + film-grain scrims (legibility)
└── Foreground content (position: relative; z-index: 1; margin-top: -100svh)
└── hero · stats · features · … · CTA (solid panels, transparent sections)
```

The foreground is pulled up over the sticky background with `margin-top: -100svh`, so the background stays pinned to the viewport for the whole scroll while content scrolls on top of it.

### Scroll-driven camera

Page scroll is normalized to a `0 → 1` progress value, **stored in a ref** and updated by a `passive` scroll listener — it is **never** put in React state, so scrolling triggers **no re-renders**. A `ScrollCamera` component reads that ref every frame (`useFrame`) and lerps the camera down a vertical column of objects:

- **Descend** — `camera.y` travels from `0` (hero core) to `-9` (CTA core), passing frosted-glass "document" panels distributed along the way.
- **Breathe** — `camera.z` eases back at the scroll midpoint (`sin(p·π)`) for a cinematic dolly.
- **Sway** — a small lateral `camera.x` drift, plus pointer-parallax on the whole group (`Rig`).

```mermaid
flowchart LR
SCROLL[window scroll] -->|passive listener| REF[scrollRef 0..1]
REF -->|read each frame| UF[useFrame / ScrollCamera]
UF --> CAM[camera dolly y/z/x + lookAt]
CAM --> R[R3F renders single sticky canvas]
PTR[pointermove] -->|parallax| RIG[Rig group rotation] --> R
```

### Scene composition (`HeroExperience.js`)

- **Two glowing cores** (`<MeshDistortMaterial>` icosahedra + additive halo) anchor the hero (top) and CTA (bottom).
- **Frosted-glass panels** (`<RoundedBox>` with `meshPhysicalMaterial` transmission) drift along the column via `<Float>`.
- **Metallic accents** (octahedron / dodecahedron / torus-knot) and a `<Sparkles>` field add depth.
- **Procedural lighting** via `<Environment>` + `<Lightformer>` rectangles/ring rendered to an in-memory cube — no image-based lighting files.

### Capability detection & fallbacks

| Condition | Behavior |
| --- | --- |
| `prefers-reduced-motion` | All animation frozen; render loop switches to `frameloop="demand"`; camera holds its initial framing. |
| No WebGL context | Canvas is never mounted; a pure-CSS warm-glow backdrop (`Fallback`) is shown so the layout/copy stay intact. |
| Low-power device (≤4 cores / ≤4 GB) | Lighter scene — fewer panels/accents, no shadows or antialias, lower DPR, smaller environment resolution. |
| GPU context loss | `webglcontextlost` is `preventDefault`ed so the browser can restore instead of blanking; a `CanvasErrorBoundary` swaps in the CSS fallback on hard failure. |

### Testing

Because jsdom has no WebGL, the snapshot suite (`frontend/src/__tests__/07_snapshots.test.js`) mocks `HeroExperience` with a deterministic stub and polyfills the browser APIs the page relies on (`matchMedia`, `IntersectionObserver`, `ResizeObserver`, scroll helpers) in `setupTests.js`. This lets the full `LandingPage` render and snapshot deterministically without a GPU.

---

## Multi-Format Ingestion & Rendering

DocuThinker extracts and renders **many** document formats entirely in the browser. All of the parsing happens client-side in `frontend/src/components/UploadModal.js` (`extractDocument`), and the viewer in `frontend/src/pages/Home.js` picks a render path from the resulting `fileType`. The guiding principle is a clean separation between **what the AI sees** and **what the user sees**:
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,9 @@ DocuThinker is built with **120+ technologies** spanning frontend, backend, AI/M
- **Material-UI (MUI) 6**: React component library for UI development.
- **Tailwind CSS**: Utility-first CSS framework for rapid styling.
- **Emotion**: CSS-in-JS styling engine (used by MUI).
- **Three.js**: WebGL 3D engine powering the landing page's procedural scene.
- **React Three Fiber (`@react-three/fiber`)**: React renderer for Three.js — declarative scene graph for the landing hero.
- **Drei (`@react-three/drei`)**: R3F helpers (`Float`, `RoundedBox`, `MeshDistortMaterial`, `Environment`, `Lightformer`, `Sparkles`) used to build the scene without binary assets.
- **Axios**: Promise-based HTTP client for API requests.
- **React Router DOM 6**: Declarative client-side routing.
- **Context API**: Built-in React state management.
Expand Down Expand Up @@ -373,6 +376,8 @@ DocuThinker is built with **120+ technologies** spanning frontend, backend, AI/M
<img src="https://img.shields.io/badge/Material--UI_6-0081CB?style=for-the-badge&logo=mui&logoColor=white" alt="Material UI" />
<img src="https://img.shields.io/badge/TailwindCSS-38B2AC?style=for-the-badge&logo=tailwind-css&logoColor=white" alt="Tailwind CSS" />
<img src="https://img.shields.io/badge/Emotion-DB7093?style=for-the-badge&logo=emotion&logoColor=white" alt="Emotion" />
<img src="https://img.shields.io/badge/Three.js-000000?style=for-the-badge&logo=threedotjs&logoColor=white" alt="Three.js" />
<img src="https://img.shields.io/badge/React_Three_Fiber-20232A?style=for-the-badge&logo=reactos&logoColor=61DAFB" alt="React Three Fiber" />
<img src="https://img.shields.io/badge/React_Router-CA4245?style=for-the-badge&logo=react-router&logoColor=white" alt="React Router" />
<img src="https://img.shields.io/badge/Axios-5A29E4?style=for-the-badge&logo=axios&logoColor=white" alt="Axios" />
<img src="https://img.shields.io/badge/Webpack-8DD6F9?style=for-the-badge&logo=webpack&logoColor=white" alt="Webpack" />
Expand Down Expand Up @@ -540,6 +545,8 @@ DocuThinker is built with **120+ technologies** spanning frontend, backend, AI/M
<img src="images/landing.png" alt="Landing Page" width="100%" style="border-radius: 8px">
</p>

The landing page is an interactive **3D experience** built with **Three.js** via **React Three Fiber** and **Drei**. A single full-page WebGL canvas sits behind the page and its camera dollies through the procedural scene as you scroll. It also degrades gracefully — honoring `prefers-reduced-motion` and falling back to a pure-CSS backdrop where WebGL isn't available.

### **Document Upload Page**

<p align="center">
Expand Down
116 changes: 116 additions & 0 deletions frontend/src/__tests__/07_snapshots.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/**
* Snapshot coverage for the presentational surface of the app.
*
* These lock in the rendered DOM for the static pages, the auth pages, the
* chrome (Navbar/Footer/Spinner), and the redesigned 3D LandingPage. Heavy
* runtime-only widgets (upload/chat modals that pull in axios + firebase +
* file APIs) are intentionally excluded — they have no deterministic initial
* render to snapshot.
*
* To intentionally update a snapshot after a UI change: `npx jest -u`.
*/
import React from "react";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { renderWithRouter } from "../test-utils";

// The hero's WebGL scene can't run in jsdom; swap in a deterministic stub so
// LandingPage snapshots are stable and don't depend on three.js / a GPU.
jest.mock("../components/three/HeroExperience", () => ({
__esModule: true,
default: () => <div data-testid="hero-experience-mock" />,
}));

// WebAuthn/passkey helpers touch browser-only APIs missing in jsdom; stub them
// so the auth pages render their default (no-passkey) state.
jest.mock("../utils/passkeys", () => ({
__esModule: true,
isPasskeySupported: () => false,
isPlatformAuthenticatorAvailable: async () => false,
authenticateWithPasskey: jest.fn(),
registerPasskey: jest.fn(),
}));

import Spinner from "../components/Spinner";
import Footer from "../components/Footer";
import Navbar from "../components/Navbar";
import NotFoundPage from "../pages/NotFoundPage";
import PrivacyPolicy from "../pages/PrivacyPolicy";
import TermsOfService from "../pages/TermsOfService";
import HowToUse from "../pages/HowToUse";
import Login from "../pages/Login";
import Register from "../pages/Register";
import ForgotPassword from "../pages/ForgotPassword";
import LandingPage from "../pages/LandingPage";

describe("Component snapshots", () => {
it("Spinner renders consistently", () => {
const { container } = render(<Spinner />);
expect(container).toMatchSnapshot();
});

it("Footer renders consistently", () => {
const { container } = renderWithRouter(<Footer />);
expect(container).toMatchSnapshot();
});

it("Navbar renders consistently", () => {
const { container } = renderWithRouter(
<Navbar theme="light" onThemeToggle={() => {}} />,
);
expect(container).toMatchSnapshot();
});
});

describe("Static page snapshots", () => {
it("NotFoundPage renders consistently", () => {
const { container } = renderWithRouter(<NotFoundPage theme="light" />);
expect(container).toMatchSnapshot();
});

it("PrivacyPolicy renders consistently", () => {
const { container } = renderWithRouter(<PrivacyPolicy theme="light" />);
expect(container).toMatchSnapshot();
});

it("TermsOfService renders consistently", () => {
const { container } = renderWithRouter(<TermsOfService theme="light" />);
expect(container).toMatchSnapshot();
});

it("HowToUse renders consistently", () => {
const { container } = renderWithRouter(<HowToUse theme="light" />);
expect(container).toMatchSnapshot();
});
});

describe("Auth page snapshots", () => {
it("Login renders consistently", () => {
const { container } = renderWithRouter(<Login theme="light" />);
expect(container).toMatchSnapshot();
});

it("Register renders consistently", () => {
const { container } = renderWithRouter(<Register theme="light" />);
expect(container).toMatchSnapshot();
});

it("ForgotPassword renders consistently", () => {
const { container } = renderWithRouter(<ForgotPassword theme="light" />);
expect(container).toMatchSnapshot();
});
});

describe("LandingPage snapshot", () => {
it("renders the full landing page (3D background stubbed)", async () => {
const { container } = render(
<MemoryRouter>
<LandingPage />
</MemoryRouter>,
);
// Wait for the lazily-imported (mocked) 3D background to resolve so the
// snapshot captures the settled tree rather than the Suspense fallback.
await screen.findByTestId("hero-experience-mock");
expect(container).toMatchSnapshot();
});
});
Loading
Loading