Frontend notes

The Arc CMS website and admin area are one Angular 21 app built with AnalogJS. These are the conventions to follow when you change it, and the few places where it does not behave the way an Angular developer might expect.

The folders are described in A tour of the folders, and how the parts fit together in Architecture. An app built on Arc CMS adds its pages in the custom space instead: see Add pages and routes.

Components

State: NgRx Signals stores

Page state lives in NgRx Signals stores (signalStore from @ngrx/signals), such as the auth store in src/app/pages/(auth)/auth.store.ts. A store for a Firestore collection is usually made with createGenericStore() from src/shared/services/generic-store.service.ts, which gives it loading, paging, sorting and the create, update and delete calls. update() does not change currentItem, so a page that fills a form from it is not refilled on every save; a page that keeps editing after a save calls refreshCurrentItem(id, written) to bring the stored copy up to date, as the content editor does. Templates read store state as signals.

Translations

The admin's and the members' text is translated with Transloco. English is the source language:

  1. Add the key and its English text to src/assets/i18n/en.json, and the translation to each other language file in that folder, such as src/assets/i18n/hi.json.

  2. Use it: {{ 'admin.nav.broadcasts' | transloco }} in a template, or injectT() from src/app/core/i18n/inject-t.ts in TypeScript.

  3. Regenerate the key type:

    npm run i18n:keys

    It writes src/app/core/i18n/translation-keys.ts, so a mistyped key in TypeScript is a compile error.

A spec (src/test/i18n-parity.spec.ts) fails when a translation misses a key, has a key English does not, or the generated keys are out of date. A missing translation falls back to English in the app, so without that spec the gap would be invisible. Keys written in templates are plain strings to the compiler; the spec is their only check.

Text members see

Apps add languages for their members that core has never seen (see Member languages), so a member-facing string is never hard-coded, not even a fallback such as {{ name || 'Member' }}:

src/app/core/i18n/member-screens-translated.spec.ts fails on English in a member screen's template or messages, and member-screens-pseudo.spec.ts renders screens in a pseudo language and fails on any plain English left. Values a person or an admin wrote (a name, the cookie banner's message) are data, not text to translate.

The languages a site publishes its content in are a separate setting: see Languages.

Routing

AnalogJS turns files into routes. A file ending in .page.ts with a default export, under src/app/pages/ (or src/custom/pages/ in an app), is a page: search.page.ts is /search, [contentTypeSlug] in a name is a parameter, and a folder in parentheses such as (settings) groups files without adding to the URL.

The explicit routes in src/app/app.routes.ts come before the file-based ones. Admin pages need one: wrap the page in the admin layout with the role guard, like the existing entries:

{
  path: 'admin/email-logs',
  loadComponent: () => import('./pages/admin.page').then((m) => m.default),
  canActivate: [roleGuard],
  data: { allowedRoles: ['admin'] },
  children: [
    {
      path: '',
      loadComponent: () =>
        import('./pages/admin/(email-logs)/email-logs.page').then((m) => m.default),
    },
  ],
},

An admin page that relies on its file alone may render on the server but not reach the browser's route table. The URL then falls through to the public content route and shows "Content Not Found" in the public layout, with HTTP 200, so a status check misses it. Give every admin page an explicit route.

A folder of admin pages with no page of its own, such as src/app/pages/admin/contents/, still gets an address, and it shows an empty admin page. Redirect it to the folder's main page with an explicit route, as admin (to the dashboard) and admin/contents (to Content types) do.

Every page URL also belongs to exactly one feature or to core, so a feature that is off can hide it. Add a new URL to the right list in src/app/core/features/feature-routes.ts; a test fails on one in no list (see Keep core generic).

Change detection without zone.js

The app is zoneless: src/main.ts and src/app/app.config.ts load no zone.js and provide no zone change detection, so Angular 21 runs without it. A view updates when a signal it reads changes, an input changes, an event it bound fires, or something marks it for check. A value changed in place, or a callback's answer that changed, does not by itself update an OnPush view.

Do not make a component OnPush when its output depends on callbacks that read page state or on objects changed in place. The row actions in tables (src/shared/components/row-actions/row-actions.component.ts) use default change detection for this reason: as OnPush they kept showing a Block button for a user who was already blocked. OnPush is fine when everything the view shows comes from signals or from inputs that are replaced, not mutated.

The tests load zone.js (src/test/setup.ts) for TestBed. A spec that changes a value in place calls markForCheck() before detectChanges(), as the app would need to.

The page header

Every admin and member page starts with <arc-page-header> (src/shared/components/page-header/page-header.component.ts): the title and subtitle on the left, the page's own buttons, the admin search box, the language picker and the notification bell on the right. It scrolls with the page, and looks the same in the admin and member layouts.

<arc-page-header title="Contacts" subtitle="Everyone you can email.">
  <button mat-flat-button color="primary">Add</button>
</arc-page-header>
Input or outputDefaultDoes
title, subtitleThe heading and the line under it
showBelltrueThe notification bell
showLanguagePickertrueThe admin language picker. It hides itself outside the admin area when the app declares member languages
showSearchtrueThe admin search box, when the search feature is on
showBack, (back)falseA back arrow before the title, and its click

A spec that renders a page with the header needs headerTestProviders(): see Testing.

Styles