<!-- source: openspec/current/web.md -->

---
title: Web
status: stable
owner: pavel
audience: [pavel, ai, engineer]
tldr: Public marketing site at rapoport.studio — sells the studio curation model and AI-orchestration thesis; intakes new projects.
---
# Web

> Public marketing site at `rapoport.studio`. Sells the studio's curation model and the AI-orchestration thesis; intakes new projects.

---

## Where web lives

- Source: `apps/web/`
- Deploy target: Cloudflare Worker `rapoport-web`
- Domain: `rapoport.studio` (apex)
- Framework: Next.js 16 (App Router) + React 19 + Tailwind CSS 4
- Build: `@opennextjs/cloudflare` (compatibility date `2024-12-30`, `nodejs_compat` flag)
- Scripts: `dev` (next dev), `build`, `start`, `lint`, `typecheck`, `test`
- Test runtime: vitest with `environment: "jsdom"` for component tests, `node` for route handlers

---

## Goal

Convert qualified visitors into qualified intakes. "Qualified" means: budgets that fit (€50,000+ engagements, six-month minimum), problem types that fit (digital platforms with AI orchestration), and decision-makers reachable for follow-up.

The site does not sell Pavel as a freelancer. It sells the studio's curation model: every engagement is staffed with a hand-picked team drawn from a vetted network. The network is a first-class brand asset, not a backstage detail.

The site also operates a parallel **Founder Track** funnel for startup founders, with Israel as the primary geo-ICP (Moldova + Romania remain primary for the Client Track per Q-FUNNEL-1 from `archive/2026-05-06-expand-home-with-services-and-community/`). Founder Track does not introduce a new service slug; it presents the existing five services (`discovery / migration / modular / platform / subscription`) with founder-friendly framing on `/founders` and through the homepage `ForFounders` block. Cold-pitch surfaces (Hero, ForFounders block, `/founders` hero, contact CTA) are constrained by the forbidden-vocabulary list captured in `platform.md § Cold-pitch vocabulary constraint` to survive the Israeli founder ICP's vocabulary expectations.

---

## Architecture

```
apps/web/
├── src/
│   ├── app/
│   │   ├── [locale]/
│   │   │   ├── (marketing)/
│   │   │   │   ├── layout.tsx            # marketing chrome wrapper
│   │   │   │   ├── page.tsx              # /
│   │   │   │   ├── work/page.tsx         # /work (index only; /work/[slug] deferred)
│   │   │   │   ├── network/page.tsx      # /network
│   │   │   │   ├── services/page.tsx     # /services
│   │   │   │   ├── lab/page.tsx          # /lab (index only; /lab/[slug] deferred)
│   │   │   │   └── start-project/page.tsx # /start-project
│   │   │   └── layout.tsx                # locale wrapper
│   │   ├── api/
│   │   │   ├── intake/route.ts           # POST forwarder to /api/studio/leads
│   │   │   └── health/route.ts           # GET /api/health (returns {status:"ok"})
│   │   ├── fonts.ts                      # next/font configuration
│   │   ├── fonts/                        # self-hosted variable woff2 assets
│   │   ├── layout.tsx                    # root layout
│   │   └── not-found.tsx                 # global 404
│   ├── components/
│   │   ├── nav.tsx                       # top-bar nav (server)
│   │   ├── nav-scroll-wrapper.tsx        # client wrapper providing scroll state
│   │   ├── nav-mobile-drawer.tsx         # mobile drawer (client)
│   │   ├── locale-switcher.tsx           # locale picker (client)
│   │   ├── footer.tsx                    # site footer
│   │   ├── ContactForm.tsx               # /contact intake form (client; server-action submit)
│   │   ├── home/                         # 8 homepage section components
│   │   ├── work/, network/, lab/, services/
│   │   └── shared/                       # cross-page components (sticky-cta, closing-cta, orchestra-diagram, pull-quote, use-in-view)
│   ├── content/
│   │   ├── work/{_schema.ts, loader.ts}  # frontmatter schema + loader (MDX optional)
│   │   ├── lab/{_schema.ts, loader.ts}   # frontmatter schema + loader (MDX optional)
│   │   └── network/{_schema.ts, loader.ts, roster.json}
│   ├── i18n/
│   │   └── request.ts                    # next-intl request config (per-app)
│   └── middleware.ts                     # next-intl locale routing
├── public/                               # static assets
├── wrangler.jsonc
└── next.config.ts
```

Locale-aware message catalogs ship from `@rapoport-studio/i18n` at `packages/i18n/messages/<locale>/{common,web}.json` (six namespaced files at the package root) — **not** from `apps/web/messages/`. The consumer-side wiring (`getRequestConfig` that loads the right catalogs per locale) lives at `apps/web/src/i18n/request.ts`. See `i18n.md` for shape and subpath exports.

The app is a Next.js 16 App Router site with a single `[locale]` segment for locale-aware routing. Marketing routes live under a `(marketing)` route group so the locale layout wraps all of them. Content for `/work/*` and `/lab/*` lives under `src/content/<type>/` with a `_schema.ts` (Zod) + `loader.ts` pair. The network roster is JSON at launch and migrates to a Supabase table when the studio app gains roster admin UI. A single API route at `/api/intake` forwards qualified intakes to `/api/studio/leads` on `app.rapoport.studio`; `/api/health` returns `{ status: "ok" }` for uptime monitoring.

**Root layout overlay requirement.** `apps/web/src/app/layout.tsx` must mount `<OverlayHost registry={registry} />` as a sibling of the provider chain (not nested inside `<NuqsAdapter>` or any other provider). The registry is defined in `apps/web/src/overlay/registry.ts` and maps overlay IDs to lazy module imports. This wires the URL-state overlay system for the marketing site (privacy banner, contact form, future public-facing overlays). Full contract in `openspec/current/linkable-ui.md`.

**Root locale layout ViewTransition requirement.** `apps/web/src/app/[locale]/layout.tsx` must wrap `<main>` in React's `<ViewTransition>` component (imported from `"react"` with the `experimental.viewTransition: true` flag set in `next.config.ts`). This is a structural peer requirement to the `<OverlayHost>` clause above: without the wrapper, the page transition pattern described in `openspec/current/design-motion.md § Choreography rules → Page transition pattern` cannot fire. The wrapper must remain at the locale layout level so it covers all marketing routes uniformly.

> **v1.x-deferred (documented but not yet shipped).** The following surfaces are part of the long-term shape and appear in the routing table below as deferred. They are not in the tree today; treat them as planned post-v1 work, not as gaps in the spec:
> - `app/[locale]/(marketing)/work/[slug]/page.tsx` and `app/[locale]/(marketing)/lab/[slug]/page.tsx` — individual case study and article pages. Index pages exist; per-slug detail routes are deferred until the case-study and article corpus justifies them.
> - `app/robots.ts` — generated robots policy. Cloudflare Worker can serve a static replacement until it is wired. (`app/sitemap.ts` is already shipped — see the routing table below.)
> - `src/lib/{seo,og,intake,content}.ts` — utility helpers. Today the equivalents live inline in the consumers (page-level `generateMetadata`, the `ContactForm` component, MDX loader in `content/`). Extracting to `lib/` is a refactor, not a feature.

---

## Routing

The site uses `localePrefix: "always"` from `@rapoport-studio/i18n` (set in `packages/i18n/src/routing.ts`). Every locale prefixes its paths, including the default — `en` is served at `/en/...`, not at unprefixed paths. Unprefixed forms redirect to `/en/...`.

| Route | Status | Purpose | Content source | Locale-aware |
|---|---|---|---|---|
| `/[locale]/` | v1, live | Homepage — top of funnel | `app/[locale]/(marketing)/page.tsx` | yes |
| `/[locale]/work` | v1, live | Case study index | `content/work/loader.ts` | yes |
| `/[locale]/work/[slug]` | **v1.x-deferred** | Individual case study | MDX | yes |
| `/[locale]/network` | v1, live; **v2 redesign in flight (RAP-96)** | Live network surface — specialists, projects, canvases, channels, open calls, cases | `content/network/{loader.ts,roster.json}` (v1) → Supabase via `add-network-data-schema` (v2, RAP-95) | yes |
| `/[locale]/services` | v1, live | Services index + DecisionRouter | Static page | yes |
| `/[locale]/services/discovery` | v1, live | Discovery engagement detail page | `app/[locale]/(marketing)/services/discovery/page.tsx` | yes |
| `/[locale]/services/migration` | v1, live | Migration engagement detail page | `app/[locale]/(marketing)/services/migration/page.tsx` | yes |
| `/[locale]/services/modular` | v1, live | Modular engagement detail page | `app/[locale]/(marketing)/services/modular/page.tsx` | yes |
| `/[locale]/services/platform` | v1, live | Platform engagement detail page | `app/[locale]/(marketing)/services/platform/page.tsx` | yes |
| `/[locale]/services/subscription` | v1, live | Subscription engagement detail page | `app/[locale]/(marketing)/services/subscription/page.tsx` | yes |
| `/[locale]/discovery` | **308 redirect** | Permanent redirect → `/[locale]/services/discovery` | `app/[locale]/(marketing)/discovery/page.tsx` | yes |
| `/[locale]/lab` | v1, live | Article index | `content/lab/loader.ts` | yes |
| `/[locale]/lab/[slug]` | **v1.x-deferred** | Individual article | MDX | yes |
| `/[locale]/start-project` | v1, live | Intake form | Static + form | yes |
| `/[locale]/founders` | v1, live | Founder Track landing — F1/F2 segment cards + service-with-lens + Discovery walkthrough + trust pillars + idea-stage hook | Static page + community-signup link via `studio.signupUrl` | yes |
| `/[locale]/specialists` | v1, live | Specialist onboarding landing — seven blocks: Hero, WhyThisExists, WhatYouContribute, WhatYouGet, TheArtefact (trust-pillar canonical sentences), ApplyCTA (primary + sticky scroll variant). CTA routes to `app.rapoport.studio/specialists/apply`. | `apps/web/src/app/[locale]/(marketing)/specialists/page.tsx` | yes |
| `/[locale]/legacy` | v1, live | Legacy Track landing — third audience track parallel to Client + Founder. Operationalises the `Long (legacy)` motivation pole from `positioning.md § 5.1`. Per-locale authored copy (en/ru/ro); intake source `web/legacy`. See `openspec/archive/2026-05-22-add-legacy-track/` for the originating change. | `apps/web/src/app/[locale]/(marketing)/legacy/page.tsx` + `<ForLegacy />` homepage block | yes |
| `/[locale]/principles` | v1, live | Philosophy spine — Principles (applied 2026-05-24, RAP-1211) | `apps/web/src/app/[locale]/(marketing)/principles/page.tsx`; body from `apps/web/src/generated/manifesto-principles.ts` (extracted from `openspec/_methodology/manifesto.en.md`) | yes |
| `/[locale]/method` | v1, live | Philosophy spine — Method: operating roles + engagement shapes (applied 2026-05-24, RAP-1211) | `apps/web/src/app/[locale]/(marketing)/method/page.tsx`; engagement shapes from `openspec/archive/2026-05-13-lock-positioning-trajectory/design.md` | yes |
| `/[locale]/openspec` | v1, live | Philosophy spine — OpenSpec corpus: what it is, how to use it, download zip (applied 2026-05-24, RAP-1211) | `apps/web/src/app/[locale]/(marketing)/openspec/page.tsx` | yes |
| `/api/intake` | v1, live | POST forwarder to `/api/studio/leads` | `app/api/intake/route.ts` | no |
| `/api/health` | v1, live | GET `{ status: "ok" }` for uptime | `app/api/health/route.ts` | no |
| `/sitemap.xml` | v1, live | Sitemap (multi-locale) | `app/sitemap.ts` | no |
| `/robots.txt` | **v1.x-deferred** | Robots policy | `app/robots.ts` (planned) | no |

Redirect rules:

- Trailing slashes: trimmed (`/work/` → `/work`)
- Unknown locale segment (e.g., `/de/work`): 404, no fallthrough to default
- Legacy path redirects: none at launch; added if Pavel's prior personal site URLs need preservation
- `/[locale]/discovery` → `/[locale]/services/discovery` — 308 permanent redirect via `permanentRedirect()` in page.tsx (RAP-1212)

The **v1.x-deferred** rows are part of the documented surface but not shipped today. `/work` and `/lab` indexes render their MDX corpus inline at the index page; per-slug detail routes activate when the corpus crosses the size where deep links become expected (heuristic: 3+ entries and an outside link points at one). `/sitemap.xml` is shipped (`app/sitemap.ts`, a next.js-generated route). `/robots.txt` will be added as an `app/robots.ts` next.js-generated route when SEO + crawl shape stabilizes — until then a Cloudflare Worker static rule can serve a placeholder if a crawler asks.

---

## Locale strategy

Three locales declared by `@rapoport-studio/i18n`: `en` (default), `ro`, `ru`.

| Layer | en | ro | ru |
|---|---|---|---|
| Chrome (`common.json`: nav, footer, form labels, CTAs) | yes | yes | yes |
| Static page copy (`web.json`: `/services`, `/contact`, `/`, home blocks) | yes | yes | yes |
| Long-form MDX (`/work/*`, `/lab/*`) | yes | English-only | English-only |
| Network roster bios | yes | English-only | English-only |

Translation policy at launch: chrome and static page copy ship populated in all three locales — `packages/i18n/messages/{en,ru,ro}/{common,web}.json` carry full translations of every shipped block as of 2026-05-04 (~38 lines per `common.json`, ~107 lines per `web.json`). Long-form content (`/work/[slug]`, `/lab/[slug]`, network roster bios) stays English-only — translating it is a content investment that doesn't pay off until traffic in those locales justifies it. The cross-locale parity test in `packages/i18n/src/__tests__/smoke.test.ts` enforces that any key present in `en/<file>.json` exists in `ru/` and `ro/`.

Pages without a translation (e.g., long-form content on `/work/[slug]`): serve the English version even when accessed via `/ro/...` or `/ru/...`. The chrome (locale switcher, nav) remains in the requested locale.

---

## Page contracts

For each page, the section sequence and what each section contains. Specific copy lives in components and MDX; this is the structural contract.

### `/` — homepage

1. **Navigation** — sticky, frosted-glass on scroll; logo + 4 links + primary CTA
2. **Hero** — eyebrow (location/credentials), display headline, sub-headline, primary CTA + ghost CTA
3. **What we build** — section heading, lede paragraph, AI orchestra diagram, supporting paragraph, pull-quote
4. **Now building** — section heading, lede, work card grid (2-N cards) — pulls from `content/work/*.mdx` filtered to `status: in-progress`
5. **How we work** — section heading, four numbered steps (Brief, Curate, Build, Deliver)
6. **The network** — section heading, lede, stats line, 6-card roster preview, link to `/network`
7. **Services** — section heading, lede, two-column "we take on / we don't", closing italicized note
8. **Lab preview** — section heading, lede, three most recent posts, link to `/lab` — entire section hidden if `content/lab/` is empty
9. **Closing CTA** — display heading, sub, primary CTA, mono email
10. **Sticky CTA** — appears after hero scrolls past; hides when closing section enters the viewport
11. **Footer** — four columns (brand, site, reach, legal), bottom strip with locale switcher

### `/work` — featured current builds + archive index

Two-zone page (applied 2026-05-15 from change `redesign-work-featured-current-builds-archive`, RAP-826):

1. **Featured zone** (top) — in-progress entries (`status: in-progress`) rendered as full-bleed `<FeaturedWorkTile>` components with hero, stage chip, surfaces chips, stack badges, outcome line, team credits, and conditional production link. Sourced from the `work_engagements` Supabase table (`stage`, `surfaces`, `stack`, `engagement`, `outcome`, `timeline`, `links`, `hero` — all optional per the DB schema).
2. **Archive zone** (below) — published / archived entries (`status: published | archived`) rendered as a 3-up `<EntityCard variant="card">` grid. Same schema; archive cards ignore the extended fields.

Each zone hides when its entry set is empty (no placeholder copy — mirrors the home `Now building` empty-state pattern). Filter chips by domain (Healthtech, Legaltech, Govtech, Fintech) on the archive zone — added when that zone has five or more entries; until then, no chips.

Data source: `work_engagements` Supabase table via `apps/web/src/content/work/loader.ts`. The exported reader signatures (`getInProgressWork`, `getPublishedWork`, `getArchivedWork`) are unchanged from the MDX-backed version; the renderer contract (two-zone layout, `<FeaturedWorkTile>` + `<EntityCard variant="card">`) is identical. Cache tag: `work-engagements`. `/work/[slug]` detail page remains v1.x-deferred.

### `/work/[slug]` — individual case study

Per-case-study layout, MDX-driven. Standard structure:

- Hero with title, client, year, project type, team credits
- Background (the problem context)
- Approach (what was built, including orchestra-specific architecture decisions)
- Outcome (metrics where available)
- Team detail (each named contributor with role and link)
- Related work (two other case studies)

### `/network`

**v1 (current, MDX-driven roster):** Heading + lede + stats line + full roster grid. Each card: photo, name, role, location, link to personal site. Filter chips by discipline. Roster sorted by tenure with the studio (most recent first). Data source: `src/content/network.json`, only `optInPublic: true` entries.

**v2 (in flight via RAP-96 `add-network-public-surface`):** Live, scroll-narrative surface backed by Supabase per RAP-95 schema, rendered through the EntityCard primitive (RAP-92). Ten sections — hero + scale stats, now active feed, open calls board, mind map, AI capability clusters, bridge spotlights, channels, cases delivered, services teaser, join CTA. Every section renders cleanly with zero data per `entities/README.md § Information architecture rules` empty-state rule (catalog icon at 50% opacity).

**Catalog entities consumed (after RAP-94 + RAP-95 ship):**

- Tier 1: `studio` (`Building2`) — hub node on the mind map.
- Tier 2: `project` (`FolderKanban`), `canvas` (`MessagesSquare`), `discussion-thread` (`MessageSquareText` — discussions are studio-only on web; this row is studio surface).
- Tier 3: `skill` (chip taxonomy via `SkillChip` primitive — categories `tech / domain / ai`).
- Tier 4: `specialist` (`UserCheck`), `community-member` (`Sprout`), `junior-practitioner` (`GraduationCap`), `client-persona` (`Briefcase`), `open-call` (`Megaphone`).
- Tier 5: `case-study` (`Briefcase`), `service` (`Layers`), `network-entry` (`UserCheck` — render alias of `specialist` for marketing chrome).
- Tier 6: `capability` (`BookMarked`) — surfaced in AI-capability clusters section.
- Tier 7: `channel` (`Radio`), `telegram-group` (`MessageCircle`).

See `entities/README.md` for the canonical entries; `archive/2026-05-07-add-network-entities/design.md § 2.5` for the 11-node mind-map composition rules; `add-network-public-surface/design.md § 2` for the section-by-section IA.

### `/services`

Listing page: `<SectionHead>` (eyebrow `УСЛУГИ` + H1) → a where-to-start **decision router** →
a five-card **expandable funnel catalog** (`ServiceFunnelCatalog` → `ServiceFunnelCard`, one
per slug `discovery / migration / modular / platform / subscription`; each card carries
`title` / `meta` / `lede` and an expandable set of "shapes"). Copy sourced from `web.json`
under `services.*`; `JSON-LD` `Service` entries embedded per slug.

**RU-first rewrite (applied from change `reposition-home`, RAP-1295 — D-RH-28).** The listing
page is RU-first + plain language + spine-A voice, coherent with the home vocabulary and the
**D-RH-26** service labels (Диагностика роста / Переход на новую систему / Сборка под задачу /
Платформа под бизнес / Сопровождение по подписке). H1 is «С чего начать — зависит от того, где
вы сейчас». The router headline + three prompts are localised via
`services.router.{headline,discovery,migration,platform}` (was hardcoded English), passed from
the server page into the client `ServicesDecisionRouter`. Per-card `meta` / `lede` and all
fourteen `shapes.*` are plain, jargon-free. IA / structure / sections / routes / slugs
unchanged. EN de-jargoned; RO formal «dumneavoastră» (D-RH-27); EN/RO AI-drafted, native review
pending. The five individual `/services/<slug>` detail pages remain in their pre-repositioning
English state — a deferred, separate follow-on.

**Primary-nav alignment (applied from change `reposition-home`, RAP-1296 — D-RH-26).** The nav
«Услуги» dropdown (`common.nav.*`, rendered by `components/nav.tsx`; structure in
`nav-config.ts`) carries the same D-RH-26 service labels with plain, de-jargoned `*Sub`
one-liners — the price/package framing (`€5k`, `€50k`, `6-month minimum`, `Spec-driven`) is
dropped to match the home. Copy-only: hrefs (`/services/<slug>`), IA, the trigger labels, the
About section, and the `founders`/`specialists` items are unchanged.

### `/lab` — article index

Reverse-chronological. Each card: title, excerpt, read time, date. Filter chips by topic (Architecture, Methodology, Network, Studio) once 10 or more articles exist.

### `/lab/[slug]` — individual article

Standard article layout. MDX-driven. Title, dek, body, author, published date, related articles.

### `/start-project`

Client intake page (route renamed from `/contact`; the old path 301-redirects). Localised page chrome — eyebrow / `<h1>` / lede — sourced from `web.json` under `startProject.*` (see `i18n.md`); the lede carries the two-working-day reply SLA. Between the header and the form, a narrative intro section (`<StartProjectIntro />`, server component) carries two parts: **the two engagement paths** — Discovery (€5k · 3–4 weeks · three named artifacts) and Full engagement (sprint-based) surfaced as readable copy, mapping to the form's `engagement_interest` radio — and a **"what happens next"** block with the reply SLA and a named trust signature. Then the intake form (see Intake flow). On submit: thanks state with the next-step expectation.

The intake form accepts an optional `?source=` query param for funnel attribution. Allowlist: `web/contact`, `web/discovery`, `web/founders`, `web/legacy`. Values outside the allowlist are dropped (form falls back to its default `web/contact` tag). The user-typed "How did you hear about us?" field still wins over the URL value when populated.

### `/founders`

Founder Track landing page. Heading + lede stating Israel-first geo-ICP for Founder Track. Section sequence:

1. Page hero — eyebrow + heading + lede; uses `<TacticalFrame />`
2. Three founder-stories cards (F1 Series-A approach / F2 AI-orchestration retrofit / F3 reservist-returner; F3 toggleable, hidden by default per Q-FOUNDER-7 close-out)
3. Service mapping (5-card with founder lens — `discovery / migration / modular / platform / subscription` framed for founder use cases via `founders.serviceLens.<slug>` i18n keys)
4. How-it-works (4-step Discovery process: Map domain → Design AI layer → Validate plan → Deliver)
5. Trust pillars (Authorship / Security / Portability — canonical sentences pinned from `archive/2026-05-06-expand-home-with-services-and-community/research/muse-trust-and-ownership.md`)
6. Idea-stage hook — link to community signup at `studio.signupUrl` for F4 (pre-seed AI-native) and F5 (idea-stage non-technical) segments
7. Closing CTA — single founder-specific contact link to `/contact?source=web/founders`

`generateMetadata` emits title, description, OG block, hreflang for en/ru/ro. `generateStaticParams` covers all three locales. JSON-LD `Service` entries embedded for each of the five engagement shapes with founder lens. Founder-page-specific forbidden phrasings (in addition to the cold-pitch constraint in `platform.md`): "build your dream product", "AI-powered", "10x your runway", "we're your CTO", "fractional CTO", "for ambitious entrepreneurs", "from idea to exit".

---

## Content principles

- **Studio voice, not founder voice.** "We curate teams" — not "I find people". Pavel is named as founder but is not the brand.
- **Network as first-class brand asset.** Collaborators have named roles in case studies, dedicated portrait pages on `/network`, links to their own sites. Not "freelancers"; not "contractors".
- **Show, don't tell.** Case studies lead with the work; explanation follows. Service pages state engagement minimums, not "starting from".
- **Honest about scale.** Indicative ranges, minimum engagement, what fits and what doesn't. The "What we don't" column on `/services` is load-bearing — it prevents wasted intake.
- **Sentence case throughout.** No Title Case headings. Eyebrow uppercase is the only ALL CAPS.
- **English at the centre.** Romanian and Russian are real but secondary. The English version is always canonical; translations follow.
- **AI is architecture, not feature.** When discussing AI on the site, frame it as the orchestration layer of the platform, not as "AI features bolted on".

---

## Content storage

Three stores:

1. **`work_engagements` Supabase table (project `nifagnmgwoqkplegsicy`)** — all work engagements for `/work`. Reads go through `apps/web/src/content/work/loader.ts` via the server-side Supabase client; results are cached in `unstable_cache` with tag `work-engagements`. Writes happen through admin server actions in `apps/studio` which call `revalidateTag('work-engagements')` after each mutation.

2. **MDX files in `src/content/lab/`** — articles (`lab/*.mdx`). Each MDX file has frontmatter (`title`, `dek`, `published`, `readTime`, `topic`, `kind`) and a body. MDX components include code blocks, callouts, image components with captions, and a `<PullQuote />` component.

3. **JSON in `src/content/network.json`** — the network roster. Each entry has `name`, `role`, `location`, `disciplines[]`, `languages[]`, `bio`, `photo`, `links{personal, twitter, github, dribbble}`, `optInPublic` (bool), `joinedYear`. Only entries with `optInPublic: true` render on `/network`.

> **v2 lifecycle note (RAP-94 → RAP-96):** When RAP-96 ships, the public roster sources from Supabase (`user_settings.is_public_in_network = true` joined with `user_skills`). The `src/content/network.json` file remains as a fallback / seed source for empty-DB environments and as the historical record of pre-Supabase membership. The two are reconciled at the seed step of RAP-95.

Migration path (when justified):

- Network → Supabase `collaborators` table when the studio app gains roster admin UI. Web reads via `@rapoport-studio/db` with anon key + RLS.
- Lab → Supabase `cms_documents` table only if non-engineer authoring becomes a blocker. MDX-in-repo is preferred until that pain is real.

---

## Components

Per-section components live under `src/components/<section>/`. Cross-page components live under `src/components/shared/`. Primitives (buttons, inputs, separators, badges, avatars) come from `@rapoport-studio/ui`.

### Marketing-specific components

| Component | Used by | Location |
|---|---|---|
| `<Nav />` | All pages | `components/nav.tsx` |
| `<NavScrollWrapper />` | All pages (sticky frosted-glass behavior on scroll) | `components/nav-scroll-wrapper.tsx` |
| `<NavMobileDrawer />` | All pages (mobile) | `components/nav-mobile-drawer.tsx` |
| `<LocaleSwitcher />` | Footer + nav | `components/locale-switcher.tsx` |
| `<Footer />` | All pages | `components/footer.tsx` |
| `<StickyCTA />` | All marketing pages | `components/shared/sticky-cta.tsx` |
| `<SectionHead />` | All section headings (eyebrow + heading + lede primitive) | `@rapoport-studio/ui/components/section-head` (see `ui.md § SectionHead primitive`) |
| `<Hero />` (home) | `/` | `components/home/hero.tsx` |
| `<WhatWeBuild />` | `/` | `components/home/what-we-build.tsx` |
| `<NowBuilding />` | `/` | `components/home/now-building.tsx` |
| `<HowWeWork />` | `/` | `components/home/how-we-work.tsx` |
| `<TheNetwork />` | `/` | `components/home/the-network.tsx` |
| `<ServicesSection />` | `/` | `components/home/services-section.tsx` |
| `<LabPreview />` | `/` | `components/home/lab-preview.tsx` |
| `<StepRow />` | `/` (HowWeWork) | `components/home/step-row.tsx` |
| `<OrchestraDiagram />` | `/`, possibly `/services` | `components/shared/orchestra-diagram.tsx` |
| `<PullQuote />` | `/`, MDX | `components/shared/pull-quote.tsx` |
| `<ClosingCTA />` | All marketing pages | `components/shared/closing-cta.tsx` |
| `<ContactForm />` | `/contact` | `components/ContactForm.tsx` |
| `<ForFounders />` | `/` | `components/home/for-founders.tsx` |
| `<FoundersHero />` | `/founders` | `components/founders/founders-hero.tsx` |
| `<FounderStories />` | `/founders` | `components/founders/founder-stories.tsx` |
| `<ServiceWithLens />` | `/founders` | `components/founders/service-with-lens.tsx` |
| `<FoundersHowItWorks />` | `/founders` | `components/founders/how-it-works.tsx` |
| `<FoundersTrustPillars />` | `/founders` | `components/founders/trust-pillars.tsx` |
| `<IdeaStageHook />` | `/founders` | `components/founders/idea-stage-hook.tsx` |
| `<FoundersClosingCta />` | `/founders` | `components/founders/closing-cta.tsx` |

| `<PhilosophyTemplate />` | `/principles`, `/method`, `/openspec` (applied 2026-05-24, RAP-1211) | `components/shared/philosophy-template.tsx` |

Per-route components live under `components/{work,network,lab,services}/`; full enumeration tracks the `apps/web/src/components/` tree, not this spec.

---

## Philosophy surface (applied 2026-05-24, RAP-1211)

Three-page spine shipped as a coherent surface with shared chrome (`<PhilosophyTemplate />`).

| Route | Title | Content source | JSON-LD |
|---|---|---|---|
| `/[locale]/principles` | Principles | `apps/web/src/generated/manifesto-principles.ts` (extracted from `openspec/_methodology/manifesto.en.md § Principles I–IV`) | `WebPage` with `about: Organization` |
| `/[locale]/method` | Method | Inline (operating roles from manifesto Cast descriptors; engagement shapes from Q-POS-7 / `archive/2026-05-13-lock-positioning-trajectory/design.md`) | `WebPage` + `Service` ×4 (Discovery, Migration, Modular, Platform, Subscription) |
| `/[locale]/openspec` | OpenSpec | Inline (what it is / how to use / what to read first) | `WebPage` + `Dataset` (flat zip at `/openspec-flat.zip`, `dateModified: 2026-05-24`) |

**Drift guard:** `tools/check-manifesto-publish.mjs` asserts byte-equality between `manifesto.en.md § Principles I–IV` and the generated module. CI step `Manifesto publish drift guard` runs on any diff touching `apps/web/**` or `openspec/_methodology/manifesto.en.md`. Run `pnpm marketing:extract-manifesto-principles` to regenerate.

**i18n:** Translation keys under `common.marketing.philosophy.{principles,method,openspec}.{nav,title,blurb,sidebar}` in `packages/i18n/messages/{en,ru,ro}/common.json`. Long-form body stays English-only per locale strategy.

**Nav:** `<Nav />` exposes the three spine pages in a Philosophy group (D-PPS-4). Desktop: inline after the existing three links, separated by a visual divider. Mobile: separate group in the drawer.

**Cast constraint:** `/method` uses operating-role descriptors only (coordination / creative substrate / world-reader / public-voice / numbers / engineering) — Cast names (Maestro/Muse/Atlas/Echo/Pulse/Forge) never appear in public copy per Q-EM-5.

### Primitives consumed from `@rapoport-studio/ui`

`Button`, `Input`, `Textarea`, `Select`, `Avatar`, `Badge`, `Separator`. Loaded from `@rapoport-studio/ui` per component, with `globals.css` imported once at the root layout. (Layout primitives `Container`, `Stack`, `Grid`, plus `Checkbox`, are referenced in some component code but are not yet implemented in `@rapoport-studio/ui`; consumers fall back to Tailwind utility classes for layout — see `ui.md § Known gaps`.)

| Component | Source | Notes |
|---|---|---|
| `EntityCard` | `@rapoport-studio/ui` (RAP-92) | Four variants (`inline`, `row`, `card`, `detail`) — used across all sections of `/network` v2. Cross-surface continuity per `entities/README.md § Information architecture rules`: same primitive renders the same entities on `apps/studio/network`. |
| `SkillChip` | `@rapoport-studio/ui` (deferred — contract locked in `add-network-entities` RAP-94) | Tier-3 chip for `skill` taxonomy. Renders 0–N instances on the meta line of person-shaped EntityCards. |

### When to upstream a component

A marketing component moves from `apps/web/src/components/` into `@rapoport-studio/ui` when:

- `apps/studio` (or any future app) consumes the same component, or
- The component is sufficiently primitive that it would belong in a generic design system

`<OrchestraDiagram />` is a candidate to upstream early because it is used in marketing and likely in the studio app's project pages too.

---

## Intake flow

```
/contact form submit
  └─► POST /api/intake (this app, server runtime)
        ├─► validate via zod schema
        ├─► verify Cloudflare Turnstile token
        ├─► attach metadata: locale, referrer, user-agent, IP, timestamp
        └─► POST app.rapoport.studio/api/studio/intake with x-api-key
                └─► studio writes to studio.inbox table
```

Form fields:

| Field | Required | Type | Validation |
|---|---|---|---|
| `name` | yes | text | 2–80 chars |
| `email` | yes | email | RFC 5322 |
| `company` | no | text | 0–120 chars |
| `role` | no | text | 0–80 chars |
| `project_type` | yes | select | enum: `platform`, `ai-orchestration`, `audit`, `other` |
| `budget_range` | yes | select | enum: `50-100k`, `100-250k`, `250-500k`, `500k+`, `to-discuss` |
| `timeline` | yes | select | enum: `0-3mo`, `3-6mo`, `6-12mo`, `flexible` |
| `brief` | yes | textarea | 50–2000 chars |
| `source` | no | text | 0–120 chars |

Anti-spam:

- Cloudflare Turnstile widget on the form (invisible mode)
- Honeypot field `website_url` (hidden, must remain empty)
- Server-side rate limiting per IP (10 submissions per hour)

The `STUDIO_INTAKE_API_KEY` lives only in the server runtime (Cloudflare secret on `rapoport-web` worker) — never reaches the client. The web app's `/api/intake` route handler is the only place it is read.

On success: form transitions to a thanks state with the next-step expectation ("we respond within 48 hours"). On failure: inline error, no redirect.

---

## SEO + metadata

### Page-level metadata

Generated via Next.js `generateMetadata`. Per-page fields: `title`, `description`, `openGraph`, `twitter`, `alternates.languages` (hreflang). Default fallback OG image: a brand mark on warm off-white.

### Sitemap

`/sitemap.xml` lists every locale-prefixed URL of every page. Generated at build time from the route tree. Per-locale sitemaps at `/<locale>/sitemap.xml` for crawlers that prefer them.

### hreflang

Every page emits hreflang `alternate` tags pointing to its three locale variants plus an `x-default` pointing to the English version. Pages without a translation point all three locales at the English version (acceptable per Google's hreflang spec for partial translations).

### Robots

`/robots.txt`: allow all, sitemap reference. No `disallow` directives at launch.

### JSON-LD

Structured data per page type:

- `/`: `Organization` schema with founder, location, `sameAs` (LinkedIn, Telegram)
- `/work/[slug]`: `CreativeWork` schema with author and `dateCreated`
- `/lab/[slug]`: `BlogPosting` schema with author and `datePublished`
- `/network`: `Organization` schema reused; individual `Person` schemas only on `/network/[slug]` if individual person pages ship later

### Indexing strategy

All English routes indexed. Non-English routes that fall back to English content: `noindex` to avoid duplicate-content penalties. Translated pages (chrome + page copy): indexed.

---

## Performance budget

Target Core Web Vitals at the 75th percentile:

| Metric | Target | Google "good" baseline |
|---|---|---|
| LCP | < 2.0s | < 2.5s |
| CLS | < 0.05 | < 0.1 |
| INP | < 100ms | < 200ms |

Bundle:

- JavaScript per page (above-the-fold, gzipped): < 50 KB
- Initial CSS (gzipped): < 15 KB
- Total page weight (homepage, gzipped): < 250 KB

Asset rules:

- Images: AVIF with WebP fallback; responsive `srcset`; `loading="lazy"` below the fold
- Fonts: variable, self-hosted via the Cloudflare Worker, `font-display: swap`, preconnect from layout
- No client-side JS for static pages; React Server Components by default. Client components only for: nav (sticky behavior), sticky CTA, intake form, locale switcher
- No third-party scripts at launch (no analytics, no chat widgets) — analytics added once a single tool is committed (PostHog candidate)

Build target: page-level code-splitting via App Router; no `getServerSideProps` equivalent on marketing routes; ISR or static generation where possible.

---

## Conventions

### Adding a marketing page

1. Create `src/app/[locale]/(marketing)/<route>/page.tsx`
2. Export `generateMetadata` with title, description, OG image, hreflang alternates
3. Add the route to the appropriate place in `<Nav />` and `<Footer />`
4. Add chrome translations to `packages/i18n/messages/{en,ru,ro}/common.json` (or `web.json` for page-specific copy) — see § Adding a translation
5. Update `src/app/sitemap.ts` if the page should be in the sitemap

### Adding a case study

1. Create `src/content/work/<slug>.mdx`
2. Frontmatter required: `title`, `client`, `year`, `status`, `team`, `summary`, `ogImage`
3. Body uses MDX components: `<Hero />`, `<Section />`, `<PullQuote />`, `<TeamCredits />`, `<RelatedWork />`
4. Status values: `in-progress` (shows on homepage "Now building"), `published` (shows on `/work` index)

### Adding an article

1. Create `src/content/lab/<slug>.mdx`
2. Frontmatter required: `title`, `dek`, `published`, `readTime`, `topic`
3. Body uses MDX components: `<Section />`, `<PullQuote />`, `<Footnotes />`

### Adding a network member

1. Add an entry to `src/content/network.json`
2. Required fields: `name`, `role`, `location`, `disciplines`, `bio`, `photo`, `optInPublic`, `joinedYear`
3. Photo: place in `public/network/<slug>.jpg`, square, 800×800 minimum
4. Set `optInPublic: false` initially; flip to `true` after explicit consent from the person

### Adding a translation

1. Decide the namespace: `common.json` for chrome (nav, footer, locale switcher, status copy) or `web.json` for marketing-page copy. Both live in the `@rapoport-studio/i18n` package, not in this app.
2. Add the key path (e.g., `nav.work` for chrome, `home.hero.eyebrow` for page copy) to all three locales of that namespace at the package root: `packages/i18n/messages/{en,ru,ro}/<namespace>.json`. The smoke test `packages/i18n/src/__tests__/smoke.test.ts` enforces parity — keys present in `en/` must exist in `ru/` and `ro/`, or the test fails.
3. Even if only English text is ready, add the English text as a placeholder in `ru/<namespace>.json` and `ro/<namespace>.json` so the parity test passes and missing keys do not render as raw key paths.
4. Use `t("nav.work")` / `t("home.hero.eyebrow")` in the consuming feature, scoped via `useTranslations("common")` or `useTranslations("web")`. The namespace is the file split — keys carry **no** `common.` or `web.` prefix.

---

## Dependencies

### Runtime

| Package | Version | Used by |
|---|---|---|
| `next` | `^16` | App framework |
| `react`, `react-dom` | `^19` | UI |
| `next-intl` | via `@rapoport-studio/i18n` | locale routing + chrome catalogs |
| `zod` | `^3` | form schema, MDX frontmatter validation |
| `tailwindcss` | `^4` | styling |
| `next-mdx-remote` | latest | MDX rendering |

### Internal

| Package | Range | Used by |
|---|---|---|
| `@rapoport-studio/ui` | `workspace:*` | primitives + global styles |
| `@rapoport-studio/i18n` | `workspace:*` | locale config, navigation helpers |
| `@rapoport-studio/db` | `workspace:*` | deferred — only when network migrates to DB |
| `@rapoport-studio/config` | `workspace:*` | transitively via db; not direct |

### Dev

| Package | Version | Used by |
|---|---|---|
| `@opennextjs/cloudflare` | `^1` | build for Cloudflare Worker |
| `wrangler` | `^4` | dev / deploy |
| `vitest`, `@testing-library/react` | `^4`, latest | component tests |
| `@types/react`, `@types/node` | `^19`, `^20` | types |
| `eslint`, `typescript` | `^9`, `^5` | lint, typecheck |

---

## Boundaries

Enforced by `eslint.config.mjs` and the workspace package graph.

- web **MAY** import from `@rapoport-studio/{ui, i18n}`
- web **MAY** import from `@rapoport-studio/db` only when the network roster migrates to Supabase, and only for read access with anon key + RLS
- web **MAY NOT** import from `@rapoport-studio/{auth, forge, canvas, muse}` or any other app
- web **MAY** call `app.rapoport.studio/api/studio/intake` from server runtime only, with `STUDIO_INTAKE_API_KEY`
- web **MAY NOT** access Supabase tables that are not part of the public read surface (e.g., `studio.inbox`, `studio.projects`)
- web **MAY NOT** contain authentication or admin functionality — both apps share a domain root but have separate auth surfaces

---

## Out of scope

Topics handled by other capability specs, not by this one:

- Visual design tokens (colors, typography, spacing) — `@rapoport-studio/ui`
- Locale list and routing helpers — `@rapoport-studio/i18n`
- Studio app routes and intake processing — `studio-workspace/README.md`
- Forge CLI or Muse agents — `forge/README.md`, `muse.md`
- Live Canvas hero illustration — the original Canvas v2 visualization vision was deprecated 2026-05-03 in favor of `mirror.md` (see `canvas.md` for historical context); the orchestra diagram on `/` is a static SVG, not Canvas
- Shape and content of individual case studies and articles — owned by the MDX files themselves, not the spec
- Pricing detail beyond the €50k floor — by design (stated minimum, no published rate sheet)
- Self-serve scheduling (Calendly, Cal.com) — out of scope at launch; intake flow is the only conversion path
- E-commerce or downloadable products — not in scope for this app

---

## Open questions

- **Network roster store at launch.** JSON in repo (current commitment) versus Supabase table from day one. JSON is simpler but blocks roster updates from the studio app. The migration path is documented; the trigger condition (when JSON becomes painful) is not.
- **Translation depth for long-form content.** Chrome and static page copy ship populated in all three locales today. Long-form MDX (`/work/*`, `/lab/*`) and network roster bios stay English-only. Trigger for translating long-form: first qualified intake from a non-English locale, or scheduled translation sprint regardless?
- **Lab pre-launch sprint.** Whether to seed three articles before launch (so `/lab` exists at the start) or hide the section until first organic post. Current commitment: hide until seeded; sprint timing not scheduled.
- **Stealth `Now building` card public visibility on `/`.** The "Now building" card for the current stealth client engagement appears publicly only after explicit go-ahead from the client. Until then, the card is replaced by a "+ 1 engagement in stealth" placeholder, or the section runs with one card.
- **Network opt-in mechanics.** Six to ten collaborators must opt in (with photo, bio, role) before the network section ships meaningfully. Pre-launch tasks: identification, outreach, photo collection, copy approval. Owner of this sprint not yet defined.
- **Performance analytics tooling.** ~~PostHog (key already in Infisical) or alternative? Cookie consent depends on choice. Cookie banner approach: minimal (one button: "OK") or granular (per-vendor)?~~ **Resolved 2026-05-15** (archive `2026-05-15-harden-cookie-transparency` D-CT-1, D-CT-4): cookieless-by-default is the strategic posture. Only Cloudflare Web Analytics is allowed on the marketing surface (qualifies for CNIL Sheet n°16 audience-measurement exemption — no banner needed). Adding any non-essential identifier (PostHog, GA4, Plausible-with-cookie-pixel, LinkedIn Insight, etc.) requires a new change reverting D-CT-4 and shipping a CNIL-grade CMP banner.
- ~~**Custom 404/500 pages.** Branded error pages with the same layout chrome — design treatment not yet defined. At launch: Next.js defaults are acceptable.~~ **404 resolved 2026-05-26** (archive `2026-05-26-add-marketing-not-found-page`, RAP-1239): the marketing 404 ships as `NotFoundPage` (`@rapoport-studio/ui/marketing/not-found-page`) inside the `(marketing)` group layout — three closed variants (`editorial` default, `spec`, `blueprint`), variant rail hidden in production per D-DS-11. The marketing **500** page remains open until first observed incident.
- **`/work/[slug]` and `/lab/[slug]` published date semantics.** Studios traditionally hide dates on case studies (timeless feel). Articles probably keep dates. Decision pending.
- **Intake form analytics.** Track abandonment per field? Helpful for funnel optimization but adds tooling. Defer until the form has measurable volume.
- **Content collection tooling.** `next-mdx-remote` is named in dependencies as a working default. Alternatives (`contentlayer`, `fumadocs`) offer better DX but heavier integration. Trigger to revisit: when MDX authoring becomes more than 10 files per content type.

---

## Related capabilities

- `platform.md` — overall context, brand boundaries, operating model
- `studio-workspace/README.md` — `app.rapoport.studio`, the workspace that processes intakes
- `design-system.md` — visual tokens, typography, animation, brand voice; addresses the landing surface explicitly under § Surfaces › Landing
- `ui.md` — implementation package consumed by this app
- `i18n.md` — locale routing and chrome catalogs

---

## Client onboarding surfaces (applied 2026-05-04 from change `add-client-onboarding`)

Adds two new marketing routes and replaces the stub contact form with a full intake form.

### IntakeForm component

`apps/web/src/components/contact/intake-form.tsx` replaces the deleted `ContactForm.tsx` stub. Props: `{ defaultEngagementInterest: 'discovery' | 'full' | 'unsure' }`. Fields: Name, Email, Company (optional), Role (optional), Engagement interest (radio group — hidden and locked in discovery mode), Project type (select: Platform / AI Orchestration / Audit / Other), Budget range (select — hidden when `engagement_interest = 'discovery'`, required when `= 'full'`), Timeline (select), Brief (textarea, min 50 chars), Source (optional freetext). Honeypot: `website_url` hidden input. On submit: `POST /api/intake` with full schema including `locale` from `useLocale()`. Source field defaults to `"web/discovery"` or `"web/contact"` when left empty. CTA: "Begin Discovery →" (discovery mode) or "Send brief →" (all others).

### /start-project page

`/start-project` uses `<IntakeForm defaultEngagementInterest="full" />` — the full engagement form with all fields visible and budget_range required.

### /discovery page

New route `apps/web/src/app/[locale]/(marketing)/discovery/page.tsx`. Copy: eyebrow "Validation Atelier", heading "Discovery", one-sentence description, 4-step process block (Map domain → Design AI layer → Validate plan → Deliver spec), pricing bar (€5,000 fixed · 3–4 weeks · NDA-bound), `<IntakeForm defaultEngagementInterest="discovery" />`. `generateMetadata()` emits title and description. `generateStaticParams()` covers all three locales.

### Sitemap

`apps/web/src/app/sitemap.ts` — new file. Static routes: `/`, `/contact`, `/discovery`, `/services`, `/work`, `/lab`, `/network`. One entry per locale × route with `alternates.languages` for hreflang. Uses `routing.locales` from `@rapoport-studio/i18n`.

### Homepage discovery banner

`apps/web/src/components/home/discovery-banner.tsx` — server component, full-width, `border-t border-b border-grid`. Left: eyebrow "Validation Atelier", heading "New: Discovery, €5k fixed, 3–4 weeks.", two sublines. Right: `<Link href="/discovery">Begin Discovery →</Link>`. Inserted between `<Hero />` and `<WhatWeBuild />` in `apps/web/src/app/[locale]/(marketing)/page.tsx`.

---

## Community signup CTA target (applied 2026-05-06 from change `add-studio-self-serve-signup`)

The two community CTAs introduced by `expand-home-with-services-and-community` (`BuildOnPlatform`, `CommunitySignal` — both home-page sections) previously routed to `/contact?segment=community`. The `/contact` route resolves to the `IntakeForm` shaped for client engagements (full / discovery), and the `segment` query parameter was read by no consumer — every community-side CTA on the live homepage was a soft dead-end.

Both CTAs now route cross-domain to `https://app.rapoport.studio/community/signup` — the studio's self-serve signup entry point (see `studio-workspace/spec.md § Community member surface`).

### Implementation

- Configurable URL: `studio.signupUrl` accessor in `@rapoport-studio/config` (`packages/config/src/studio.ts`), reading `NEXT_PUBLIC_STUDIO_SIGNUP_URL` with fallback `https://app.rapoport.studio/community/signup`.
- `apps/web/src/components/home/build-on-platform.tsx` and `apps/web/src/components/home/community-signal.tsx` both swapped from `next-intl <Link href="/contact?segment=community">` to plain `<a href={studio.signupUrl}>`.
- The link is intentionally a plain anchor, not the locale-aware next-intl `<Link>`. The target is on a different subdomain (`app.rapoport.studio`); the locale-prefix machinery is wrong for cross-domain navigation.
- `/contact` remains unchanged — it stays the client-engagement intake surface.

### Boundary preserved

`apps/web` still has **zero** direct `@rapoport-studio/auth` imports (verified via `grep -r '@rapoport-studio/auth' apps/web/src` — 0 hits). The marketing site links cross-domain to the studio; it does not import auth or DB primitives. The same rule from `auth.md § Modules` ("`apps/web` has zero direct imports of `@rapoport-studio/auth`") still holds.

### Operational note

`NEXT_PUBLIC_STUDIO_SIGNUP_URL` should be set in CI / Cloudflare Workers production env if the canonical signup URL ever changes. The fallback covers the canonical production path so dev / preview environments don't need to define it.

## Network public surface (applied 2026-05-10 from change `add-network-public-surface`)

`apps/web/src/app/[locale]/(landing)/network/` renders `/network` as a server-component scroll narrative across nine EntityCard-driven sections (hero / now-active / open-calls / mind-map / ai-clusters / bridges / channels / cases / services-teaser / join-cta). Live data via Supabase RLS-gated PostgREST queries through `lib/fetchers.ts` (anon-key client, `getCloudflareContext()`). Mind map uses a server-pre-computed dagre LR layout (`lib/mind-map-layout.ts`) with client-side interactivity (click → detail Sheet, URL deep-link `?node=<kind>:<id>`, `< 768px` tier-grouped fallback). Locales en + ru ship with parity content; ro placeholder per RAP-93 ratification.

Ships across two PRs: #529 (sections 2.1/2.5/2.6/2.7/2.9/2.10) and #538 (Wave 3 close-out — fetchers + mind map + locales + remaining sections).

Authoritative source: `archive/2026-05-10-add-network-public-surface/{proposal,design}.md`.

Deferred follow-up (carved out): Playwright e2e for `/network` empty-state and section selectors lands as part of a wider `apps/web` e2e push (no separate change folder today).

## URL-state overlay system cross-references (applied 2026-05-10 from change `add-url-state-overlay-system`, RAP-308)

Added root layout overlay requirement to § Architecture: `apps/web/src/app/layout.tsx` must mount `<OverlayHost registry={registry} />` as a sibling of the provider chain. Full overlay contract in `openspec/current/linkable-ui.md`.

## Route caching policy (applied 2026-05-11 from change `add-frontend-runtime-standards`)

`apps/web` is the marketing surface — ~6 static routes plus a small intake API. Its caching posture is **static-assets-only**, not R2-backed:

- `apps/web/open-next.config.ts` is `defineCloudflareConfig({})` — empty by design. No R2 incremental cache, no Durable Object tag cache, no queue. Marketing pages serve from Workers Static Assets via OpenNext.
- This sidesteps the documented R2 hang on builds with >300 prerendered routes ([opennextjs#637](https://github.com/opennextjs/opennextjs-cloudflare/issues/637)) and avoids unnecessary Durable Object compute cost.
- Edge cache headers (`Cache-Control: public, s-maxage=…, stale-while-revalidate=…`) for public, non-personalised marketing routes are the studio canon — this file's `next.config.ts § headers()` is the reference shape.
- When `apps/web` later ships dynamic routes that warrant on-demand revalidation (e.g. a CMS-backed `/work/[slug]`), upgrade to the R2 + DO setup per [`frontend-runtime.md § Layer 1 — OpenNext backing`](frontend-runtime.md).

Client-side router cache: `apps/web/next.config.ts § experimental.staleTimes` is set to `{ dynamic: 30, static: 180 }` (Next 15 fallback path; migration to per-segment `cacheLife` happens domain-by-domain when reads are wrapped — see `frontend-runtime.md § Layer 3`).

Full contract: [`frontend-runtime.md`](frontend-runtime.md).

## Specialist landing surface (applied 2026-05-13 from change `add-specialist-onboarding`)

New route `/[locale]/specialists` added to the routing table above. Ships across PR #936.

### Components

`apps/web/src/components/specialists/` — six server components: `SpecialistsHero`, `WhyThisExists`, `WhatYouContribute`, `WhatYouGet`, `TheArtefact` (trust-pillar canonical sentences verbatim; "OpenSpec" exempt from cold-pitch vocabulary constraint), `ApplyCTA` (primary + sticky scroll variant). All components consume tokens from `@rapoport-studio/ui`; no hardcoded colors.

### Sitemap

`/specialists` added to `STATIC_ROUTES` in `apps/web/src/app/sitemap.ts`. One entry per locale (`en`, `ru`, `ro`) with `alternates.languages` hreflang.

### CTA re-targeting

`BuildOnPlatform` and `TheNetworkAndCommunity` home-page blocks re-targeted from `studio.signupUrl` → locale-aware `<Link href="/specialists">` (PR #936).

---

## May 2026 home audit batch (RAP-744) — web infrastructure amendments

### Marketing edge-caching convention (HFC, archive `2026-05-14-optimize-home-fonts-and-cache`)

Marketing pages render statically with `revalidate ≥ 3600`. Request-context-dependent UI (cookie peek, geolocation, headers) is implemented as a client island, not a server-side conditional that opts the page into dynamic rendering. Source: `apps/web/src/app/[locale]/(marketing)/page.tsx` + `apps/web/src/components/nav-auth-link.tsx`.

### Cookie disclosure surface (archive `2026-05-15-harden-cookie-transparency`)

The marketing site enforces a **cookieless-by-default** posture under D-CT-1. Only one third-party host is allowed on `apps/web/`: `static.cloudflareinsights.com` (Cloudflare Web Analytics beacon — cookieless per vendor spec; qualifies for the CNIL Sheet n°16 audience-measurement exemption). Adding any non-essential identifier requires a new change that reverts D-CT-4 and ships a CNIL-grade CMP banner.

**One strictly-necessary first-party cookie** is set on the marketing surface: `NEXT_LOCALE` (next-intl middleware, session-only, no `Expires`) — locale-preference persistence so the bare path `/` redirects to the correct locale URL. Documented in `_legal/cookie-inventory.md` § Marketing site.

**Disclosure surface.** `/legal/cookies` ships two-surface disclosure (Marketing + Workspace) at `apps/web/src/app/[locale]/(marketing)/legal/cookies/page.tsx`. Sub-processor list lives at `/legal/sub-processors` (bridge implementation, 10 executed vendor DPAs; replaced by `publish-legal-bundle` Phase 6 when that lands). Both pages render through `<DocumentShell>` and use the `<InventoryTable>` / `<SubProcessorTable>` / `<DPABadge>` / `<EmptyStateRow>` / `<LegalSection>` primitives in `@rapoport-studio/ui` (Round 2 design spec, accepted 2026-05-15).

**CI gate.** `tools/verify-cookieless-marketing.mjs` (RAP-846) scans `apps/web/src/**/*.{ts,tsx,js,jsx,mjs}` and `packages/ui/src/**/*.{ts,tsx}` for forbidden patterns (`document.cookie`, `localStorage.`, `sessionStorage.`, tracker domains, global symbols like `gtag(`/`fbq(`/`posthog.`). Allowlist is exactly one host. Wired into `.github/workflows/verify.yml` under step name `Verify cookieless marketing posture`. Adding a new tracker without the corresponding inventory + page update fails CI.

**Forensic map.** Every code-level cookie/storage write site carries a `cookie-inventory:` comment anchor pointing at the inventory section. `git grep -n 'cookie-inventory:'` returns the complete write-site map.

### Cookie transparency notice (archive `2026-05-16-harden-cookie-compliance-followup`)

A first-visit informational notice surfaces the cookieless posture proactively. **It is not a consent gate** (D-CC-1) — there are no non-essential cookies to gate; a CMP banner would be theater. One dismiss action, links to `/legal/cookies`.

- `<TransparencyNotice>` in `@rapoport-studio/ui` — bottom dismissable bar, client-only island (no SSR, no CLS), RTL-aware.
- Mounted via `apps/web/src/components/cookie-notice-island.tsx` on the `[locale]` root layout; reads `web.cookieNotice.*` i18n strings.
- Dismissal persists in `localStorage` under `cookie-notice-dismissed:v1` — the `:v1` suffix is the notice version; bump it on material copy change to re-surface.
- Dismissal writes one row to the `consent_events` Supabase table (anon-INSERT-only RLS) via `apps/web/src/lib/consent-log.ts`, keyed by an anonymous `localStorage` UUID (`anon-visitor-id`, no PII linkage). Forward-compatible audit log — schema supports a future full CMP without re-migration.
- The two new `localStorage` identifiers (`cookie-notice-dismissed:v1`, `anon-visitor-id`) are allowlisted in the CI gate via inline `// CT-ALLOWLIST: <identifier>` directives; the gate still fails on any non-allowlisted `localStorage.` use.

### Font loading convention (HFC, archive `2026-05-14-optimize-home-fonts-and-cache`)

Marketing-surface `next/font` declarations meet three rules:

- Unused font families are deleted from `apps/web/src/app/fonts.ts` rather than left for future tree-shaking. (`next/font/local` does not tree-shake declared families.)
- Self-hosted variable fonts use `preload: true` (explicit, even though it is the default) to ensure the bundler emits `<link rel="preload" as="font">` on the rendered HTML head — required for LCP on the hero `<h1>` which resolves to the canonical sans (currently Lora).
- `next/font/google`-only options (`subsets`, `text`) do not apply to `next/font/local` declarations. Woff2-level subsetting of self-hosted fonts requires a build-time toolchain (`pyftsubset` / `glyphhanger`) and is out of scope for the font-loading convention itself.

### Marketing metadata convention (HSS, archive `2026-05-14-home-seo-and-share-baseline`)

Marketing pages override `<title>` and `<meta description>` via page-level `generateMetadata` reading from `web.meta.<page>.*` i18n keys. Both fields are localized per shipped locale. `og:image` is provided via a co-located `opengraph-image.tsx` per page or route group; `twitter:card` is `summary_large_image`.

### Hreflang and canonical convention (HSS, archive `2026-05-14-home-seo-and-share-baseline`)

Marketing pages declare `alternates.canonical` per locale with absolute URL. `alternates.languages` includes every shipped locale plus `x-default` pointing to the canonical English variant. Copy that names a locale must match `apps/web/src/i18n/routing.ts`.

### Structured-data baseline for marketing home (HSS, archive `2026-05-14-home-seo-and-share-baseline`)

Marketing home injects Organization, WebSite, ProfessionalService, Service (one per paid engagement), and Person (founder) JSON-LD via a single `<script type="application/ld+json">` containing a JSON array.

---

## Lab detail route + first whitepaper (applied 2026-05-15 from change `publish-operating-model-whitepaper`)

`/lab` gains a per-slug detail route at `/[locale]/lab/[slug]/page.tsx`, rendering MDX bodies through `next-mdx-remote/rsc` with a render branch on the frontmatter `kind` field:

- `kind: "whitepaper"` → wraps body in `<DocumentShell eyebrow="WHITEPAPER" heading lastUpdated footerMeta={<WhitepaperFooter/>}>`. The shell, prose preset, and 720px reading column come from `design-system.md § Document surface`. The footer slot renders publication date + version anchor + `/contact` CTA.
- `kind: "article"` (default for the 6 pre-existing articles) → renders the existing article wrapper unchanged (header with read-time / date / title / excerpt, prose-document body, `max-w-reading` column).

The frontmatter zod schema in `apps/web/src/content/lab/_schema.ts` carries an optional `kind: z.enum(["article", "whitepaper"]).default("article")` field. Backwards-compatible with all 6 pre-existing articles (no edits required); forward-compatible with future categories (`rfc`, `playbook`, `case-deep-dive`) — extend the enum when first such post lands.

### `/lab` index card variants

The index partitions posts by `kind` and renders two stacked sections: whitepapers as `<PostCard mode="featured">` pinned at the top (full-width card, larger heading), articles in the existing 3-column grid below. Index renders identically when no whitepaper is present.

### First whitepaper

`apps/web/src/content/lab/operating-model.mdx` ships as the inaugural `kind: "whitepaper"` post — the studio's Operating Model document, ~4,000 words across 12 sections, sourced from `_methodology/rapoport-studio-engagement.md`, `specialist-network.md`, `manifesto.en.md`, `positioning.md`, `current/platform.md`, `current/agents/muse.md`. URL: `/[locale]/lab/operating-model` (English body served at all three locales per existing lab pattern; per-locale MDX deferred until Pavel-voice Russian canonical lands).

Cross-ref: `archive/2026-05-15-publish-operating-model-whitepaper/`. Linear: [RAP-809](https://linear.app/rapoport-studio-slr/issue/RAP-809).

### Lab frontmatter `toc` field (applied 2026-05-15 from RAP-799)

The lab frontmatter schema gains an optional `toc?: Array<{ id: string; title: string }>` field. When set on a `kind: "whitepaper"` post, the lab detail route passes it through to `<DocumentShell toc={...}>` which renders the sticky aside + inline collapsed `<details>` per `design-components.md § DocumentShell`. Articles (`kind: "article"`) do not consume the field even if set. Authors maintain the list manually alongside body headings; automatic extraction from MDX is out of scope (revisit if a second whitepaper-class post lands with significant drift between body and frontmatter).

## Per-service pages + Hero-C + DecisionRouter (applied 2026-05-24 from add-per-service-pages-and-conversion)

### Per-service detail pages

Five `<ServicePageTemplate />` pages ship under `/services/<slug>`:

| Slug | Title | Indicative range |
|---|---|---|
| `discovery` | Discovery | Fixed scope · 4 weeks |
| `migration` | Migration | Scoped per engagement |
| `modular` | Modular | Per module scope |
| `platform` | Platform | Full-build engagement |
| `subscription` | Subscription | Monthly retainer |

Each page emits a `Service` schema JSON-LD block (design.md § 5). CTA routes to `/start-project?source=web/services/<slug>`. Discovery additionally links to `https://github.com/rapoport-studio/openspec` (Q-PSC-1 default). Long-form content (blurb, deliverables) is English-only across all locales per the long-form locale strategy.

### DecisionRouter placement on /services

`<ServicesDecisionRouter />` (a client wrapper around `<DecisionRouter />`) mounts between the `<SectionHead>` and the `<ServiceFunnelCatalog>` on `/services/page.tsx`. It presents three prompts (unsure → discovery, migrate → migration, build → platform) and hash-navigates to `#service-card-<slug>` on selection. Hash state `#start=<slug>` on page load highlights the matching card.

### Hero-C (homepage hero rewrite)

`<HeroC />` at `apps/web/src/components/home/hero-c.tsx` replaces `<Hero />` on the homepage. Manifesto-first three-line lockup:

- Line 1: "Architecture-first." (ink)
- Line 2: "Agent-native." (ink)
- Line 3: "Built in the open." (ink-light)

Subhead ≤160 chars. Primary CTA → `/services`; secondary CTA → `/founders` (fallback applied: `/principles` not yet live). No `AudienceTag`. All copy passes `pnpm lint:cold-pitch-vocab`. i18n keys: `web.home.heroC.*` in all three locales.

### 308 redirect

`/[locale]/discovery` → `/[locale]/services/discovery` via `permanentRedirect()` in `apps/web/src/app/[locale]/(marketing)/discovery/page.tsx`. The old page content (process walkthrough, inline intake form) is superseded by the new `/services/discovery` detail page.

Cross-ref: `openspec/archive/2026-05-24-add-per-service-pages-and-conversion/`. Linear: [RAP-1212](https://linear.app/rapoport-studio-slr/issue/RAP-1212).

---

## /network — empty-state contract (applied 2026-05-23 from fix-network-loading-state)

Sections on `/network` MUST follow these rules:

1. Per-section `<Suspense>` fallback MUST contain honest interim copy describing why the section is empty and what backing data populates it.
2. The string "Loading…" MUST NOT appear as a user-visible placeholder for sections waiting on data-source population. "Loading…" is reserved for in-flight requests with a finite expected wait.
3. Sections without backing data MAY be omitted from the page until data exists; per-section absence is preferable to per-section "coming soon" if no honest interim copy is available.
4. The bottom "How to come in" CTA MUST always render — it is the primary inbound funnel from `/network`.

Cross-ref: `archive/2026-05-23-fix-network-loading-state/`. Linear: [RAP-1189](https://linear.app/rapoport-studio-slr/issue/RAP-1189).

---

## Primary nav contract (applied 2026-05-28 from change redesign-primary-nav)

The marketing-site header (`apps/web/src/components/nav.tsx`) left-of-CTA linkset MUST render four top-level entries in this order:

1. **Services ▾** — dropdown trigger. Renders `NavPanelGrouped` (5 engagement-shape items under `/engagements` eyebrow + 2 audience-framing items under `/for` eyebrow + featured block).
2. **Work** — direct link to `/work`.
3. **Lab** — direct link to `/lab`.
4. **About ▾** — dropdown trigger. Renders `NavPanelStructured` (4 trust-bundle links — Principles · Method · OpenSpec · Network — + featured block).

The linkset is sourced from `apps/web/src/components/nav-config.ts` (`NAV_ENTRIES`), which is the single source of truth for nav IA. Adding a new top-level entry, a panel item, or shifting an entry between Services and About happens by editing `nav-config.ts` plus the matching `nav.*` i18n keys — `nav.tsx` itself must not hard-code labels or hrefs (D-RPN-3, D-RPN-5).

### Primitive

The dropdowns are rendered via `@rapoport-studio/ui/composed/navigation-menu` — a thin wrapper over `@radix-ui/react-navigation-menu` with four panel layouts (`NavPanelMinimal`, `NavPanelStructured`, `NavPanelGrouped`, `NavPanelEditorial`). Of those, Services consumes `NavPanelGrouped` and About consumes `NavPanelStructured`; the other two are exported for future surfaces (D-RPN-1, D-RPN-3). The primitive's API and ARIA contract is documented in [`design-components.md § Compound primitives`](design-components.md).

### Active-route indicator

The active trigger MUST carry `data-active="true"` driven by a prefix match against `pathname` from `next-intl`'s server pathname helper. The default visual indicator is `underline` (1px blueprint rule, 2px offset) on `data-active`. The `data-indicator` prop on `<NavigationMenu>` accepts `underline` / `bracket` / `arrow` — surfaces other than the marketing nav may opt into the alternates; the marketing nav stays on `underline` (D-RPN-6).

Route → trigger active mapping:

- `/services`, `/services/*`, `/founders`, `/specialists` → Services
- `/work`, `/work/*` → Work
- `/lab`, `/lab/*` → Lab
- `/principles`, `/method`, `/openspec`, `/network` → About

### Hover-intent + keyboard contract

Hover opens the panel after Radix's default delay; mouseleave closes after Radix's close delay. One panel open at a time — opening trigger B closes panel A (D-RPN-7). Escape closes any open panel. Tab cycles between triggers; Arrow keys cycle links inside an open panel. No shared `<NavigationMenuViewport>` in v1; each trigger renders its own `<Content>` (D-RPN-7).

### Mobile-drawer accordion contract

Below `768px` the drawer (`apps/web/src/components/nav-mobile-drawer.tsx` + sibling client island `nav-mobile-links.tsx`) renders the same four entries as a vertical stack:

- **Services** and **About** render as native `<details>` accordions with a `<summary>` chrome (mono uppercase, 14px, `tracking-[0.18em]`, ≥56px tap-target, ChevronDown rotates 180° on open).
- **Work** and **Lab** render as flat `<Link>` between the two accordions.
- The `<details>` whose `matchRoutes` cover the current `usePathname()` opens by default; the other stays closed.
- Services-accordion body renders primary items + a hairline divider + secondary items (mirroring the desktop subgrouping inside `NavPanelGrouped`).

The native `<details>` toggle keeps zero added client JS for the desktop accordion logic (D-RPN-8). The `nav-mobile-links.tsx` extraction exists because the layout's RSC cache freezes server-side `pathname` reads — the open-state computation must respond to `usePathname()` from a client island.

### Motion

Panel motion is `160ms` fade + `6px` translate on open/close (D-RPN-11). The blueprint rule under the active trigger uses a `320ms` `--ease-deploy` deploy-from-center. Both collapse to instant under `prefers-reduced-motion: reduce` (handled globally by the `--duration-scalar` cascade).

### Featured-block content

Each dropdown panel may carry a `featured` block (eyebrow + body). Featured-block content is hard-coded in `nav.tsx` for v1 — no CMS, no editorial pipeline (D-RPN-9). The `featured` prop is optional on `NavPanelStructured` and `NavPanelGrouped`; passing `undefined` omits the block entirely.

Cross-ref: `openspec/archive/2026-05-28-redesign-primary-nav/`. Linear: [RAP-1216](https://linear.app/rapoport-studio-slr/issue/RAP-1216). Decisions: D-RPN-1..11.

---

## Header right-cluster contract (applied 2026-05-24 from change refine-nav-locale-and-auth)

The marketing-site header (`apps/web/src/components/nav.tsx`) right-hand cluster MUST render three groups in this order, left-to-right:

1. **Filled primary CTA** — `Start a project →` (filled ink button; existing).
2. **Outline secondary CTA** — `Sign in` (anonymous) or `Open app →` (authenticated). Both auth states use the same outline-button shape (`bg-canvas`, `border border-ink`, `text-ink`, `font-mono text-xs uppercase tracking-[0.15em]`, `px-4 py-2`) so the cluster width is auth-state-stable.
3. **Utility locale glyphs** — `EN · RO · RU` two-letter codes (`apps/web/src/components/locale-switcher.tsx` desktop branch), `font-mono` uppercase `tracking-[0.2em]`, with CSS-only native-name tooltips on `:hover`/`:focus-visible` and a 1px `--color-blueprint` underline on the active locale (active-state marker MUST NOT be colour-only).

`NavAuthLink` (`apps/web/src/components/nav-auth-link.tsx`) MUST NOT return `null` in any branch. Authenticated visitors get an `Open app →` link to `https://app.rapoport.studio`; unauthenticated visitors get a `Sign in` link to the same URL. Auth state is read from the Supabase cookie via `/^sb-[^=]+-auth-token(\.\d+)?$/`.

Mobile-drawer behaviour is unchanged: it continues to use the compact `<select>`-based `LocaleSwitcher` and the secondary CTA renders below the primary CTA.

Cross-ref: `openspec/archive/2026-05-24-refine-nav-locale-and-auth/`. Linear: [RAP-1215](https://linear.app/rapoport-studio-slr/issue/RAP-1215).

---

## Marketing-funnel measurement layer (applied 2026-05-25 from change `add-marketing-funnel-instrumentation`, RAP-1214)

Server-side, cookieless funnel measurement. No client analytics SDK; no third-party tracker.

### `/api/funnel` ingestion route

`apps/web/src/app/api/funnel/route.ts` — POST endpoint that accepts whitelisted event kinds and writes them to `marketing_funnel_events` via the service-role Supabase client.

Contract:

| Step | Behaviour |
|---|---|
| Whitelist | `event_kind` must be in `FUNNEL_EVENT_KINDS` (see `apps/web/src/lib/funnel-constants.ts`). Unknown → 400, no insert. |
| Size cap | JSON-stringified `metadata` payload bounded by `FUNNEL_METADATA_MAX_LENGTH`. Oversized → 413, no insert. |
| Success | Service-role insert into `marketing_funnel_events`; returns 204 No Content. |
| DB error | Insert error surfaces as 500 (Cloudflare metrics distinguish from misconfigured-env 503). |

### `emitFunnel(event_kind, metadata?)` client helper

`apps/web/src/lib/funnel.ts` — fire-and-forget POST to `/api/funnel`. `keepalive: true` so the request survives navigation triggered by the click. Reads `window.location.pathname` and `document.documentElement.lang` for `source_page` and `locale`. Per-tab anonymous UUID stored in `sessionStorage` under `rs-funnel-session` (cookieless; allowlisted in `tools/verify-cookieless-marketing.mjs` via inline `// CT-ALLOWLIST: rs-funnel-session` directives).

### Emit-points wired on the marketing surface

| Surface | Event | Metadata |
|---|---|---|
| `/services` `<DecisionRouter />` prompt click | `decision_router_selected` | `{ selected_slug }` |
| `/openspec` zip-archive download anchor | `openspec_archive_downloaded` | `{}` |
| `<IntakeForm />` submit success (via `apps/web/src/lib/intake-client.ts`) | `lead_submitted` | `{ engagement_interest, project_type }` |

Two emit-points remain deferred:

- Multi-step `<IntakeForm />` step-transition emits (`intake_step_reached`) — requires a `packages/ui` primitive change; tracked as a follow-up.
- Stripe webhook `discovery_paid` emit — gated on `wire-stripe-checkout-on-discovery` (RAP-1213) operator setup.

### Weekly Telegram digest

`apps/web/src/cron/marketing-digest.ts` — `runMarketingDigest(env)` aggregates the last 7 days of events into a Telegram-Markdown summary (Traffic / Leads / Decision-router / Other emit-points) and POSTs to the operator's chat via `TELEGRAM_FUNNEL_BOT_TOKEN` + `TELEGRAM_OPERATOR_CHAT_ID`.

The Worker `scheduled()` entrypoint that routes the cron pattern (`0 6 * * 1`) to this handler is shared with the pre-existing operating-model-stats cron — both wait on the same custom-worker-entrypoint follow-up PR.

Cross-ref: `openspec/archive/2026-05-25-add-marketing-funnel-instrumentation/`. Linear: [RAP-1214](https://linear.app/rapoport-studio-slr/issue/RAP-1214).

## Icon contract (applied 2026-05-25 from change add-studio-favicon)

The marketing site (and the workspace, which shares the icon-convention paths transitively) carries a **two-layer favicon mark** — RS letterforms inside a tactical four-corner bracket frame. Both layers ship together; neither alone is the favicon. The default Next.js `favicon.ico` is deleted; replaced by Next 16's file-based icon convention.

### Files

```
apps/web/src/app/
  icon.tsx         — dynamic SVG via plain Response; embeds @media (prefers-color-scheme: dark) inside the SVG body so one route serves both variants
  icon.svg         — static SVG fallback (Firefox + other SVG-aware consumers that bypass the dynamic route)
  apple-icon.tsx   — dynamic 180x180 PNG via next/og ImageResponse; ivory RS + ivory bracket frame on a terracotta tile

packages/ui/assets/marks/
  rs-glyph.svg     — single source SVG, 256x256 viewBox, letterforms only (no brackets — D-FAV-10); committed source
  README.md        — what this file is, who reads it, how to regenerate

apps/web/src/generated/
  rs-glyph.ts      — build-time codegen embedding rs-glyph.svg as RS_GLYPH_SVG + RS_GLYPH_PATHS typed constants (gitignored — regenerated by postinstall + apps/web prebuild via tools/build-rs-glyph.mjs)
```

### Two-layer mark contract

- **Letter layer (D-FAV-3).** The "RS" glyph pair, JetBrains Mono SemiBold (weight 600), uppercase, tracking `0em`, cap-height 53 % of viewBox (136 px at the 256-px source). Vectorised once via `apps/web/scripts/extract-rs-glyph.{mjs,py}` from `apps/web/src/app/fonts/jetbrains-mono-var.woff2`; no font binary embedded in the icon routes.
- **Bracket layer (D-FAV-10).** Four corner L-brackets, hardcoded as percentages of viewBox in `icon.tsx` and `apple-icon.tsx` via `BRACKET = { lenPct: 18, weightPct: 4.5, insetPct: 7 }`. **Brackets are not part of `rs-glyph.svg`** — they render separately so per-surface colour can swap (terracotta on light/dark tab, ivory on touch tile) without forking the source SVG. `icon.tsx` and `icon.svg` emit each bracket as two axis-aligned `<rect>`s (one per leg of the L, 8 rects total); `apple-icon.tsx` uses four absolutely-positioned `<div>`s with `border-top` + `border-left` (etc.).
- **Per-surface palette (D-FAV-2).** Light tab: ivory canvas, ink letters, terracotta `#F97316` brackets. Dark tab: near-black canvas, lifted-ink letters, orange-400 `#FB923C` brackets. Apple touch tile: terracotta canvas, ivory letters + ivory brackets (accent-on-accent would disappear, so the brackets collapse to canvas on the accent tile).

### Media-aware switching (D-FAV-11(b))

Light/dark is selected by an `@media (prefers-color-scheme: dark)` CSS block embedded **inside the SVG body** of `icon.tsx` and `icon.svg`, not by two media-aware `<link rel="icon">` tags. Next 16's `generateImageMetadata.media` field is silently dropped by `next/dist/lib/metadata/resolvers/resolve-icons.js`, so the two-link approach would have shipped untagged `<link>`s and the browser would pick one non-deterministically. Browsers honour the media query inside `<link rel="icon">` SVGs on Chrome / Safari / Firefox / Edge (stable since ~2020).

### Cloudflare Worker safety (D-FAV-11(a))

The icon routes import the glyph from `@/generated/rs-glyph` (build-time codegen), not via runtime `fs.readFile` against `packages/ui/assets/`. OpenNext does not bundle paths outside `apps/<app>/`, so a request-time fs read returns 500 in production — the same failure mode that took the marketing site red for 14 hours on 2026-05-15 with `_legal/cookie-inventory.md` (archive `2026-05-15-harden-cookie-transparency`, hotfix PRs #1179 + #1182). `tools/build-rs-glyph.mjs` mirrors `tools/build-legal-data.mjs`.

### `metadata.icons` non-statement (D-FAV-1)

`apps/web/src/app/[locale]/layout.tsx`'s `generateMetadata()` deliberately does **not** carry an `icons` key. Next 16 auto-emits `<link rel="icon">` and `<link rel="apple-touch-icon">` tags from the file-based convention paths; an explicit `metadata.icons` would override that and break the discovery. This is a non-statement that must stay non-stated.

Decisions: D-FAV-1..11. Source change: `openspec/archive/2026-05-25-add-studio-favicon/`. Linear: [RAP-1221](https://linear.app/rapoport-studio-slr/issue/RAP-1221).

---

## Marketing 404 contract (applied 2026-05-26 from change `add-marketing-not-found-page`, RAP-1239)

### `[locale]/<unknown>` — 404 (marketing-group)

- **Status:** experimental → canonical after first redirect-backlog review.
- **Renders:** `NotFoundPage` (`@rapoport-studio/ui/marketing/not-found-page`) inside the `(marketing)` group layout — sticky nav + four-column footer intact.
- **Source:** [`apps/web/src/app/[locale]/(marketing)/not-found.tsx`](../../apps/web/src/app/[locale]/(marketing)/not-found.tsx) plus the group catch-all at `[locale]/(marketing)/[...rest]/page.tsx` that calls `notFound()` so unmatched URLs surface the group-scoped not-found instead of the root-level [`apps/web/src/app/not-found.tsx`](../../apps/web/src/app/not-found.tsx) (kept as the no-locale fallback).
- **Surface:** light. Page-wide LIGHT theme is forced at the segment file; do not rely on parent theme cascade (resolved during follow-up `cece39a0b`).
- **HTTP status:** 404 (Next.js framework default for `not-found.tsx`; the segment file does not call `notFound()` itself).
- **i18n:** every string resolves through `packages/i18n/messages/{en,ru,ro}/web.json § pages.notFound.*`. Smoke-test key parity gate covers it.
- **Middleware contract:** [`apps/web/src/middleware.ts`](../../apps/web/src/middleware.ts) writes `x-invoked-path` and `x-invoked-search` response headers on every request. The 404 segment reads them via `headers()` for the route-echo block and to parse the optional `?surface=…` selector.
- **Telemetry:** none in v1. A `marketing.not_found_seen` event is a follow-up after an analytics sink lands on the marketing surface.
- **Variant rail:** hidden in production by default; surfaces under `?surface=editorial|spec|blueprint` for design-review and docs use. See `D-DS-11`.

### D-DS-11 — Variant rail on marketing 404 stays hidden by default

Production traffic sees the `editorial` variant only. The `spec` and `blueprint` variants are reachable under `?surface=…` for design-review and docs use. A visible production-facing toggle is `Q-DS-11`; revisit after first redirect-backlog review.

Decisions: D-DS-11. Source change: `openspec/archive/2026-05-26-add-marketing-not-found-page/`. Linear: [RAP-1239](https://linear.app/rapoport-studio-slr/issue/RAP-1239).
