A glossy Matrix collaboration client for the web.
# AGENTS.md
Instructions for AI coding agents working in this repository. Humans should read
[CONTRIBUTING.md](./CONTRIBUTING.md) and [code_style.md](./code_style.md) instead โ this file summarises those and
adds the things that are easy to get wrong here.
## Repository layout
A pnpm + nx monorepo (`element-web-monorepo`). Workspaces are `apps/*`, `packages/*`, `modules` and `modules/*`.
| Path | Contents |
| ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `apps/web` | The Element Web app. Also hosts view models (`src/viewmodels/`) and the Playwright e2e suite (`playwright/`). |
| `apps/desktop` | Element Desktop. |
| `packages/shared-components` | Published UI component library, `@element-hq/web-shared-components`. New views go here. |
| `packages/module-api`, `packages/shared-utils`, `packages/shared-types`, `packages/playwright-common` | Published support packages. |
| `modules/*` | Optional runtime modules (banner, widget toggles, โฆ). |
| `docs/` | VitePress docs site. |
There is **no** top-level `playwright/` directory; `docs/playwright.md` uses paths relative to `apps/web`.
## Commands
Run from the repo root unless stated otherwise.
| Task | Command |
| ------------------------------------------------------ | ------------------------------------------------- |
| Full lint (types, format, js, styles, workflows, knip) | `pnpm lint` |
| Format | `pnpm lint:fmt:fix` |
| Lint JS/TS | `pnpm lint:js:fix` |
| Typecheck | `pnpm -r --workspace-concurrency=1 lint:types` |
| All unit tests | `pnpm test:unit` |
| Regenerate i18n strings | `pnpm i18n` |
| Start the app | `cd apps/web && pnpm start` |
| Storybook | `cd packages/shared-components && pnpm storybook` |
Formatting is **oxfmt** and linting is **oxlint**. This repo does not use prettier or eslint โ running prettier here
reformats files against the project style and reports failures that do not exist.
Run `pnpm i18n` after adding or changing any translated string.
## Running unit tests
Both jest and vitest are in use; a migration to vitest is in progress. **The file path decides the runner.**
| Test file | Runner | Command |
| ---------------------------------------------- | ---------------- | ----------------------------------------------------------- |
| `apps/web/test/**/*-test.[tj]s?(x)` | jest | `cd apps/web && pnpm jest <path>` |
| `apps/web/src/**/*.test.{ts,tsx}` | vitest | `pnpm vitest run <path>` |
| `apps/web/src/**/*.test.browser.{ts,tsx}` | vitest (browser) | `pnpm vitest run --project web-browser <path>` |
| `packages/shared-components/src/**/*.test.tsx` | vitest | `cd packages/shared-components && pnpm test:unit -- <path>` |
Notes:
- Write new tests as co-located vitest `*.test.ts(x)` next to the source file. The `apps/web/test/` jest tree is
legacy; extend it only when the file you are changing already lives there.
- Vitest runs with `globals: false`, so import `describe`, `it`, `expect` and friends explicitly.
- `packages/shared-components` is excluded from the root vitest config and must be run from its own directory.
- "No test files found" means you picked the wrong runner or path. It is not a pass.
- Prefer extending an existing test over adding a new file when covering a close variant of existing behaviour.
## Running e2e tests
Specs live in `apps/web/playwright/e2e`. See [docs/playwright.md](./docs/playwright.md) for the full guide.
```sh
cd apps/web && pnpm test:playwright -- playwright/e2e/<spec>.spec.ts --project=Chrome
```
- Requires a container runtime (Docker/Podman/Colima) for the homeserver testcontainers.
- On macOS, add `--ignore-snapshots`. Screenshot baselines are only committed for Linux, so comparisons fail locally.
- Never run `pnpm test:playwright:screenshots` locally to "fix" a screenshot diff โ it writes host-rendered baselines.
Screenshots are updated in the Docker environment, which CI matches.
- The config uses `reuseExistingServer: true`. Check `lsof -ti:8080` first; a stale dev server silently serves the
tests. Port-bind errors are an infrastructure problem, not a test failure.
- Locate elements by role, label and accessible name, not CSS classes.
- Tag any test using `toMatchScreenshot` with `@screenshot`.
### Reviewing updated screenshots
Updated baselines are part of the diff and must be reviewed, not accepted blindly. Before committing any changed
`.png` under `apps/web/playwright/snapshots/` or `packages/shared-components/__vis__/`:
- Look at the image. Confirm every visible difference is one your change was meant to produce.
- Treat anything else as a regression until proven otherwise: shifted or clipped layout, changed spacing or font,
missing or duplicated elements, a wrong theme, an untranslated or placeholder string, a loading or error state
captured instead of the real content.
- Unrelated files changing baselines is a signal in itself โ if a screenshot moved for a component you did not
touch, find out why rather than committing it.
- Never regenerate baselines to make a failing test pass. A diff you cannot explain is a bug in the change.
## Writing UI code: MVVM
New UI follows MVVM. Full details in [docs/MVVM.md](./docs/MVVM.md); the shape for a feature `Foo`:
**View** โ `packages/shared-components/src/<domain>/FooView/` containing `FooView.tsx`, `FooView.module.css`,
`FooView.test.tsx`, `FooView.stories.tsx` and `index.ts`. The view declares the contract:
```tsx
export interface FooViewSnapshot { title: string }
interface FooViewActions { setTitle: (title: string) => void }
export type FooViewModel = ViewModel<FooViewSnapshot, FooViewActions>;
export function FooView({ vm }: { vm: FooViewModel }): JSX.Element {
const { title } = useViewModel(vm);
...
}
```
Views are dumb: they read the snapshot and call actions, nothing else. Develop them in Storybook.
**ViewModel** โ `apps/web/src/viewmodels/<domain>/FooViewModel.ts`, a class extending `BaseViewModel` that implements
the interface from the view. `apps/web/src/components/viewmodels/` is the deprecated v1 location; do not add there.
Rules that are easy to miss:
- Define actions as arrow-function class properties (`public doThing = (): void => {}`) so `this` survives being
passed as a callback.
- Use `this.snapshot.merge({ field })` for partial updates. `merge` already skips emitting when nothing changed, so
do not add equality guards around it, and do not recompute the whole snapshot for one field.
- Track listeners and sub-view-models with `this.disposables.trackListener(...)` / `this.disposables.track(...)`.
- A non-MVVM function component owning a view model should create it with `useCreateAutoDisposedViewModel`; a class
component creates it in `componentDidMount` and disposes it in `componentWillUnmount`.
Reference implementation: `packages/shared-components/src/room-list/RoomListSearchView/` with
`apps/web/src/viewmodels/room-list/RoomListSearchViewModel.ts`.
## Code style
Read [code_style.md](./code_style.md). The points most often missed:
- **Every new file needs a copyright header**, enforced by oxlint (`element-call/copyright-header`). Lint fails
without it. Substitute the actual current year โ check it rather than copying a year from another file โ and leave
headers on existing files alone:
```
/*
Copyright <current year> <copyright holder>
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
```
The holder is `Element Creations Ltd.` **only for contributions made as part of Element**. An external
contributor puts their own name or their company's there instead. Note that `oxlint --fix` inserts the Element
line unconditionally, so external contributors should write the header by hand and check what the autofix added.
- TypeScript only, named exports only โ avoid `export default`.
- 4-space indent, 120-column limit, double quotes, semicolons.
- Declare member visibility (`public`/`private`/`protected`) on class members.
- Avoid `any`; if unavoidable, comment why.
- Roughly one interface, class or enum per file, named after the file.
- Never mix cosmetic and functional changes.
- **Styles:** `apps/web` uses PostCSS (`res/css/**/_Component.pcss`, `mx_`-prefixed classes).
`packages/shared-components` uses CSS modules (`Component.module.css`, semantic camelCase class names, no `mx_`
prefix) imported as `styles`. Use Compound design tokens (`var(--cpd-color-โฆ)`, `var(--cpd-space-โฆ)`) for all values.
- Prefer Compound typography components over raw text elements, and `Flex`/`Box` from shared-components over raw
flexbox markup.
## Comments
Keep comments short and relevant.
- Add TSDoc to exported types, functions, classes and components. Document a component's props.
- Inside a function, explain _why_, not line by line. Add a short overview before a non-obvious block.
- Use plain language matching the identifiers around the comment โ no metaphors or CS jargon.
- When changing code, update the comments around it so they stay accurate.
- Any intentionally suppressed lint or type rule needs a nearby comment explaining why.
## Tests are required
- Every change needs unit tests, including features behind labs flags.
- New user-facing features need a "happy path" Playwright e2e test before leaving labs.
- Aim for โฅ80% coverage on the diff; CI checks this. There is no `coverage` script at the root โ generate the
reports in the workspaces you touched, then compare from the root:
```sh
pnpm test:unit --coverage # vitest, writes coverage/lcov.info
cd apps/web && pnpm coverage # jest only, writes apps/web/coverage/lcov.info
cd packages/shared-components && pnpm coverage
pnpm coverage:diff # from the root, needs diff_cover installed
```
## Commits
- Split the work into logically separate commits: one concern per commit. Keep refactors, formatting and behaviour
changes in separate commits.
- Short imperative subject line, no conventional-commit prefix (`feat:`, `fix:`, โฆ).
- Add a body only when it says something the diff does not; keep it to a line or two.
## Pull requests
- Don't open PR. This should be done by the human, not the agent.