# Renderer Architecture

This is the canonical reference for how `src/renderer/` is organized: directory responsibilities, dependency direction, and the rules that keep them enforceable.

Renderer code is organized along **two orthogonal axes** — **type** (what kind of artifact it is) and **domain** (which business domain owns it) — with dependencies flowing **strictly downward**, and a **closed top-level**: no capability ever earns its own top-level directory.

## 1. Two Axes

| Axis | Question it answers | Values |
|---|---|---|
| **Type** | What kind of artifact is this? | page / component / hook / service / util / … |
| **Domain** | Which domain owns it? | a specific business domain (chat, knowledge, agent, …) **\|** `shared` (no single owner) |

- `features/<domain>/` is a full **row** on the domain axis: it spans every type column for one domain (its own pages, components, hooks, services, utils). This is why a feature is "cross-cutting" — it cuts across the type buckets.
- The top-level type buckets (`components/`, `pages/`, `hooks/`, `utils/`, …) are the cells of the **`shared` row**: they hold **only** the cross-domain / standalone remainder.
- The meaningful comparison is **cell-to-cell within a column** (`features/chat/components/` ↔ top-level `components/`), never `features/` ↔ `components/` (a category error: a row is not a cell).

## 2. Layers & Dependency Direction

Four layers. Dependencies may only flow **downward** (1 → 2 → 3 → 4).

| # | Layer | Directories | Role |
|---|---|---|---|
| 1 | **App / composition** | `windows/`, `routes/`, top-level `pages/` (cross-domain shells only) | Entry points, provider mounting, router, app shell; composes features |
| 2 | **Domain** | `features/<domain>/` | One business domain's vertical slice; mutually isolated from sibling features (consumed from above by the app layer) |
| 3 | **Shared** (no single owner) | `components/` → `hooks/` / `services/` → `utils/` / `data/` / `ipc/` / `workers/`; plus `i18n/` / `assets/` / `types/` | Cross-domain reusable artifacts |
| 4 | **Primitives** | `packages/ui` (`@cherrystudio/ui`), `@shared`, `@logger` | App-agnostic foundation |

Rules:

- **Within the type axis**: `window → page → component → primitive` (UI composition; detailed in §2.1).
- **Along the domain axis**: a domain row depends only **downward** — on the shared layer, primitives, and its own internals; it **never** imports a sibling domain row, and the shared layer **never** imports it (an upward edge). Its only legal *consumers* are therefore the app layer (`windows/` / `routes/` / top-level `pages/`): `window → feature` and `page → feature` are the legal inbound edges — a feature is built to be imported from above. Cross-domain needs route **down** (extract the shared piece into the shared layer) or **up** (the app layer composes both features), never sideways.
- **Inside the shared layer**: `components` (UI) → `hooks` / `services` (behavior / runtime) → `utils` / `data` / `ipc` / `workers` (pure / infra) → primitives. No shared module renders into or imports from a higher layer.

**Why the two banned edges matter** — both keep the dependency graph a strict downward DAG. `shared → feature` (an upward edge) would make a *shared* module secretly domain-coupled, open `feature → shared → feature` cycles, and pin the feature into the eager shared chunk (defeating per-feature code-split). `feature → feature` (a sideways edge) would leak one domain's blast radius into another, bind callers to internals the barrel (§5) declares unstable, and block clean deletion (features get reshaped/removed in v2). Both are banned as **categories**, not case-by-case, so one `import/no-restricted-paths` rule enforces them (§5; sources in §9).

### 2.1 Type-Axis Composition Chain

The type axis is a strict UI composition order: each kind composes the one below it and never imports the one above.
It is orthogonal to the domain axis (§1) — a `page` may be domain-owned (`features/<domain>/pages/`) or a top-level shell, but its composition rules are the same either way. A feature-internal piece obeys the **identical** type-axis direction rules as its top-level counterpart; its one extra freedom is that it may import its **own** feature's siblings directly (internal cohesion needs no barrel — the §5 barrel is only the *external* door).

| Kind | Composes / may import | Must not import |
|---|---|---|
| `window` | router, app-wide providers, pages, features, shared, primitives | — (imported by no one) |
| `page` | components, feature content, shared, primitives | another `page`, a `window` |
| `component` | other components, primitives, shared behavior (`hooks` / `services` / `utils`) | `page`, `window`, `features` |
| `primitive` | third-party only | any `@renderer/*` / app layer |

**Same-kind peering vs same-slice isolation — two senses of "same layer".** Within one kind, peers compose freely: `component → component`, `hook → hook`, `util → util` are normal edges (a component is built from other components); the type axis only forbids importing **up** a kind (`component → page`/`window`/`feature`). Do not conflate this with the **domain-axis** rule that sibling *features* (the same slice layer, §2) may never import each other — `component → component` is allowed while `feature → feature` is not, because they sit on different axes. Two riders: (a) `page` is the one kind where same-kind peering is **also** banned (`page → page`, §7); (b) same-kind peering still obeys the domain axis — a shared component still can't reach up into a feature, nor a feature-A component sideways into feature-B (§2). `service` / `util` peering is allowed on the same terms but must stay **acyclic**.

**Primitive requirements** (`packages/ui` and `@shared`):

- `packages/ui` (`@cherrystudio/ui`) holds app-agnostic UI primitives (Shadcn + Tailwind). It imports only third-party packages and **never** `@renderer/*`; it carries no business, domain, or data-layer knowledge.
- `@shared` holds **cross-process** types, contracts, and pure logic, importable by both `main` and `renderer` and depending on no app layer. **Cross-process is the entry gate, not a description**: logic reachable from only one process stays in that process's own layer. For `@shared`'s internal layout, its two invariants (cross-process; no mutable runtime state), and the closed top-level set, see [Shared Layer Architecture](./shared-layer-architecture.md).
- Primitives are the leaves: everything may import them; they import no app code.

## 3. Directory Responsibilities

Target layout (in-flight directories pending migration are listed in §8):

```text
src/renderer/
├── windows/      # App      — per-window entry roots (MainApp/SettingsApp/SubWindowApp) + shell
├── routes/       # App      — route definitions
├── pages/        # App      — cross-domain shell pages only (domain pages live in features)
├── features/     # Domain   — one business domain per dir
│   └── <domain>/ #            index.ts (sole public API) + pages/ components/ hooks/ services/ utils/
├── components/   # Shared    — cross-domain, app-aware, presentational UI
├── hooks/        # Shared    — cross-domain hooks
├── services/     # Shared    — non-component singletons / runtime logic
├── utils/        # Shared    — cross-domain pure functions
├── data/ ipc/ workers/  # Shared infra — data access, IpcApi bridge, web workers
└── i18n/ assets/ types/ # Shared — locale, static assets, cross-domain types

packages/ui (@cherrystudio/ui)  # Primitive — app-agnostic design system
src/shared                       # Primitive — cross-process types / contracts / pure logic
```

| Directory | Responsibility | May depend on (downward) | Must not |
|---|---|---|---|
| `windows/` | Multi-window entry points; mount providers, router, shell | every lower layer | be imported by anyone |
| `routes/` | Route definitions pointing at pages | features, shared, primitives | be imported by lower layers |
| `pages/` (top-level) | **Only** cross-domain shell / composition pages; domain pages move into `features/<domain>/pages/` | features, components, shared, primitives | import another `pages/<page>` (cross-page coupling) |
| `features/<domain>/` | One **business domain**'s vertical slice (its pages/components/hooks/services/utils); curated `index.ts` is the sole public entry. Its **only** legal importers are the app layer (`windows`/`routes`/`pages`), via the barrel | shared layer, primitives, its own internals | (1) import a sibling feature (2) be imported by the shared layer or a sibling feature (3) hold non-domain / cross-cutting / domain-agnostic infra |
| `components/` | App-level **shared UI**: cross-page, no domain knowledge, app-aware, presentational | packages/ui, other components, hooks, services, utils, @shared | import features; import pages; own a domain's data flow |
| `services/` | App-level **singletons / runtime logic** — plain modules, **no components or JSX** | utils, data, ipc, @shared | import features; import pages; import components; render UI; call React hooks |
| `hooks/` | **Cross-domain** reusable hooks | services, utils, data, @shared | import features/pages/components; retain a domain's hooks once that domain has its own feature (§4.1) |
| `utils/` | **Cross-domain** pure functions | @shared, third-party only | import any higher layer |
| `data/`, `ipc/`, `workers/` | Foundational subsystems (data layer, IPC bridge, web workers) | utils, @shared | import features/pages/components |
| `i18n/`, `assets/`, `types/` | **App-global** locale / static assets / shared types only; domain-specific entries move into the owning feature | — | hold domain-specific content |
| `packages/ui` | App-agnostic design system (Shadcn + Tailwind primitives + generic composites) | third-party only | import any `@renderer/*` |

**Routing `services/` vs `hooks/` vs `utils/`.** The decisive test is the module's *shape*: pure / stateless → `utils/`; uses React lifecycle / state / context → `hooks/`; a stateful class owning state / resources → a `Service` / `Manager` (top-level `services/` when cross-domain); renders JSX → `components/` / `pages/`.
The authoritative table is [Naming Conventions §5.2](./naming-conventions.md).
These top-level buckets hold cross-domain pieces; a small **domain-specific** piece may stay here until its domain earns a `features/<domain>/`, then it moves in (the §4.1 promotion rule).

**Providers.** A React context provider is a **component**, not a service — `services/` holds non-component logic only.
App-wide providers (theme, command, context-key, notification) live in the shared tier (they are components) and are mounted by `windows/` (a downward `window → component` edge); domain-owned providers live in their feature.
A provider's reusable, non-React logic belongs in `@shared` or `services/`, not in the provider component itself.

## 4. `features/` Definition

> A `features/<domain>/` is a **self-contained business-domain module** — a full row on the domain axis that co-locates the pages, components, hooks, services, and utils for **one** business domain in a single tree, exposing its public API through a curated `index.ts`.
>
> *Self-contained* describes **internal cohesion** (all of one domain's parts live in one tree), **not** external unreachability: a feature is openly imported from above by the app layer (§2). It is isolated only **horizontally** — from sibling features.

- **Promotion, not default.** A domain earns a `features/<domain>/` home only once it is large and multi-file; a small domain stays as single files in the shared buckets. Do not pre-create a feature for an anticipated module. See **§4.1** for the operational trigger and a worked example.
- **Business domains only.** Cross-cutting capabilities (e.g. a command/keybinding system), domain-agnostic infrastructure (`data`, `ipc`), and the app shell do **not** live in `features/`.
- **Closest industry match** is bulletproof-react's `features/` (a self-contained domain folder). It is **not** FSD's fine-grained "feature" (a single business action) and **not** Nx's `type:feature` (a role that splits a domain across typed libs).

### 4.1 Promotion Rule — when a domain earns a feature

Promotion is **lazy and per-case** (§4), not a default — but it is a real path, not a directory doomed to stay empty: the rules above describe what the destination *looks like*. Until a domain qualifies, its pieces legitimately sit in the shared type-buckets (`pages/<domain>/`, `components/<domain>/`, `hooks/<domain>/`, …). (No `features/` directory exists yet — see §8.)

Operational trigger (guidance, not a hard gate) — promote when **all** hold:

- the domain already owns its **own page(s)** plus a **multi-file** spread of components/hooks/services across several shared buckets;
- those pieces are imported **mainly within the domain** — broad cross-domain reuse is instead the signal to push a piece **down** into the shared layer, not into a feature;
- folding them behind one barrel would **shrink** cross-bucket coupling, not merely relocate it.

Worked example — `chat` → `features/chat/`:

```text
# scattered today (shared type-buckets)        # promoted
pages/home/           chat page shell           features/chat/
components/chat/      ~288 files          →       ├── index.ts      # curated public API (named exports, no export *)
components/composer/  ~119 files                  ├── pages/        # ← pages/home
hooks/chat/                                       ├── components/   # ← components/chat + components/composer
services/…            chat-only services          ├── hooks/        # ← hooks/chat
                                                  └── services/     # ← chat-only services
```

After promotion: the app layer (`windows`/`routes`/`pages`) imports `@renderer/features/chat`'s barrel; nothing reaches into its internals (§5); and cross-surface runtime that *other* domains also use (e.g. the AI-stream transport, now at `services/aiTransport`) stays in the **shared** layer, **not** inside the feature.

## 5. Public API & Boundary Enforcement

- **Single entry.** Each feature exposes exactly one curated `index.ts` (explicit named exports, **no `export *`**). External consumers import the barrel; reaching into a feature's internal files is forbidden. (VS Code applies the same rule: one contribution may import only another's single public `common/` API, never its internals.)
- **Shared buckets carry no root barrel.** `types/` and `utils/` are *categories*, not modules: each has **no root `index.ts`** — consumers import the specific file or topic (`@renderer/types/<topic>`, `@renderer/utils/<topic>`), never the bucket root. A multi-file topic *subdirectory* exposes exactly one curated `index.ts` (named exports, **no `export *`**) and keeps its other files private; a single-file topic stays a flat `<topic>.ts` and is promoted to a subdirectory only when it actually owns multiple files. This mirrors [Shared Layer Architecture §3.1](./shared-layer-architecture.md) one-for-one — same rule, the bucket merely lives under `@renderer/*` instead of `@shared/*`.
- **Mechanical enforcement.** Boundaries are enforced by lint, not by convention alone. Configure `import/no-restricted-paths` zones: `components`/`hooks`/`utils`/`services` may not import `features`/`pages`; `pages` may not import another `pages`; `packages/ui` may not import `@renderer/*`. Roll out at `warn` to quantify existing violations, then tighten to `error`.

## 6. Top-Level Governance

> The top level is a **closed set of categories**, not an open list of modules. A new capability is placed **inside** an existing category by decomposing along the type axis; it never earns a new top-level directory.

This is the renderer-specific application of [Naming Conventions §4.8](./naming-conventions.md) (top-level directories are closed by default): a capability fails §4.8's *necessity* test because existing buckets can host it by decomposition.

Corollary — **capabilities decompose, they do not relocate as a blob**: route each part by its shape (§3) — non-component logic → `services/` (or `@shared/` if cross-process), React providers and UI → `components/`, hooks → `hooks/`, types → `@shared/`. Nothing is added to the top level.

This is why a command/keybinding/menu system is not a feature and not a top-level directory: it decomposes **by shape** across existing homes, one cell per type:

| Part | Nature | Home |
|---|---|---|
| keybinding definitions + resolution, context-expr eval, menu resolution, `ContextKeyService`/`MenuRegistry` blueprints | cross-process pure logic + class blueprints | `@shared/utils/command` |
| command / keybinding / menu types | cross-process types | `@shared/types/command` |
| shortcut-label, `KeyboardEvent` → binding, display-state helpers | renderer-only pure logic | `utils/command` |
| context objects + their accessor hooks, `useResolvedCommand`/`useResolvedCommandMenu`, `useCommandShortcuts` | React contexts + hooks | `hooks/command` |
| `CommandProvider`/`CommandContextKeyProvider`, `CommandMenus`, `CommandControls` | React components | `components/command` |

A `Provider` returns JSX so it is a **component**; the contexts it fills and the hooks that read them are non-JSX and sink one tier below to `hooks/command`; pure logic sinks to `utils/command` (renderer-only) or `@shared/utils/command` (cross-process), and types to `@shared/types/command`. Nothing goes to `services/`, and `@shared` keeps only what **both** processes use — a resolver consumed only by the renderer (e.g. `getCommandShortcutLabel`) belongs in `utils/command`.
After decomposition every edge is downward (`component → component`/`hook`, `hook → hook`); the former `component → feature` and `hook → feature` inversions are gone (the importing `component`/`hook` are the **shared** buckets — a feature-internal `component` importing its own siblings is not such an inversion), and nothing is a "feature".

## 7. Anti-Patterns

- A shared bucket (`components/`/`hooks/`/`utils/`) importing `features` or `pages` (a reverse / upward edge).
- `pages/X` importing `pages/Y` (cross-page coupling).
- Domain-specific artifacts left in a top-level type bucket (backup managers, model/provider widgets, etc.).
- Treating a cross-cutting capability as a peer feature.
- Opening a new top-level directory for a single capability.
- A feature using `export *`, or an external consumer deep-importing a feature's internals.
- Importing a shared bucket root (`@renderer/utils`, `@renderer/types`) instead of the specific file/topic, or giving `types/`/`utils/` a re-export root `index.ts` (§5).
- A hand-rolled `components/layout/` bucket — "layout" is not a layer here: route layouts live in `routes/` (TanStack layout routes), layout primitives (`Box`/`Stack`/`Grid`) in `packages/ui`, app shell in `windows/`.

## 8. Target vs Current State

This document describes the **target** architecture. The renderer has not yet been migrated to it; the gaps below are known and tracked. Migration is deferred and intentionally out of scope here.

**Already aligned:**

- `packages/ui` has no back-imports from `@renderer/*` (the primitive layer is clean).
- The command capability is decomposed by shape with no `component`/`hook → feature` edges: the renderer cells (`utils/command`, `hooks/command`, `components/command`) are in place, and its cross-process cell is split into `@shared/utils/command` (logic + `ContextKeyService`/`MenuRegistry` blueprints) and `@shared/types/command` (types) per [Shared Layer Architecture](./shared-layer-architecture.md).
- The `context/` by-kind bucket has been dissolved by shape: app-wide providers (`ThemeProvider`, `CodeStyleProvider`) sit in `components/` with their context objects and accessor hooks in `hooks/` (`useTheme`, `useCodeStyle`); the tab subsystem's behavior layer is decomposed into `hooks/tab/` (context + hooks, mirroring the `command` pattern), with the `TabsProvider`/`TabIdProvider` components co-located in the shell UI (`components/layout/`) pending the App-shell migration.
- The renderer-side AI-streaming runtime (`IpcChatTransport`, `TopicStreamSubscription`, `streamDispatchCoordinator`) lives in the shared `services/aiTransport/` bucket — a cross-surface `ai` runtime consumed by chat, quick-assistant, and selection, pairing with the cross-process `@shared/ai/transport` contract; it is not its own top-level directory (Naming Conventions §4.8).

**Pending (current deviations from the target):**

This table lists definite mis-classifications and structural violations only.
A small domain's pieces (components, pages, hooks, services, utils) may legitimately sit in the shared type-buckets until that domain earns a `features/<domain>/`; that promotion is a separate per-case judgment (§4.1) and is not prescribed here.

| Area | Current state | Target |
|---|---|---|
| App shell | shell chrome in `components/layout/` is partly window-specific, partly cross-window | decompose by ownership: main shell (`AppShell`, `AppShellTabBar`, tab drag) → `windows/main/`; sub-window chrome (`SubWindowControls`, `SubWindowTitle`) → `windows/subWindow/`; cross-window building blocks (`TabRouter`, `TabIcon`, `titleBar`, tab icons) → shared `components/` (e.g. `components/shell/`). No new `windows/shell/` bucket |
| `components/app/Navbar` | a shared page-header component (`Navbar`/`NavbarCenter`/…) consumed by ~10 pages, mislabeled under an `app/` (shell) subdirectory | it is shared UI, **not** shell: keep in `components/` (regroup as `components/Navbar/`) |
| `components/app/Sidebar` | no importers found — likely dead code | verify; remove if unused, otherwise place by its actual consumer (window shell → `windows/`, reusable UI → `components/`) |
| Cross-page imports | `pages/<domain>/` import each other (`pages → pages` coupling) | a page must not import another page; route shared needs through the shared layer |
| `queue/` | a single-file capability (`NotificationQueue`) occupies its own top-level directory | belongs with its owning logic; not its own top-level directory (Naming Conventions §4.8) |
| `config/` | by-kind bucket mixing app-global constants (`constant.ts`, `env.ts`) with domain static data (`providers.ts` ~1.4k lines, `models/`, `agent.ts`, …) | **dissolved** during the v2 refactor: platform predicates → `utils/platform`; domain constants → owning domains; `env.ts` assets / `APP_NAME` inlined at consumers; directory removed |
| `utils/` root barrel | `src/renderer/utils/index.ts` (11 `export *`) imported bucket-root by ~127 `@renderer/utils` consumers; `utils/messageUtils/` is a multi-file topic subdir with **no `index.ts`** | drop the root barrel (import `@renderer/utils/<topic>`); give `messageUtils/` one curated `index.ts` (named exports, no `export *`) |
| `databases/` | v1 Dexie | removed during the v2 refactor (do not model) |
| Domain promotion | large multi-file domains (`chat` ≈ `pages/home` + `components/chat` + `components/composer`; `knowledge` ≈ `pages/knowledge` + …) are scattered across the shared type-buckets, and **no `features/` directory exists yet** | promote the largest domains into `features/<domain>/` per the §4.1 trigger (`chat` and `knowledge` first) |
| Boundary enforcement | none | `import/no-restricted-paths` zones (§5) |

Known reverse/coupling edges at time of writing: ~35 `pages → pages` cross-imports (the command-driven `component`/`hook → feature` edges have been resolved). These are the violations the §5 lint rules are designed to catch and prevent.

## 9. Industry References

| Claim | Source |
|---|---|
| Unidirectional dependencies; no cross-feature imports | bulletproof-react — `docs/project-structure.md` |
| Same-layer slices cannot import each other, so a widely-depended-on module must sit on a strictly lower layer; `shared` is the lowest layer | Feature-Sliced Design — `reference/layers`, `reference/public-api` |
| Tag cross-cutting capabilities as a lower type (`type:ui`/`util`) and enforce direction with lint | Nx — `enforce-module-boundaries` |
| Command/keybinding services live in the `platform/` foundation layer; feature contributions are isolated | VS Code — Source Code Organization |
| A domain-agnostic, non-differentiating capability is a generic subdomain, not a peer of core domains | DDD strategic design |
| App-wide singletons live in Core; features do not import each other | Angular — Core / Shared / Feature modules |

## Related

- [Naming Conventions §4.10](./naming-conventions.md) — feature-module placement and naming.
- [Architecture Overview](./architecture-overview.md) — monorepo structure and cross-process layering.
