# MaleniaVPN — Frontend Agent Guidance
## Project Overview
MaleniaVPN (malenia) is a VPN purchase service built around a **pay-for-device** model:
one flat rate per device ("seat"), no tiers. The frontend is a **Russian-language** web
app living in `frontend/` on top of a FastAPI backend. This file is the canonical
guidance for AI assistants working on it.
The service is run by a two-person studio. The brand is minimalist and archival
(cream/ink/gold, extreme whitespace), but the *voice* is honest, ironic and sometimes
snarky. Keep the UI elegant; keep the copy human.
### Screens
| Screen | Route | Access | Source of truth |
|---|---|---|---|
| Landing | `/` | Public | `.ref/landing.html` (existing hosted page) |
| Plan calculator | `/plan` | Public (auth needed only to confirm an order) | `.ref/calculator.html` |
| Login / Signup | `/login` | Public | `.ref/login.html` |
| Account | `/account` | Protected (auth required) | `.ref/account.html` |
## Authoritative Source Directories (read-only)
Never edit these; they are reference material.
| Path | What it is |
| --- | --- |
| `.info/openapi.json` | **The** backend API contract. All types below mirror it. |
| `.info/ToV.md` | Company values and tone of voice (Russian). |
| `.design/DESIGN.md`, `.design/brand.json` | Visual identity: palette, typography, layout posture. |
| `.design/fonts/` | Self-hosted `.woff2` + `fonts.css`. **Latin-only subsets — see Typography.** |
| `.design/logos/` | `favicon-0.ico` — the favicon. |
| `.ref/*.html` | Ready-made page references: full markup, styles and behaviors. Refactor into React, do not copy verbatim. |
The `.design/system/` folder (tokens, `kit.html`, artifacts) is brand-system material;
at page build time use the **canonical tokens below** rather than the `--brand-*`
derived tokens.
## Tech Stack
- Vite + React 19 + TypeScript (`"strict": true`)
- React Router v6
- Zustand — **auth tokens and UI state only**, never server-fetched data
- Native `fetch` API client (no axios)
- Vanilla CSS with custom properties (no Tailwind, no CSS-in-JS, no CSS frameworks)
- Vitest + React Testing Library for tests
- ESLint (typescript/react/hooks) + Prettier (print width 100, single quotes, trailing commas, semicolons)
## Design System
### Canonical CSS tokens (`src/styles/tokens.css`)
| Token | Value | Usage |
| --- | --- | --- |
| `--bg` | `#faf7f2` | Page background (cream) |
| `--surface` | `#f5f0e8` | Cards, raised blocks (warm white) |
| `--fg` | `#1a1610` | Primary text, primary buttons (ink) |
| `--muted` | `#3d3428` | Secondary text (ink-soft) |
| `--fg-muted` | `#7a6e5f` | Tertiary text, captions (ink-muted) |
| `--accent` | `#c9a84c` | Highlights, active states, emblems (gold) |
| `--accent-light` | `#e8ca7a` | Hover glow, gold-light |
| `--accent-dim` | `#8b6914` | Emphasis inside quote text |
| `--hairline` | `rgba(26, 22, 16, 0.10)` | Faint structural rules, grid gaps |
| `--hairline-strong` | `rgba(26, 22, 16, 0.18)` | Input underlines, card borders |
| `--border` | `rgba(201, 168, 76, 0.25)` | Landing header/footer rules |
| `--font-display` | `'Cormorant Garamond', Georgia, serif` | Headings, numerals |
| `--font-body` | `'Tenor Sans', 'Helvetica Neue', Arial, sans-serif` | Body, labels, buttons |
| `--radius` | `8px` | All corners |
| `--baseline` | `8px` | Spacing unit |
### Layout rules
- **8px baseline grid** for spacing and padding; generous padding everywhere.
- **Extreme whitespace + centered alignment**. One primary purpose per screen.
- **1px hairlines** for structural division (horizontal rules, stat strips divided by
grid `gap: 1px` over a hairline background).
- Buttons: uppercase, letterspacing `0.16–0.22em`, 10px font-size, `--radius` corners.
Primary = `--fg` background / `--bg` text; hover → `--accent`. Secondary = transparent,
`1px solid var(--hairline-strong)`.
- Inputs: underline style (`border-bottom: 1px solid var(--hairline-strong)`),
transparent background, gold animated focus line (`scaleX`), no boxed inputs.
### Signature UI details (all screens)
- **Custom cursor**: gold dot (6px) + trailing ring (32px) that grows to 48px over
interactive elements. Hide on touch devices (`@media (hover: none), (pointer: coarse)`)
— these also need `* { cursor: auto !important; }`.
- **Grain overlay**: fixed `body::before` with the fractal-noise SVG data-URI, `opacity: 0.035`, `mix-blend-mode: multiply`.
- **Fade-up reveals**: `.reveal { opacity: 0; transform: translateY(14px); }` → `.is-in`;
staggered `transition-delay` via IntersectionObserver.
- **Logo**: `Malenia` — Cormorant Garamond, uppercase,
`letter-spacing: 0.35em`; the `e` is `var(--accent)`.
- **Emblem**: the 90×90 geometric SVG (ring + diamond + cardinal marks), gold stroke.
- Respect `prefers-reduced-motion`: disable cursor animation, grain is fine (static),
use plain opacity transitions without translate.
### Typography
- **Display/headings:** Cormorant Garamond (weights 400, 500, 600 + italics). Large
sizes with `line-height 1.1–1.25` and slight negative `letter-spacing`.
- **Body:** Tenor Sans.
- **CRITICAL — Cyrillic fonts:** the self-hosted `.design/fonts/*.woff2` subsets have
`unicode-range` for **Latin Extended only** (no `U+0400–04FF`) and will render Russian
text with fallback glyphs. The interface is Russian, so load fonts from **Google Fonts**
exactly as the `.ref` pages do:
`https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600&family=Tenor+Sans&display=swap`
(with `preconnect` links). Self-hosting is only acceptable after adding Cyrillic subsets.
## Brand Voice & Copy (from `.info/ToV.md`)
- All interface copy is **Russian**. No placeholders left in English.
- Tone: restrained, archival and minimal on the surface; honest, ironic, occasionally
snarky underneath. Plain wording, no corporate clichés, no hype ("revolutionize",
"unleash the power", "best-in-class" are banned).
- Service attitude: "включил и забыл" (set-and-forget), transparency about incidents,
engineering-first, anti-censorship.
- Tagline: **«Свобода. Практика. Простота.»**
- Landing quote block: rotate randomly through the Russian quotes array from
`.ref/landing.html`; render the second line as an italic ``.
## Routing (`src/App.tsx`)
| Path | Element | Notes |
| --- | --- | --- |
| `/` | `Landing` | Existing marketing page |
| `/login` | `LoginPage` | Credentials only. `?returnTo=` preserved for post-auth redirect |
| `/plan` | `PlanPage` | Calculator + confirm → checkout |
| `/account` | `AccountPage` | Protected: redirect to `/login` when unauthenticated |
## Backend API (mirrors `.info/openapi.json` — the only source of truth)
Base: backend routes have **no `/api/` prefix**. In dev, `vite.config.ts` proxies
`/auth`, `/plans`, `/orders`, `/users`, `/payments` to `http://127.0.0.1:8000`.
In prod the reverse proxy serves same-origin. Never hardcode absolute URLs.
### Types (`src/types.ts`)
```ts
type Provider = 'credentials' | 'telegram' | 'api';
interface UserInfo {
username: string | null;
telegram_id: string | null;
referal_code: string;
}
interface LoginResponse { // POST /auth/login
access_token: string;
refresh_token: string;
expires_at: number; // epoch seconds
user: UserInfo;
}
interface Tokens { // POST /auth/refresh
access_token: string;
refresh_token: string;
expires_at: number;
}
interface AddonData { // inside GET /plans/
id: string;
name: string;
price: number;
free_threshold: number;
}
interface PricingPlans { // GET /plans/
device_price: number;
addons: AddonData[];
}
interface OrderDetails { // POST /orders/checkout body
devices: number;
addons: string[]; // addon ids
duration_days: number;
}
interface CheckoutResponse { // POST /orders/checkout → 201
order_id: string;
total_amount: number;
bonus_paid: number;
amount_to_pay: number;
payment_link: string | null;
}
interface Device { // GET /users/subscription/hwid
os: string | null;
model: string | null;
client: string | null;
hwid: string;
}
```
### Endpoint table
| Method | Path | Auth | Purpose |
|---|---|---|---|
| POST | `/auth/signup` | – | Register, body `{provider, username?, password?, telegram_id?, referal_code?}` |
| POST | `/auth/login` | – | Login, body `{provider:'credentials', username, password}` |
| POST | `/auth/refresh?refresh_token=&iss=` | – | New token pair; `iss` is the provider |
| GET | `/plans/` | – | Pricing: `device_price` + addons |
| POST | `/orders/checkout` | yes | Create order → `payment_link` |
| GET | `/users/me` | yes | `UserInfo` |
| GET | `/users/subscription` | yes | Subscription state (schema is `{}` — shape to be confirmed at implementation) |
| GET | `/users/subscription/hwid` | yes | `Device[]` |
| DELETE | `/users/subscription/hwid?hwid=` | yes | Unlink a device |
| POST | `/payments/pally/result` | – | Payment callback — backend-internal, frontend never calls it |
### Auth flow
- **Credentials only.** Sign up / sign in are username + password. Telegram auth is
explicitly **out of scope** for now (linking ships later). The API *accepts*
`provider: telegram`, but the frontend must only use `credentials`.
- Tokens stored via a `useLocalStorage`-style hook under keys
`malenia.access`, `malenia.refresh`, `malenia.expires_at` (epoch seconds).
- The API client injects `Authorization: Bearer ` on every request.
- On **401**: try `POST /auth/refresh?refresh_token=…&iss=credentials` once; on success
retry the original request, on failure clear tokens and redirect to `/login`.
Guard against refresh loops.
- **Signup** may include `referal_code` — expose an optional field on the signup form
and pass it through.
### API client conventions
- Generic `request()` helper; throws `Error` with the backend `detail` for non-2xx.
- 201 responses return parsed JSON; no 204s in this API.
- Errors surface as **toast notifications** (inline errors only for form validation).
## Pages — implementation notes (from `.ref/`)
### Landing (`/`) — `.ref/landing.html`
Keep the overall structure and design. **Apply the existing page's corrections:**
- **Header** gains navigation links (`