Compare commits

...

7 Commits

Author SHA1 Message Date
Julien Gautier bea5e1954f chore: generate portal-shell and portal-bff apps per ADR-0004 / ADR-0005
Add the @nx/angular, @nx/nest, @nx/vite, @nx/eslint plugins, then
generate the two apps. Adjust the empty-template tsconfig.base.json
to be Angular-compatible (drop project references and customConditions
that the empty-template defaults to but Angular doesn't support; keep
the strict-TS extensions from ADR-0004).

apps/portal-shell (Angular 21):
- standalone APIs, routing, SCSS, esbuild
- vitest-angular as unitTestRunner, playwright for e2e
- strict mode
- tags scope:portal-shell, type:app
- app.config.ts wired with provideZonelessChangeDetection() per
  ADR-0004 (Angular 21 + Nx 22 generates without zone.js by default)

apps/portal-bff (NestJS 11):
- Express adapter (default per ADR-0005)
- Jest as unitTestRunner
- tags scope:portal-bff, type:app
- main.ts wired with a global ValidationPipe configured
  whitelist + forbidNonWhitelisted + transform per ADR-0005
- Phase-2 security additions (helmet, CORS, sessions, CSRF, rate
  limit, auth guards, error filter) deferred to their respective
  ADRs - placeholder comment in main.ts

Workspace dependencies: class-validator + class-transformer added
(required by NestJS ValidationPipe at runtime). Nx-generated
.gitignore additions (.angular, __screenshots__) merged into ours.
.vscode/extensions.json and launch.json added by Nx are kept (do not
override our existing settings.json).
2026-04-30 16:12:42 +02:00
Julien Gautier 64ea72b321 chore: bootstrap Nx workspace per ADR-0002
Initialize the Nx 22 monorepo via copy-from-scratch (the
create-nx-workspace 'apps' preset is now mapped to nrwl/empty-template
in Nx 22). Workspace files extracted from a scratch generation and
adapted to the project:

- package.json: name 'apf-portal' (renamed from @org/source default)
- tsconfig.base.json: customConditions aligned to apf-portal; strict TS
  extended per ADR-0004 with noUncheckedIndexedAccess,
  exactOptionalPropertyTypes, noPropertyAccessFromIndexSignature
- nx.json: nxCloudId stripped (on-prem, no Nx Cloud SaaS)
- .prettierrc: project convention (singleQuote, semi, printWidth 100)
- pnpm-workspace.yaml: apps/* and libs/** for the integrated layout
- pnpm-lock.yaml committed

Plugins for Angular, NestJS, Vite, ESLint will be added in the next
phase when apps are generated. The auto-generated CLAUDE.md / AGENTS.md
/ .claude/ / .github/ from the bootstrap were intentionally not
carried over (we keep our own CLAUDE.md and use Gitea, not GitHub).
2026-04-30 16:07:11 +02:00
Julien Gautier a88fc1e8a1 docs: align CLAUDE.md narrative with phase-2 and phase-3a ADRs
The Architecture section title and the Repository status paragraph
still referred to 'phase-1 ADRs' as if that were the current state.
Update both to reflect that ADRs 0001-0017 (phase 1 + 2 + 3a) are
recorded, that the security baseline ADR is paused awaiting RSSI
input, and that docs/setup/03 has already been rewritten to align
with the ADRs.
2026-04-30 15:46:42 +02:00
Julien Gautier ea852b3239 docs: add ADR-0017 for performance budgets (Core Web Vitals + Lighthouse CI + Angular budgets + BFF SLOs)
Pin the perf framework. Front-end metrics tracked at Google Core Web
Vitals 'Good' thresholds (LCP <= 2.5s, INP <= 200ms, CLS <= 0.1, plus
TBT <= 200ms, TTFB <= 800ms) and Lighthouse Performance >= 90 on
critical routes. Lighthouse CI (@lhci/cli) enforces them in CI with
median-of-3 runs to mitigate runner variance, on a curated
critical-routes list (login, home, accessibility statement, flagship
features as they land). Wires into the perf gate slot from ADR-0015.

Bundle budgets enforced at nx build via Angular's project.json budgets
array, type 'error': initial <= 300 KB gzip, lazy chunks <= 100 KB
gzip, per-component CSS <= 6 KB. source-map-explorer wired as an Nx
analyze target for diagnosis on budget breaches.

Back-end SLOs documented per endpoint family (p95/p99) and observed
via the OpenTelemetry spans already shipped by ADR-0012 - advisory in
CI (load profile unrepresentative), alerting in production. Quarterly
review tightens budgets when achievable.

Scheduled weekly Lighthouse run on the prod env via the existing
security-scheduled.yml workflow extends coverage beyond PR-time.

Explicit a11y/perf trade-off rule: when they conflict, a11y wins (per
APF's mission, ADR-0016). The perf budget is then re-evaluated at the
next quarterly review.

No browser-side RUM SDK in v1 - the OTel browser tracing from ADR-0012
plus scheduled prod Lighthouse runs cover the gap. RUM revisited in
v2 if a real incident escapes the existing signal.

decisions/README.md index updated. CLAUDE.md gains an explicit
'Performance budgets' line pointing to ADR-0017.
2026-04-30 14:22:36 +02:00
Julien Gautier 98a8c78a31 docs: add ADR-0016 for accessibility baseline (WCAG 2.2 AA + targeted AAA, spartan-ng + CDK + Tailwind, APF panel)
Pin the a11y baseline given the host organisation context (APF France
Handicap, where accessibility is the core mission, not a compliance
checkbox):

- Conformance: WCAG 2.2 AA universal + AAA on criteria with high
  impact for the user base (1.4.6 Enhanced Contrast 7:1, 2.2.3 No
  Timing, 2.3.3 Animation from Interactions, 3.1.5 Reading Level,
  1.4.8 Visual Presentation, 2.4.9 Link Purpose, 3.3.5 Help). RGAA 4.1
  alignment for French audit context.

- User-preferences panel as a first-class feature: contrast (3 tiers),
  text size (up to 200%), motion, text spacing, cognitive
  simplification, reading focus. Persisted in session (ADR-0010).

- UI stack: Angular CDK + spartan-ng + TailwindCSS (not Angular
  Material; not React libs). Components copy-pasted into
  libs/shared/ui under our control. Design tokens with
  contrast-verified colour pairs in libs/shared/tokens.

- Tooling and CI gates: @angular-eslint/template/* a11y rules
  (blocking), @axe-core/playwright e2e blocking on critical/serious
  violations, design-token contrast verifier (blocking), touch-target
  size check (blocking on 44x44 min, default 48x48). Wires into the
  a11y gate slot from ADR-0015.

- Manual testing: keyboard and screen reader required on every UI PR
  via PR-template checklist; APF user-panel session before every major
  release covering visual / motor / cognitive / hearing categories.

- Documentation: public accessibility statement at /accessibility (EN)
  and /accessibilite (FR) - EAA legal requirement, generated from
  source-of-truth in the repo. Internal patterns library in
  docs/accessibility/.

decisions/README.md index updated. CLAUDE.md gains an explicit
'Accessibility' line pointing to ADR-0016 and the CI/CD line is
adjusted to mark the a11y gate as locked-in (no longer 'future').
2026-04-30 13:36:00 +02:00
Julien Gautier 880c7ded6b chore: rename project from adastra_portal to apf_portal
The host organisation - APF France Handicap - was confirmed on
2026-04-30. Update all in-repo references to use apf_portal
(snake_case in prose) and apf-portal (kebab-case workspace name and
repo URL). Touched: CLAUDE.md, ADRs 0001/0002/0003/0015, and the Nx
bootstrap setup guide.

The historical name is preserved as a single sentence in ADR-0003 as
a self-validating example of the function-prefixed naming convention
designed exactly for this scenario - the apps (portal-shell,
portal-bff) and the lib conventions (feature-<name>, shared-<scope>)
were unaffected by the rename, which was the explicit point of
ADR-0003.

Memory state aligned out-of-band: project_adastra.md retired,
project_apf_portal.md created with the expanded APF context (host
org, health + financial data scope, ASVS L3 pending RSSI input, UI
stack decision spartan-ng + CDK + Tailwind, expanded phase-3 status).

Pending follow-ups (user-side, not in this commit):
- rename the Gitea repo julien/adastra_portal -> julien/apf_portal
- git remote set-url origin gitea@git.unespace.com:julien/apf_portal.git
- optionally rename the local working directory ~/Works/adastra_portal/
  -> ~/Works/apf_portal/
2026-04-30 13:31:38 +02:00
Julien Gautier 49712d0bbf docs: add ADR-0015 for the CI/CD pipeline (Gitea Actions, trunk-based + squash-merge, thin YAML)
Pin the CI/CD shape with an explicit two-level structure, anticipating
the GitLab migration on the 6-18-month horizon:

Level 1 - vendor-neutral, survives the migration:
- Trunk-based with short-lived feature branches; squash-merge only.
- Branch protection on main: no direct push, no force push, linear
  history, required green CI, required reviewers = 0 in v1 (raised to
  >=1 when a second contributor joins), branch deletion after merge.
- Required CI gates (all blocking): format, lint, type-check, test,
  build (nx affected), audit (pnpm + Trivy), secret-scan (gitleaks),
  commit-lint (Conventional Commits on the PR commit range). Future
  a11y and perf gates land with their respective ADRs.
- Conventional Commits validated locally (hook from ADR-0007) and in
  CI as defense in depth.
- Signed commits recommended but not required in v1; revisited at the
  GitLab migration.
- Thin YAML: all orchestration logic lives in package.json scripts
  (ci:check, ci:scan, ci:commits) and Nx targets. Workflow files only
  do checkout, setup, cache, and call one script per job. The migration
  rewrites the YAML wrappers, never the gates.
- Secrets convention SCOPE_PURPOSE; gitleaks enforces no-secrets-in-code.
- Container images / deploy explicitly out of scope (deferred to the
  on-prem infrastructure ADR).

Level 2 - Gitea Actions specific, will be superseded by a GitLab
migration ADR:
- Engine: Gitea Actions (built-in, GitHub Actions-compatible syntax,
  partial portability hedge if the org pivots to GitHub).
- Runners: >=3 self-hosted act_runner instances on-prem, Debian image
  pinned by SHA, weekly rebuild via security-scheduled.yml.
- Three workflow files: ci.yml (PR + push to main), release.yml
  (stub for tag, populated by the deploy ADR), security-scheduled.yml
  (weekly Trivy + gitleaks full-tree scan + Renovate trigger).

Migration weight estimated at 3-5 days dev/ops when GitLab arrives:
the YAML is rewritten, the gates and scripts are unchanged. A future
ADR will explicitly supersede only the level-2 sections.

decisions/README.md index updated. CLAUDE.md gains an explicit 'CI/CD'
line pointing to ADR-0015 and noting the future GitLab supersession.
2026-04-30 11:21:30 +02:00
65 changed files with 19985 additions and 22 deletions
+4
View File
@@ -43,3 +43,7 @@ Thumbs.db
# Personal scratchpad — see CLAUDE.md # Personal scratchpad — see CLAUDE.md
notes/ notes/
.angular
__screenshots__/
+7
View File
@@ -0,0 +1,7 @@
# Add files here to ignore them from prettier formatting
/dist
/coverage
/.nx/cache
/.nx/workspace-data
.nx/self-healing
.angular
+5
View File
@@ -0,0 +1,5 @@
{
"singleQuote": true,
"semi": true,
"printWidth": 100
}
+7
View File
@@ -0,0 +1,7 @@
{
"recommendations": [
"ms-playwright.playwright",
"esbenp.prettier-vscode",
"firsttris.vscode-jest-runner"
]
}
+20
View File
@@ -0,0 +1,20 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Debug portal-bff with Nx",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["exec", "nx", "serve", "portal-bff"],
"env": {
"NODE_OPTIONS": "--inspect=9229"
},
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"skipFiles": ["<node_internals>/**"],
"sourceMaps": true,
"outFiles": ["${workspaceFolder}/apps/portal-bff/dist/**/*.(m|c|)js", "!**/node_modules/**"]
}
]
}
+9 -6
View File
@@ -8,7 +8,7 @@ These constraints were set by the project lead at kickoff. They apply to every c
- **Scale & quality bar.** Treat this as a large-scale portal for a sizable organization, not a prototype. No bricolage, no exotic stacks. **Default to stable, recognized, battle-tested choices.** Cutting-edge / "à la pointe" alternatives must always be evaluated alongside the stable option, but are only adopted when the trade-off is captured in an ADR (drivers, risk, exit strategy). Pre-1.0 dependencies and one-maintainer projects are rejected unless an ADR justifies the exception. - **Scale & quality bar.** Treat this as a large-scale portal for a sizable organization, not a prototype. No bricolage, no exotic stacks. **Default to stable, recognized, battle-tested choices.** Cutting-edge / "à la pointe" alternatives must always be evaluated alongside the stable option, but are only adopted when the trade-off is captured in an ADR (drivers, risk, exit strategy). Pre-1.0 dependencies and one-maintainer projects are rejected unless an ADR justifies the exception.
- **Security, performance, accessibility.** All three are first-class concerns from day one — never bolted on. Architecture, dependency, and feature decisions must explicitly consider their impact on these axes and document the trade-offs. - **Security, performance, accessibility.** All three are first-class concerns from day one — never bolted on. Architecture, dependency, and feature decisions must explicitly consider their impact on these axes and document the trade-offs.
- **Project name.** Currently `adastra_portal`, provisional. Do not hardcode it outside repo/workspace-level metadata so a rename stays a one-line change. - **Project name.** Currently `apf_portal`, provisional. Do not hardcode it outside repo/workspace-level metadata so a rename stays a one-line change.
- **Language.** All code, identifiers, comments, documentation, commit messages, and PR descriptions are written in **English**. (Conversation with the project lead happens in French — but artifacts shipped in the repo are English-only.) - **Language.** All code, identifiers, comments, documentation, commit messages, and PR descriptions are written in **English**. (Conversation with the project lead happens in French — but artifacts shipped in the repo are English-only.)
- **Commits / PRs.** Never add a `Co-Authored-By: Claude` trailer or a `🤖 Generated with Claude Code` footer to commits or PR bodies. - **Commits / PRs.** Never add a `Co-Authored-By: Claude` trailer or a `🤖 Generated with Claude Code` footer to commits or PR bodies.
- **Be a peer, not a typist.** Challenge requests when a better approach exists; surface trade-offs frankly. Don't silently execute a suboptimal directive — propose, then execute the agreed plan. - **Be a peer, not a typist.** Challenge requests when a better approach exists; surface trade-offs frankly. Don't silently execute a suboptimal directive — propose, then execute the agreed plan.
@@ -27,12 +27,12 @@ These constraints were set by the project lead at kickoff. They apply to every c
- Categorization: via the `tags:` array in the MADR frontmatter (e.g. `[frontend, security]`). The canonical tag vocabulary lives in `decisions/README.md`; never invent ad-hoc tags inline. - Categorization: via the `tags:` array in the MADR frontmatter (e.g. `[frontend, security]`). The canonical tag vocabulary lives in `decisions/README.md`; never invent ad-hoc tags inline.
- **Proactivity.** Any non-trivial development decision (tool/library choice, framework pattern, security control, perf budget, a11y target, naming convention, deprecation, breaking change) warrants proposing an ADR before implementation. Don't wait to be asked. Update the index in the same change. - **Proactivity.** Any non-trivial development decision (tool/library choice, framework pattern, security control, perf budget, a11y target, naming convention, deprecation, breaking change) warrants proposing an ADR before implementation. Don't wait to be asked. Update the index in the same change.
## Architecture (decided in phase-1 ADRs) ## Architecture (recorded in ADRs)
The structural choices are recorded as ADRs and summarized below. Any change to these requires updating the corresponding ADR. The structural, security, observability, and quality choices are recorded as ADRs and summarized below. Any change to these requires updating the corresponding ADR.
- **Workspace:** Nx monorepo with the `apps` preset, managed by pnpm — see [ADR-0002](decisions/0002-adopt-nx-monorepo-apps-preset.md). - **Workspace:** Nx monorepo with the `apps` preset, managed by pnpm — see [ADR-0002](decisions/0002-adopt-nx-monorepo-apps-preset.md).
- **Naming:** workspace `adastra-portal`; apps `portal-shell` (frontend) and `portal-bff` (backend); libs `feature-<name>` and `shared-<scope>` — see [ADR-0003](decisions/0003-workspace-and-app-naming-convention.md). - **Naming:** workspace `apf-portal`; apps `portal-shell` (frontend) and `portal-bff` (backend); libs `feature-<name>` and `shared-<scope>` — see [ADR-0003](decisions/0003-workspace-and-app-naming-convention.md).
- **Frontend (`portal-shell`):** Angular at the latest LTS major — standalone APIs, zoneless change detection, Signals, **CSR only (no SSR)**, Vitest, SCSS — see [ADR-0004](decisions/0004-frontend-stack-angular-csr-zoneless-signals.md). - **Frontend (`portal-shell`):** Angular at the latest LTS major — standalone APIs, zoneless change detection, Signals, **CSR only (no SSR)**, Vitest, SCSS — see [ADR-0004](decisions/0004-frontend-stack-angular-csr-zoneless-signals.md).
- **Backend (`portal-bff`):** NestJS at the latest stable major, mounted on the Express adapter (Fastify adapter swappable later) — see [ADR-0005](decisions/0005-backend-stack-nestjs.md). - **Backend (`portal-bff`):** NestJS at the latest stable major, mounted on the Express adapter (Fastify adapter swappable later) — see [ADR-0005](decisions/0005-backend-stack-nestjs.md).
- **Persistence:** PostgreSQL (latest stable major) via Prisma — see [ADR-0006](decisions/0006-persistence-postgresql-prisma.md). - **Persistence:** PostgreSQL (latest stable major) via Prisma — see [ADR-0006](decisions/0006-persistence-postgresql-prisma.md).
@@ -43,14 +43,17 @@ The structural choices are recorded as ADRs and summarized below. Any change to
- **Observability:** Pino + `nestjs-pino` for structured JSON logs, OpenTelemetry SDK + auto-instrumentations for traces, W3C Trace Context propagation across SPA → BFF → DB → Redis, `nestjs-cls` for request-scoped context (`trace_id`, `session_id`, `user_id_hash`, `audience`), 100 % sampling at the app with tail sampling deferred to the OTel Collector, stdout + OTLP shipping — see [ADR-0012](decisions/0012-observability-pino-opentelemetry.md). - **Observability:** Pino + `nestjs-pino` for structured JSON logs, OpenTelemetry SDK + auto-instrumentations for traces, W3C Trace Context propagation across SPA → BFF → DB → Redis, `nestjs-cls` for request-scoped context (`trace_id`, `session_id`, `user_id_hash`, `audience`), 100 % sampling at the app with tail sampling deferred to the OTel Collector, stdout + OTLP shipping — see [ADR-0012](decisions/0012-observability-pino-opentelemetry.md).
- **Audit trail:** dedicated `audit.events` schema in the same Postgres instance, append-only by Postgres role grants (`audit_writer` INSERT, `audit_reader` SELECT, `audit_archiver` DELETE older than retention; no `UPDATE`/`TRUNCATE` to anyone); 365-day retention default; cross-referenced with app logs via `trace_id` and `actor_id_hash` (same salt); blocking writes (no audit ⇒ no action) — see [ADR-0013](decisions/0013-audit-trail-separated-postgres-append-only.md). - **Audit trail:** dedicated `audit.events` schema in the same Postgres instance, append-only by Postgres role grants (`audit_writer` INSERT, `audit_reader` SELECT, `audit_archiver` DELETE older than retention; no `UPDATE`/`TRUNCATE` to anyone); 365-day retention default; cross-referenced with app logs via `trace_id` and `actor_id_hash` (same salt); blocking writes (no audit ⇒ no action) — see [ADR-0013](decisions/0013-audit-trail-separated-postgres-append-only.md).
- **Downstream API access:** unified `DownstreamApiClient` (`@nestjs/axios` + `cockatiel`), per-service `DownstreamApiConfig`; default auth strategy is **OBO via MSAL Node** for Entra-protected APIs (downstream-scoped tokens cached in Redis with AES-256-GCM under a dedicated key); fallback strategy is service credential + signed `X-User-Assertion` JWT (BFF JWKS at `/.well-known/jwks.json`); per-call audience pre-check; no `axios`/`fetch` outside `src/downstream/` — see [ADR-0014](decisions/0014-downstream-api-access-obo-pattern.md). - **Downstream API access:** unified `DownstreamApiClient` (`@nestjs/axios` + `cockatiel`), per-service `DownstreamApiConfig`; default auth strategy is **OBO via MSAL Node** for Entra-protected APIs (downstream-scoped tokens cached in Redis with AES-256-GCM under a dedicated key); fallback strategy is service credential + signed `X-User-Assertion` JWT (BFF JWKS at `/.well-known/jwks.json`); per-call audience pre-check; no `axios`/`fetch` outside `src/downstream/` — see [ADR-0014](decisions/0014-downstream-api-access-obo-pattern.md).
- **CI/CD:** **Gitea Actions** (level-2 implementation; will be superseded by a GitLab migration ADR within 6-18 months). Trunk-based with squash-merge, branch protection on `main`, all CI gates blocking. Thin YAML — orchestration logic lives in `package.json` scripts (`ci:check`, `ci:scan`, `ci:commits`) and Nx targets, runnable locally. Gates: format / lint / type-check / test / build / audit / secret-scan / commit-lint, plus `a11y` (per ADR-0016) and future `perf`. Self-hosted `act_runner` on-prem. Conventional Commits validated locally (hook) and in CI (defense in depth). Required reviewer count = 0 in v1, raised to ≥1 once a second contributor joins. Signed commits recommended, revisited at GitLab migration — see [ADR-0015](decisions/0015-cicd-gitea-actions.md).
- **Accessibility:** **WCAG 2.2 AA baseline + targeted AAA** on criteria with high impact for APF's user base (1.4.6 Contrast Enhanced, 2.2.3 No Timing, 2.3.3 Animation, 3.1.5 Reading Level, 1.4.8 Visual Presentation, 2.4.9 Link Purpose, 3.3.5 Help). RGAA 4.1 alignment for French audit. UI stack: **Angular CDK + spartan-ng + Tailwind** (no Angular Material; no React libs). User-preferences panel (contrast / text size / motion / spacing / cognitive simplification / reading focus) persisted in session. Tooling: `@angular-eslint/template/*` lint, `@axe-core/playwright` e2e (blocking on critical/serious), token-contrast CI check, touch-target check (44×44 min). Manual testing cadence with APF's internal user panel before each major release. Public accessibility statement page at `/accessibility` and `/accessibilite` — see [ADR-0016](decisions/0016-accessibility-baseline-wcag-aa-targeted-aaa.md).
- **Performance budgets:** Core Web Vitals at Google "Good" thresholds (LCP ≤ 2.5 s, INP ≤ 200 ms, CLS ≤ 0.1, TBT ≤ 200 ms, TTFB ≤ 800 ms), Lighthouse Performance ≥ 90 on critical routes. **Lighthouse CI** (`@lhci/cli`) runs in CI with median-of-3 mitigation, blocking on threshold breach. Angular bundle `budgets` (`type: "error"`): initial ≤ 300 KB gzip, lazy chunks ≤ 100 KB gzip. BFF p95/p99 SLOs per endpoint family observed via OTel (advisory in CI, alerting in prod). Weekly scheduled Lighthouse run on prod env. **a11y wins over perf** when they conflict — see [ADR-0017](decisions/0017-performance-budgets-lighthouse-ci.md).
- **Local quality gates:** Husky + lint-staged + commitlint with Conventional Commits — see [ADR-0007](decisions/0007-pre-commit-hooks-and-conventional-commits.md). - **Local quality gates:** Husky + lint-staged + commitlint with Conventional Commits — see [ADR-0007](decisions/0007-pre-commit-hooks-and-conventional-commits.md).
- **Runtime:** Node.js latest LTS major. - **Runtime:** Node.js latest LTS major.
## Repository status ## Repository status
The Nx workspace is **not yet bootstrapped** — there is no `package.json`, no source code, no tests. Phase-1 ADRs (0001 → 0006) have fixed the structural choices; the next step is to scaffold the workspace per a revised version of [docs/setup/03-angular-nx-monorepo.md](docs/setup/03-angular-nx-monorepo.md) (the existing file still carries the original placeholder values and needs an update to match the ADRs before it is followed). The Nx workspace is **not yet bootstrapped** — there is no `package.json`, no source code, no tests. ADRs 0001 → 0017 cover the structural, security, observability, and quality choices for phase-1, phase-2, and phase-3a. The security baseline ADR is **paused** awaiting RSSI input on the OWASP ASVS reference level and adjacent frameworks (HDS, GDPR, possibly PCI DSS / NIS 2). The next step is to scaffold the workspace per [docs/setup/03-angular-nx-monorepo.md](docs/setup/03-angular-nx-monorepo.md), which has already been rewritten to align with the phase-1 ADRs.
If asked to "build", "test", or "run" anything, first verify whether the workspace exists; if not, the right response is to scaffold it (or update the setup doc), not to invent commands. If asked to "build", "test", or "run" anything, first verify whether the workspace exists; if not, the right response is to scaffold it, not to invent commands.
## Commands once the workspace exists ## Commands once the workspace exists
+18
View File
@@ -0,0 +1,18 @@
export default {
displayName: 'portal-bff-e2e',
preset: '../../jest.preset.js',
globalSetup: '<rootDir>/src/support/global-setup.ts',
globalTeardown: '<rootDir>/src/support/global-teardown.ts',
setupFiles: ['<rootDir>/src/support/test-setup.ts'],
testEnvironment: 'node',
transform: {
'^.+\\.[tj]s$': [
'ts-jest',
{
tsconfig: '<rootDir>/tsconfig.spec.json',
},
],
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../coverage/portal-bff-e2e',
};
+17
View File
@@ -0,0 +1,17 @@
{
"name": "portal-bff-e2e",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"implicitDependencies": ["portal-bff"],
"projectType": "application",
"targets": {
"e2e": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{e2eProjectRoot}"],
"options": {
"jestConfig": "apps/portal-bff-e2e/jest.config.cts",
"passWithNoTests": true
},
"dependsOn": ["portal-bff:build", "portal-bff:serve"]
}
}
}
@@ -0,0 +1,10 @@
import axios from 'axios';
describe('GET /api', () => {
it('should return a message', async () => {
const res = await axios.get(`/api`);
expect(res.status).toBe(200);
expect(res.data).toEqual({ message: 'Hello API' });
});
});
@@ -0,0 +1,16 @@
import { waitForPortOpen } from '@nx/node/utils';
/* eslint-disable */
var __TEARDOWN_MESSAGE__: string;
module.exports = async function () {
// Start services that that the app needs to run (e.g. database, docker-compose, etc.).
console.log('\nSetting up...\n');
const host = process.env.HOST ?? 'localhost';
const port = process.env.PORT ? Number(process.env.PORT) : 3000;
await waitForPortOpen(port, { host });
// Hint: Use `globalThis` to pass variables to global teardown.
globalThis.__TEARDOWN_MESSAGE__ = '\nTearing down...\n';
};
@@ -0,0 +1,10 @@
import { killPort } from '@nx/node/utils';
/* eslint-disable */
module.exports = async function () {
// Put clean up logic here (e.g. stopping services, docker-compose, etc.).
// Hint: `globalThis` is shared between setup and teardown.
const port = process.env.PORT ? Number(process.env.PORT) : 3000;
await killPort(port);
console.log(globalThis.__TEARDOWN_MESSAGE__);
};
@@ -0,0 +1,9 @@
/* eslint-disable */
import axios from 'axios';
module.exports = async function () {
// Configure axios for tests to use.
const host = process.env.HOST ?? 'localhost';
const port = process.env.PORT ?? '3000';
axios.defaults.baseURL = `http://${host}:${port}`;
};
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.spec.json"
}
],
"compilerOptions": {
"esModuleInterop": true
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"module": "commonjs",
"types": ["jest", "node"]
},
"include": ["jest.config.ts", "src/**/*.ts"]
}
+10
View File
@@ -0,0 +1,10 @@
module.exports = {
displayName: 'portal-bff',
preset: '../../jest.preset.js',
testEnvironment: 'node',
transform: {
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../coverage/apps/portal-bff',
};
+70
View File
@@ -0,0 +1,70 @@
{
"name": "portal-bff",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "apps/portal-bff/src",
"projectType": "application",
"tags": ["scope:portal-bff", "type:app"],
"targets": {
"build": {
"executor": "nx:run-commands",
"options": {
"command": "webpack-cli build",
"args": ["--node-env=production"],
"cwd": "apps/portal-bff"
},
"configurations": {
"development": {
"args": ["--node-env=development"]
}
}
},
"prune-lockfile": {
"dependsOn": ["build"],
"cache": true,
"executor": "@nx/js:prune-lockfile",
"outputs": [
"{workspaceRoot}/dist/apps/portal-bff/package.json",
"{workspaceRoot}/dist/apps/portal-bff/pnpm-lock.yaml"
],
"options": {
"buildTarget": "build"
}
},
"copy-workspace-modules": {
"dependsOn": ["build"],
"cache": true,
"outputs": ["{workspaceRoot}/dist/apps/portal-bff/workspace_modules"],
"executor": "@nx/js:copy-workspace-modules",
"options": {
"buildTarget": "build"
}
},
"prune": {
"dependsOn": ["prune-lockfile", "copy-workspace-modules"],
"executor": "nx:noop"
},
"serve": {
"continuous": true,
"executor": "@nx/js:node",
"defaultConfiguration": "development",
"dependsOn": ["build"],
"options": {
"buildTarget": "portal-bff:build",
"runBuildTargetDependencies": false
},
"configurations": {
"development": {
"buildTarget": "portal-bff:build:development"
},
"production": {
"buildTarget": "portal-bff:build:production"
}
}
},
"test": {
"options": {
"passWithNoTests": true
}
}
}
}
@@ -0,0 +1,21 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let app: TestingModule;
beforeAll(async () => {
app = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
});
describe('getData', () => {
it('should return "Hello API"', () => {
const appController = app.get<AppController>(AppController);
expect(appController.getData()).toEqual({ message: 'Hello API' });
});
});
});
+12
View File
@@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getData() {
return this.appService.getData();
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
@Module({
imports: [],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
@@ -0,0 +1,20 @@
import { Test } from '@nestjs/testing';
import { AppService } from './app.service';
describe('AppService', () => {
let service: AppService;
beforeAll(async () => {
const app = await Test.createTestingModule({
providers: [AppService],
}).compile();
service = app.get<AppService>(AppService);
});
describe('getData', () => {
it('should return "Hello API"', () => {
expect(service.getData()).toEqual({ message: 'Hello API' });
});
});
});
+8
View File
@@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getData(): { message: string } {
return { message: 'Hello API' };
}
}
View File
+26
View File
@@ -0,0 +1,26 @@
import { Logger, ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app/app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
// Phase-2 security ADRs will add: helmet, CORS allowlist, cookie-session,
// CSRF protection, rate limiting, auth guards, structured error filter.
const globalPrefix = 'api';
app.setGlobalPrefix(globalPrefix);
const port = process.env.PORT || 3000;
await app.listen(port);
Logger.log(`Application is running on: http://localhost:${port}/${globalPrefix}`);
}
bootstrap();
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"module": "commonjs",
"types": ["node"],
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"target": "es2021",
"moduleResolution": "node"
},
"include": ["src/**/*.ts"],
"exclude": ["jest.config.ts", "jest.config.cts", "src/**/*.spec.ts", "src/**/*.test.ts"]
}
+16
View File
@@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.base.json",
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
],
"compilerOptions": {
"esModuleInterop": true
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"module": "commonjs",
"moduleResolution": "node10",
"types": ["jest", "node"]
},
"include": [
"jest.config.ts",
"jest.config.cts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}
+25
View File
@@ -0,0 +1,25 @@
const { NxAppWebpackPlugin } = require('@nx/webpack/app-plugin');
const { join } = require('path');
module.exports = {
output: {
path: join(__dirname, '../../dist/apps/portal-bff'),
clean: true,
...(process.env.NODE_ENV !== 'production' && {
devtoolModuleFilenameTemplate: '[absolute-resource-path]',
}),
},
plugins: [
new NxAppWebpackPlugin({
target: 'node',
compiler: 'tsc',
main: './src/main.ts',
tsConfig: './tsconfig.app.json',
assets: ['./src/assets'],
optimization: false,
outputHashing: 'none',
generatePackageJson: true,
sourceMap: true,
}),
],
};
+12
View File
@@ -0,0 +1,12 @@
import playwright from 'eslint-plugin-playwright';
import baseConfig from '../../eslint.config.mjs';
export default [
playwright.configs['flat/recommended'],
...baseConfig,
{
files: ['**/*.ts', '**/*.js'],
// Override or add rules here
rules: {},
},
];
@@ -0,0 +1,68 @@
import { defineConfig, devices } from '@playwright/test';
import { nxE2EPreset } from '@nx/playwright/preset';
import { workspaceRoot } from '@nx/devkit';
// For CI, you may want to set BASE_URL to the deployed application.
const baseURL = process.env['BASE_URL'] || 'http://localhost:4200';
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
...nxE2EPreset(__filename, { testDir: './src' }),
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
baseURL,
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
},
/* Run your local dev server before starting the tests */
webServer: {
command: 'pnpm exec nx run portal-shell:serve',
url: 'http://localhost:4200',
reuseExistingServer: true,
cwd: workspaceRoot,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
// Uncomment for mobile browsers support
/* {
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
{
name: 'Mobile Safari',
use: { ...devices['iPhone 12'] },
}, */
// Uncomment for branded browsers
/* {
name: 'Microsoft Edge',
use: { ...devices['Desktop Edge'], channel: 'msedge' },
},
{
name: 'Google Chrome',
use: { ...devices['Desktop Chrome'], channel: 'chrome' },
} */
],
});
+9
View File
@@ -0,0 +1,9 @@
{
"name": "portal-shell-e2e",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"sourceRoot": "apps/portal-shell-e2e/src",
"implicitDependencies": ["portal-shell"],
"// targets": "to see all targets run: nx show project portal-shell-e2e --web",
"targets": {}
}
@@ -0,0 +1,8 @@
import { test, expect } from '@playwright/test';
test('has title', async ({ page }) => {
await page.goto('/');
// Expect h1 to contain a substring.
expect(await page.locator('h1').innerText()).toContain('Welcome');
});
+24
View File
@@ -0,0 +1,24 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"allowJs": true,
"outDir": "../../dist/out-tsc",
"sourceMap": false,
"module": "commonjs",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"include": [
"**/*.ts",
"**/*.js",
"playwright.config.ts",
"src/**/*.spec.ts",
"src/**/*.spec.js",
"src/**/*.test.ts",
"src/**/*.test.js",
"src/**/*.d.ts"
]
}
+34
View File
@@ -0,0 +1,34 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../eslint.config.mjs';
export default [
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
...baseConfig,
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'app',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'app',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];
+80
View File
@@ -0,0 +1,80 @@
{
"name": "portal-shell",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"prefix": "app",
"sourceRoot": "apps/portal-shell/src",
"tags": ["scope:portal-shell", "type:app"],
"targets": {
"build": {
"executor": "@angular/build:application",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/apps/portal-shell",
"browser": "apps/portal-shell/src/main.ts",
"tsConfig": "apps/portal-shell/tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "apps/portal-shell/public"
}
],
"styles": ["apps/portal-shell/src/styles.scss"]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kb",
"maximumError": "1mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kb",
"maximumError": "8kb"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"continuous": true,
"executor": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "portal-shell:build:production"
},
"development": {
"buildTarget": "portal-shell:build:development"
}
},
"defaultConfiguration": "development"
},
"lint": {
"executor": "@nx/eslint:lint"
},
"test": {
"executor": "@angular/build:unit-test",
"options": {}
},
"serve-static": {
"continuous": true,
"executor": "@nx/web:file-server",
"options": {
"buildTarget": "portal-shell:build",
"port": 4200,
"staticFilePath": "dist/apps/portal-shell/browser",
"spa": true
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+15
View File
@@ -0,0 +1,15 @@
import {
ApplicationConfig,
provideBrowserGlobalErrorListeners,
provideZonelessChangeDetection,
} from '@angular/core';
import { provideRouter } from '@angular/router';
import { appRoutes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideZonelessChangeDetection(),
provideBrowserGlobalErrorListeners(),
provideRouter(appRoutes),
],
};
+2
View File
@@ -0,0 +1,2 @@
<app-nx-welcome></app-nx-welcome>
<router-outlet></router-outlet>
+3
View File
@@ -0,0 +1,3 @@
import { Route } from '@angular/router';
export const appRoutes: Route[] = [];
View File
+18
View File
@@ -0,0 +1,18 @@
import { TestBed } from '@angular/core/testing';
import { App } from './app';
import { NxWelcome } from './nx-welcome';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App, NxWelcome],
}).compileComponents();
});
it('should render title', async () => {
const fixture = TestBed.createComponent(App);
await fixture.whenStable();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Welcome portal-shell');
});
});
+13
View File
@@ -0,0 +1,13 @@
import { Component } from '@angular/core';
import { RouterModule } from '@angular/router';
import { NxWelcome } from './nx-welcome';
@Component({
imports: [NxWelcome, RouterModule],
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.scss',
})
export class App {
protected title = 'portal-shell';
}
+938
View File
@@ -0,0 +1,938 @@
import { Component, ViewEncapsulation } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-nx-welcome',
imports: [CommonModule],
template: `
<!--
* * * * * * * * * * * * * * * * * * * * * * * * * * * *
This is a starter component and can be deleted.
* * * * * * * * * * * * * * * * * * * * * * * * * * * *
Delete this file and get started with your project!
* * * * * * * * * * * * * * * * * * * * * * * * * * * *
-->
<style>
html {
-webkit-text-size-adjust: 100%;
font-family:
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Arial,
'Noto Sans',
sans-serif,
'Apple Color Emoji',
'Segoe UI Emoji',
'Segoe UI Symbol',
'Noto Color Emoji';
line-height: 1.5;
tab-size: 4;
scroll-behavior: smooth;
}
body {
font-family: inherit;
line-height: inherit;
margin: 0;
}
h1,
h2,
p,
pre {
margin: 0;
}
*,
::before,
::after {
box-sizing: border-box;
border-width: 0;
border-style: solid;
border-color: currentColor;
}
h1,
h2 {
font-size: inherit;
font-weight: inherit;
}
a {
color: inherit;
text-decoration: inherit;
}
pre {
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
monospace;
}
svg {
display: block;
vertical-align: middle;
}
svg {
shape-rendering: auto;
text-rendering: optimizeLegibility;
}
pre {
background-color: rgba(55, 65, 81, 1);
border-radius: 0.25rem;
color: rgba(229, 231, 235, 1);
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
monospace;
overflow: auto;
padding: 0.5rem 0.75rem;
}
.shadow {
box-shadow:
0 0 #0000,
0 0 #0000,
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
.rounded {
border-radius: 1.5rem;
}
.wrapper {
width: 100%;
}
.container {
margin-left: auto;
margin-right: auto;
max-width: 768px;
padding-bottom: 3rem;
padding-left: 1rem;
padding-right: 1rem;
color: rgba(55, 65, 81, 1);
width: 100%;
}
#welcome {
margin-top: 2.5rem;
}
#welcome h1 {
font-size: 3rem;
font-weight: 500;
letter-spacing: -0.025em;
line-height: 1;
}
#welcome span {
display: block;
font-size: 1.875rem;
font-weight: 300;
line-height: 2.25rem;
margin-bottom: 0.5rem;
}
#hero {
align-items: center;
background-color: hsla(214, 62%, 21%, 1);
border: none;
box-sizing: border-box;
color: rgba(55, 65, 81, 1);
display: grid;
grid-template-columns: 1fr;
margin-top: 3.5rem;
}
#hero .text-container {
color: rgba(255, 255, 255, 1);
padding: 3rem 2rem;
}
#hero .text-container h2 {
font-size: 1.5rem;
line-height: 2rem;
position: relative;
}
#hero .text-container h2 svg {
color: hsla(162, 47%, 50%, 1);
height: 2rem;
left: -0.25rem;
position: absolute;
top: 0;
width: 2rem;
}
#hero .text-container h2 span {
margin-left: 2.5rem;
}
#hero .text-container a {
background-color: rgba(255, 255, 255, 1);
border-radius: 0.75rem;
color: rgba(55, 65, 81, 1);
display: inline-block;
margin-top: 1.5rem;
padding: 1rem 2rem;
text-decoration: inherit;
}
#hero .logo-container {
display: none;
justify-content: center;
padding-left: 2rem;
padding-right: 2rem;
}
#hero .logo-container svg {
color: rgba(255, 255, 255, 1);
width: 66.666667%;
}
#middle-content {
align-items: flex-start;
display: grid;
grid-template-columns: 1fr;
margin-top: 3.5rem;
}
#middle-content #middle-left-content {
display: flex;
flex-direction: column;
gap: 2rem;
}
#learning-materials {
padding: 2.5rem 2rem;
}
#learning-materials h2 {
font-weight: 500;
font-size: 1.25rem;
letter-spacing: -0.025em;
line-height: 1.75rem;
padding-left: 1rem;
padding-right: 1rem;
}
.list-item-link {
align-items: center;
border-radius: 0.75rem;
display: flex;
margin-top: 1rem;
padding: 1rem;
transition-property:
background-color,
border-color,
color,
fill,
stroke,
opacity,
box-shadow,
transform,
filter,
backdrop-filter,
-webkit-backdrop-filter;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
width: 100%;
}
.list-item-link svg:first-child {
margin-right: 1rem;
height: 1.5rem;
transition-property:
background-color,
border-color,
color,
fill,
stroke,
opacity,
box-shadow,
transform,
filter,
backdrop-filter,
-webkit-backdrop-filter;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
width: 1.5rem;
}
.list-item-link > span {
flex-grow: 1;
font-weight: 400;
transition-property:
background-color,
border-color,
color,
fill,
stroke,
opacity,
box-shadow,
transform,
filter,
backdrop-filter,
-webkit-backdrop-filter;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
.list-item-link > span > span {
color: rgba(107, 114, 128, 1);
display: block;
flex-grow: 1;
font-size: 0.75rem;
font-weight: 300;
line-height: 1rem;
transition-property:
background-color,
border-color,
color,
fill,
stroke,
opacity,
box-shadow,
transform,
filter,
backdrop-filter,
-webkit-backdrop-filter;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
.list-item-link svg:last-child {
height: 1rem;
transition-property: all;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
width: 1rem;
}
.list-item-link:hover {
color: rgba(255, 255, 255, 1);
background-color: hsla(162, 55%, 33%, 1);
}
.list-item-link:hover > span > span {
color: rgba(243, 244, 246, 1);
}
.list-item-link:hover svg:last-child {
transform: translateX(0.25rem);
}
.button-pill {
padding: 1.5rem 2rem;
margin-bottom: 2rem;
transition-duration: 300ms;
transition-property:
background-color,
border-color,
color,
fill,
stroke,
opacity,
box-shadow,
transform,
filter,
backdrop-filter,
-webkit-backdrop-filter;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
align-items: center;
display: flex;
}
.button-pill svg {
transition-property:
background-color,
border-color,
color,
fill,
stroke,
opacity,
box-shadow,
transform,
filter,
backdrop-filter,
-webkit-backdrop-filter;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
flex-shrink: 0;
width: 3rem;
}
.button-pill > span {
letter-spacing: -0.025em;
font-weight: 400;
font-size: 1.125rem;
line-height: 1.75rem;
padding-left: 1rem;
padding-right: 1rem;
}
.button-pill span span {
display: block;
font-size: 0.875rem;
font-weight: 300;
line-height: 1.25rem;
}
.button-pill:hover svg,
.button-pill:hover {
color: rgba(255, 255, 255, 1) !important;
}
.nx-console:hover {
background-color: rgba(0, 122, 204, 1);
}
.nx-console svg {
color: rgba(0, 122, 204, 1);
}
.nx-console-jetbrains {
margin-top: 2rem;
}
.nx-console-jetbrains:hover {
background-color: rgba(255, 49, 140, 1);
}
.nx-console-jetbrains svg {
color: rgba(255, 49, 140, 1);
}
#nx-repo:hover {
background-color: rgba(24, 23, 23, 1);
}
#nx-repo svg {
color: rgba(24, 23, 23, 1);
}
#nx-cloud {
margin-bottom: 2rem;
margin-top: 2rem;
padding: 2.5rem 2rem;
}
#nx-cloud > div {
align-items: center;
display: flex;
}
#nx-cloud > div svg {
border-radius: 0.375rem;
flex-shrink: 0;
width: 3rem;
}
#nx-cloud > div h2 {
font-size: 1.125rem;
font-weight: 400;
letter-spacing: -0.025em;
line-height: 1.75rem;
padding-left: 1rem;
padding-right: 1rem;
}
#nx-cloud > div h2 span {
display: block;
font-size: 0.875rem;
font-weight: 300;
line-height: 1.25rem;
}
#nx-cloud p {
font-size: 1rem;
line-height: 1.5rem;
margin-top: 1rem;
}
#nx-cloud pre {
margin-top: 1rem;
}
#nx-cloud a {
color: rgba(107, 114, 128, 1);
display: block;
font-size: 0.875rem;
line-height: 1.25rem;
margin-top: 1.5rem;
text-align: right;
}
#nx-cloud a:hover {
text-decoration: underline;
}
#commands {
padding: 2.5rem 2rem;
margin-top: 3.5rem;
}
#commands h2 {
font-size: 1.25rem;
font-weight: 400;
letter-spacing: -0.025em;
line-height: 1.75rem;
padding-left: 1rem;
padding-right: 1rem;
}
#commands p {
font-size: 1rem;
font-weight: 300;
line-height: 1.5rem;
margin-top: 1rem;
padding-left: 1rem;
padding-right: 1rem;
}
details {
align-items: center;
display: flex;
margin-top: 1rem;
padding-left: 1rem;
padding-right: 1rem;
width: 100%;
}
details pre > span {
color: rgba(181, 181, 181, 1);
}
summary {
border-radius: 0.5rem;
display: flex;
font-weight: 400;
padding: 0.5rem;
cursor: pointer;
transition-property:
background-color,
border-color,
color,
fill,
stroke,
opacity,
box-shadow,
transform,
filter,
backdrop-filter,
-webkit-backdrop-filter;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
summary:hover {
background-color: rgba(243, 244, 246, 1);
}
summary svg {
height: 1.5rem;
margin-right: 1rem;
width: 1.5rem;
}
#love {
color: rgba(107, 114, 128, 1);
font-size: 0.875rem;
line-height: 1.25rem;
margin-top: 3.5rem;
opacity: 0.6;
text-align: center;
}
#love svg {
color: rgba(252, 165, 165, 1);
width: 1.25rem;
height: 1.25rem;
display: inline;
margin-top: -0.25rem;
}
@media screen and (min-width: 768px) {
#hero {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
#hero .logo-container {
display: flex;
}
#middle-content {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4rem;
}
}
</style>
<div class="wrapper">
<div class="container">
<!-- WELCOME -->
<div id="welcome">
<h1>
<span> Hello there, </span>
Welcome portal-shell 👋
</h1>
</div>
<!-- HERO -->
<div id="hero" class="rounded">
<div class="text-container">
<h2>
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z"
/>
</svg>
<span>You&apos;re up and running</span>
</h2>
<a href="#commands"> What&apos;s next? </a>
</div>
<div class="logo-container">
<svg
fill="currentColor"
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11.987 14.138l-3.132 4.923-5.193-8.427-.012 8.822H0V4.544h3.691l5.247 8.833.005-3.998 3.044 4.759zm.601-5.761c.024-.048 0-3.784.008-3.833h-3.65c.002.059-.005 3.776-.003 3.833h3.645zm5.634 4.134a2.061 2.061 0 0 0-1.969 1.336 1.963 1.963 0 0 1 2.343-.739c.396.161.917.422 1.33.283a2.1 2.1 0 0 0-1.704-.88zm3.39 1.061c-.375-.13-.8-.277-1.109-.681-.06-.08-.116-.17-.176-.265a2.143 2.143 0 0 0-.533-.642c-.294-.216-.68-.322-1.18-.322a2.482 2.482 0 0 0-2.294 1.536 2.325 2.325 0 0 1 4.002.388.75.75 0 0 0 .836.334c.493-.105.46.36 1.203.518v-.133c-.003-.446-.246-.55-.75-.733zm2.024 1.266a.723.723 0 0 0 .347-.638c-.01-2.957-2.41-5.487-5.37-5.487a5.364 5.364 0 0 0-4.487 2.418c-.01-.026-1.522-2.39-1.538-2.418H8.943l3.463 5.423-3.379 5.32h3.54l1.54-2.366 1.568 2.366h3.541l-3.21-5.052a.7.7 0 0 1-.084-.32 2.69 2.69 0 0 1 2.69-2.691h.001c1.488 0 1.736.89 2.057 1.308.634.826 1.9.464 1.9 1.541a.707.707 0 0 0 1.066.596zm.35.133c-.173.372-.56.338-.755.639-.176.271.114.412.114.412s.337.156.538-.311c.104-.231.14-.488.103-.74z"
/>
</svg>
</div>
</div>
<!-- MIDDLE CONTENT -->
<div id="middle-content">
<div id="middle-left-content">
<div id="learning-materials" class="rounded shadow">
<h2>Learning materials</h2>
<a
href="https://nx.dev/getting-started/intro?utm_source=nx-project"
target="_blank"
rel="noreferrer"
class="list-item-link"
>
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg>
<span>
Documentation
<span> Everything is in there </span>
</span>
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</a>
<a
href="https://nx.dev/blog?utm_source=nx-project"
target="_blank"
rel="noreferrer"
class="list-item-link"
>
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z"
/>
</svg>
<span>
Blog
<span> Changelog, features & events </span>
</span>
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</a>
<a
href="https://www.youtube.com/@NxDevtools/videos?utm_source=nx-project&sub_confirmation=1"
target="_blank"
rel="noreferrer"
class="list-item-link"
>
<svg
role="img"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<title>YouTube</title>
<path
d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"
/>
</svg>
<span>
YouTube channel
<span> Nx Show, talks & tutorials </span>
</span>
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</a>
<a
href="https://nx.dev/getting-started/tutorials/angular-standalone-tutorial?utm_source=nx-project"
target="_blank"
rel="noreferrer"
class="list-item-link"
>
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5M7.188 2.239l.777 2.897M5.136 7.965l-2.898-.777M13.95 4.05l-2.122 2.122m-5.657 5.656l-2.12 2.122"
/>
</svg>
<span>
Interactive tutorials
<span> Create an app, step-by-step </span>
</span>
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</a>
</div>
<a
id="nx-repo"
class="button-pill rounded shadow"
href="https://github.com/nrwl/nx?utm_source=nx-project"
target="_blank"
rel="noreferrer"
>
<svg
fill="currentColor"
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
/>
</svg>
<span>
Nx is open source
<span> Love Nx? Give us a star! </span>
</span>
</a>
</div>
<div id="other-links">
<a
class="button-pill rounded shadow nx-console"
href="https://marketplace.visualstudio.com/items?itemName=nrwl.angular-console&utm_source=nx-project"
target="_blank"
rel="noreferrer"
>
<svg
fill="currentColor"
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<title>Visual Studio Code</title>
<path
d="M23.15 2.587L18.21.21a1.494 1.494 0 0 0-1.705.29l-9.46 8.63-4.12-3.128a.999.999 0 0 0-1.276.057L.327 7.261A1 1 0 0 0 .326 8.74L3.899 12 .326 15.26a1 1 0 0 0 .001 1.479L1.65 17.94a.999.999 0 0 0 1.276.057l4.12-3.128 9.46 8.63a1.492 1.492 0 0 0 1.704.29l4.942-2.377A1.5 1.5 0 0 0 24 20.06V3.939a1.5 1.5 0 0 0-.85-1.352zm-5.146 14.861L10.826 12l7.178-5.448v10.896z"
/>
</svg>
<span>
Install Nx Console for VSCode
<span>The official VSCode extension for Nx.</span>
</span>
</a>
<a
class="button-pill rounded shadow nx-console-jetbrains"
href="https://plugins.jetbrains.com/plugin/21060-nx-console"
target="_blank"
rel="noreferrer"
>
<svg height="48" width="48" viewBox="20 20 60 60" xmlns="http://www.w3.org/2000/svg">
<path d="m22.5 22.5h60v60h-60z" />
<g fill="#fff">
<path d="m29.03 71.25h22.5v3.75h-22.5z" />
<path
d="m28.09 38 1.67-1.58a1.88 1.88 0 0 0 1.47.87c.64 0 1.06-.44 1.06-1.31v-5.98h2.58v6a3.48 3.48 0 0 1 -.87 2.6 3.56 3.56 0 0 1 -2.57.95 3.84 3.84 0 0 1 -3.34-1.55z"
/>
<path d="m36 30h7.53v2.19h-5v1.44h4.49v2h-4.42v1.49h5v2.21h-7.6z" />
<path d="m47.23 32.29h-2.8v-2.29h8.21v2.27h-2.81v7.1h-2.6z" />
<path
d="m29.13 43.08h4.42a3.53 3.53 0 0 1 2.55.83 2.09 2.09 0 0 1 .6 1.53 2.16 2.16 0 0 1 -1.44 2.09 2.27 2.27 0 0 1 1.86 2.29c0 1.61-1.31 2.59-3.55 2.59h-4.44zm5 2.89c0-.52-.42-.8-1.18-.8h-1.29v1.64h1.24c.79 0 1.25-.26 1.25-.81zm-.9 2.66h-1.57v1.73h1.62c.8 0 1.24-.31 1.24-.86 0-.5-.4-.87-1.27-.87z"
/>
<path
d="m38 43.08h4.1a4.19 4.19 0 0 1 3 1 2.93 2.93 0 0 1 .9 2.19 3 3 0 0 1 -1.93 2.89l2.24 3.27h-3l-1.88-2.84h-.87v2.84h-2.56zm4 4.5c.87 0 1.39-.43 1.39-1.11 0-.75-.54-1.12-1.4-1.12h-1.44v2.26z"
/>
<path
d="m49.59 43h2.5l4 9.44h-2.79l-.67-1.69h-3.63l-.67 1.69h-2.71zm2.27 5.73-1-2.65-1.06 2.65z"
/>
<path d="m56.46 43.05h2.6v9.37h-2.6z" />
<path d="m60.06 43.05h2.42l3.37 5v-5h2.57v9.37h-2.26l-3.53-5.14v5.14h-2.57z" />
<path
d="m68.86 51 1.45-1.73a4.84 4.84 0 0 0 3 1.13c.71 0 1.08-.24 1.08-.65 0-.4-.31-.6-1.59-.91-2-.46-3.53-1-3.53-2.93 0-1.74 1.37-3 3.62-3a5.89 5.89 0 0 1 3.86 1.25l-1.26 1.84a4.63 4.63 0 0 0 -2.62-.92c-.63 0-.94.25-.94.6 0 .42.32.61 1.63.91 2.14.46 3.44 1.16 3.44 2.91 0 1.91-1.51 3-3.79 3a6.58 6.58 0 0 1 -4.35-1.5z"
/>
</g>
</svg>
<span>
Install Nx Console for JetBrains
<span>Available for WebStorm, Intellij IDEA Ultimate and more!</span>
</span>
</a>
<div id="nx-cloud" class="rounded shadow">
<div>
<svg
id="nx-cloud-logo"
role="img"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
fill="transparent"
viewBox="0 0 24 24"
>
<path
stroke-width="2"
d="M23 3.75V6.5c-3.036 0-5.5 2.464-5.5 5.5s-2.464 5.5-5.5 5.5-5.5 2.464-5.5 5.5H3.75C2.232 23 1 21.768 1 20.25V3.75C1 2.232 2.232 1 3.75 1h16.5C21.768 1 23 2.232 23 3.75Z"
/>
<path
stroke-width="2"
d="M23 6v14.1667C23 21.7307 21.7307 23 20.1667 23H6c0-3.128 2.53867-5.6667 5.6667-5.6667 3.128 0 5.6666-2.5386 5.6666-5.6666C17.3333 8.53867 19.872 6 23 6Z"
/>
</svg>
<h2>
Nx Cloud
<span> Enable faster CI & better DX </span>
</h2>
</div>
<p>You can activate distributed tasks executions and caching by running:</p>
<pre>nx connect</pre>
<a
href="https://nx.dev/nx-cloud?utm_source=nx-project"
target="_blank"
rel="noreferrer"
>
What is Nx Cloud?
</a>
</div>
</div>
</div>
<!-- COMMANDS -->
<div id="commands" class="rounded shadow">
<h2>Next steps</h2>
<p>Here are some things you can do with Nx:</p>
<details>
<summary>
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
Build, test and lint your app
</summary>
<pre><span># Build</span>
nx build
<span># Test</span>
nx test
<span># Lint</span>
nx lint
<span># Run them together!</span>
nx run-many -t build test lint</pre>
</details>
<details>
<summary>
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
View project details
</summary>
<pre>nx show project portal-shell</pre>
</details>
<details>
<summary>
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
View interactive project graph
</summary>
<pre>nx graph</pre>
</details>
<details>
<summary>
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
Add UI library
</summary>
<pre><span># Generate UI lib</span>
nx g &#64;nx/angular:lib ui
<span># Add a component</span>
nx g &#64;nx/angular:component ui/src/lib/button</pre>
</details>
</div>
<p id="love">
Carefully crafted with
<svg
fill="currentColor"
stroke="none"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
/>
</svg>
</p>
</div>
</div>
`,
styles: [],
encapsulation: ViewEncapsulation.None,
})
export class NxWelcome {}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>portal-shell</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
</head>
<body>
<app-root></app-root>
</body>
</html>
+5
View File
@@ -0,0 +1,5 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
bootstrapApplication(App, appConfig).catch((err) => console.error(err));
+1
View File
@@ -0,0 +1 @@
/* You can add global styles to this file, and also import other style files */
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
}
+23
View File
@@ -0,0 +1,23 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"emitDecoratorMetadata": false,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": ["vitest/globals"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts"]
}
@@ -9,7 +9,7 @@ tags: [process]
## Context and Problem Statement ## Context and Problem Statement
Architecturally-significant decisions will be taken throughout the lifecycle of `adastra-portal`. Without a written, durable trace of *why* a decision was made — context, drivers, alternatives, trade-offs — that knowledge fades within months. New contributors re-litigate settled debates, the rationale of constraints (security, performance, accessibility) gets lost, and reversing a choice later becomes risky because nobody remembers what it was protecting against. Architecturally-significant decisions will be taken throughout the lifecycle of `apf-portal`. Without a written, durable trace of *why* a decision was made — context, drivers, alternatives, trade-offs — that knowledge fades within months. New contributors re-litigate settled debates, the rationale of constraints (security, performance, accessibility) gets lost, and reversing a choice later becomes risky because nobody remembers what it was protecting against.
How do we record decisions in a way that is light enough to be sustained, durable enough to outlive contributor turnover, and integrated into the development workflow? How do we record decisions in a way that is light enough to be sustained, durable enough to outlive contributor turnover, and integrated into the development workflow?
@@ -9,7 +9,7 @@ tags: [infrastructure, frontend, backend]
## Context and Problem Statement ## Context and Problem Statement
`adastra-portal` will host two cooperating runtime artifacts from day one — an Angular SPA frontend and a Node.js BFF backend — and is expected to grow with feature libraries shared between them (auth, data-access, UI primitives). Strongly-typed contracts must travel from back to front without round-tripping through a published package, dependency hygiene must be enforceable, and CI must run only on what changed. `apf-portal` will host two cooperating runtime artifacts from day one — an Angular SPA frontend and a Node.js BFF backend — and is expected to grow with feature libraries shared between them (auth, data-access, UI primitives). Strongly-typed contracts must travel from back to front without round-tripping through a published package, dependency hygiene must be enforceable, and CI must run only on what changed.
How do we structure the repository so both apps coexist with shared libraries, builds and tests run incrementally, and the tooling is mainstream enough to be supported long-term? How do we structure the repository so both apps coexist with shared libraries, builds and tests run incrementally, and the tooling is mainstream enough to be supported long-term?
@@ -51,7 +51,7 @@ Plugins enabled at bootstrap: `@nx/angular`, `@nx/nest`, `@nx/eslint`, `@nx/vite
### Confirmation ### Confirmation
* Workspace is bootstrapped with `pnpm dlx create-nx-workspace@latest adastra-portal --preset=apps --pm=pnpm`. * Workspace is bootstrapped with `pnpm dlx create-nx-workspace@latest apf-portal --preset=apps --pm=pnpm`.
* `nx.json` has `@nx/eslint/enforce-module-boundaries` enabled and configured against the project tags. * `nx.json` has `@nx/eslint/enforce-module-boundaries` enabled and configured against the project tags.
* CI runs `pnpm nx affected -t lint test build` on every push. * CI runs `pnpm nx affected -t lint test build` on every push.
@@ -9,7 +9,7 @@ tags: [process]
## Context and Problem Statement ## Context and Problem Statement
Default Nx scaffolding offers placeholder names like `my-workspace` and `web`, which are unacceptable for a long-lived enterprise project. Naming has to survive two foreseeable changes: the product's marketing name may evolve (the working title `adastra_portal` is provisional), and the project may sit alongside other R&D projects in the same Gitea organization, where collisions on generic names like `web` or `api` would cause confusion. Default Nx scaffolding offers placeholder names like `my-workspace` and `web`, which are unacceptable for a long-lived enterprise project. Naming has to survive two foreseeable changes: the product's marketing name may evolve (the working title `apf_portal` is provisional), and the project may sit alongside other R&D projects in the same Gitea organization, where collisions on generic names like `web` or `api` would cause confusion.
What naming convention do we adopt for the workspace, the apps, and future libraries so names are explicit, function-anchored, and stable against rebranding? What naming convention do we adopt for the workspace, the apps, and future libraries so names are explicit, function-anchored, and stable against rebranding?
@@ -24,7 +24,7 @@ What naming convention do we adopt for the workspace, the apps, and future libra
## Considered Options ## Considered Options
* **Defaults** — `my-workspace`, `web`. (Rejected up-front.) * **Defaults** — `my-workspace`, `web`. (Rejected up-front.)
* **Brand-anchored** — `adastra-front`, `adastra-back`. Fragile to rebranding. * **Brand-anchored** — `apf-front`, `apf-back`. Fragile to further rebranding.
* **Function-prefixed** — `portal-shell`, `portal-bff`. (Chosen.) * **Function-prefixed** — `portal-shell`, `portal-bff`. (Chosen.)
* **Generic** — `shell`, `bff`. Risk of org-wide collision; reduced readability. * **Generic** — `shell`, `bff`. Risk of org-wide collision; reduced readability.
@@ -34,13 +34,13 @@ Chosen option: **function-prefixed naming**.
| Scope | Name | Rationale | | Scope | Name | Rationale |
| --- | --- | --- | | --- | --- | --- |
| Workspace / repo / npm package root | `adastra-portal` | matches the Gitea repository name; the only place a brand-ish name lives, so a rebrand is a one-line change | | Workspace / repo / npm package root | `apf-portal` | matches the Gitea repository name; the only place a brand-ish name lives, so a rebrand is a one-line change |
| Frontend app | `portal-shell` | "shell" describes the function — the Angular host that loads features | | Frontend app | `portal-shell` | "shell" describes the function — the Angular host that loads features |
| Backend app | `portal-bff` | explicit role: backend-for-frontend | | Backend app | `portal-bff` | explicit role: backend-for-frontend |
| Feature libraries | `feature-<name>` | e.g. `feature-auth`, `feature-billing` | | Feature libraries | `feature-<name>` | e.g. `feature-auth`, `feature-billing` |
| Shared libraries | `shared-<scope>` | e.g. `shared-ui`, `shared-data-access`, `shared-util` | | Shared libraries | `shared-<scope>` | e.g. `shared-ui`, `shared-data-access`, `shared-util` |
The `portal-` prefix encodes the *function* (a portal). Even if the brand `Adastra` is renamed, "portal" remains accurate. The brand-ish token `adastra` is confined to the workspace root and a tiny number of metadata files (root `package.json`, repo URL). The `portal-` prefix encodes the *function* (a portal). The host organisation's brand can change without invalidating "portal" — the project was originally proposed as `adastra_portal` and renamed to `apf_portal` on 2026-04-30 once the host organisation (APF France Handicap) was confirmed; the apps `portal-shell` / `portal-bff` were unaffected by the rename, validating this convention. The brand-ish token `apf` is confined to the workspace root and a tiny number of metadata files (root `package.json`, repo URL).
### Consequences ### Consequences
+312
View File
@@ -0,0 +1,312 @@
---
status: accepted
date: 2026-04-30
decision-makers: R&D Lead
tags: [infrastructure, process]
---
# CI/CD pipeline — Gitea Actions, trunk-based + squash-merge, thin YAML over portable scripts
## Context and Problem Statement
The repository currently lives on Gitea (`gitea@git.unespace.com:julien/apf_portal.git`). The organisation plans to migrate to GitLab on a 618-month horizon. The pipeline shape we adopt now must be (a) operational on Gitea immediately, (b) low-cost to migrate later, and (c) consistent with the project's anti-bricolage and security-first values.
We also need to fix the branch model, the merge strategy, the required gates, the protection rules on `main`, and the location of the orchestration logic — all of these have first-order effects on the Nx scaffold (`package.json` scripts, lint configs, branch tags, conventional-commits validation surface).
This ADR is split into two levels of decision:
- **Level 1 — vendor-neutral.** Survives the future GitLab migration unchanged.
- **Level 2 — Gitea Actions implementation.** Will be rewritten when GitLab is adopted; a future ADR will supersede the level-2 sections without affecting level-1.
## Decision Drivers
* Pipeline portability across CI vendors (Gitea now, GitLab later, possibly other platforms in the future).
* All gates blocking — no "warnings ignored". Either we fail or we adjust the threshold via ADR.
* Trunk-based development for fast feedback and continuous integration discipline.
* Compatibility with the Nx monorepo's `affected` model and remote cache.
* Self-hosted runners (on-prem context — see ADR-0008's hosting constraint).
* Mature, mainstream tooling — anti-bricolage applies particularly here.
* Local enforcement (Husky + lint-staged + commitlint, ADR-0007) plus CI defense in depth — the same checks run twice, on purpose.
## Considered Options
### Branch / merge strategy (level 1)
* **Trunk-based + squash-merge.** (Chosen.)
* Trunk-based + rebase-merge.
* Trunk-based + merge commit.
* GitFlow.
### Required reviewer count on PRs to `main` (level 1)
* **0 in v1, ≥1 once a second active contributor exists.** (Chosen.)
* 1 always (blocks if solo).
* 2 (heavy for a small team).
### Signed commits (level 1)
* **Recommended but not required in v1; reconsidered at the GitLab migration ADR.** (Chosen.)
* Required in v1.
* Never.
### Conventional Commits validation (level 1)
* **Local `commit-msg` hook (ADR-0007) + CI defense-in-depth on the PR commit range.** (Chosen.)
* Local hook only.
* CI only.
### Pipeline orchestration logic location (level 1)
* **Thin YAML — logic lives in `package.json` scripts and Nx targets, the workflow file orchestrates.** (Chosen.)
* Logic in YAML, scripts called step by step.
### CI engine (level 2 — Gitea-specific)
* **Gitea Actions** (built-in since Gitea 1.19, GitHub Actions-compatible YAML). (Chosen.)
* Drone CI alongside Gitea.
* Concourse / Tekton / Buildkite / etc.
### Runner topology (level 2)
* **≥ 3 self-hosted `act_runner` instances on-prem.** (Chosen.)
* Single runner.
* Cloud-hosted (rejected — on-prem constraint).
## Decision Outcome
### Level 1 — vendor-neutral decisions
**Branch model.** Trunk-based with `main` always deployable. Feature branches are short-lived (hours to days), named `feat/<short-slug>` or `fix/<slug>` or `chore/<slug>`. Releases happen by tagging `vX.Y.Z` on `main`.
**Merge strategy.** Squash-merge only. The squash subject is the PR title and must be a valid Conventional Commits message; the squash body inherits the PR body. This produces a clean linear history on `main` where each commit corresponds 1:1 to a PR. Rebase-merge and merge-commit are disabled at the platform level.
**Branch protection on `main`:**
- direct push: forbidden (no exceptions, including the project lead);
- force push: forbidden;
- linear history: required (consistent with squash-merge);
- required status checks: every CI gate listed below, all blocking;
- required PR review approvals: 0 in v1 (solo), revisited to ≥1 once a second active contributor joins (a follow-up ADR or amendment will mark the date);
- branch deletion after merge: required;
- merge of stale branches: PRs must be up-to-date with `main` before merging (or use a merge queue once GitLab provides one).
**Required CI gates** (every gate is blocking; failing any blocks the merge):
| Gate | What it runs | Tooling |
| --- | --- | --- |
| `format` | Prettier check, no auto-fix | `prettier --check` via `pnpm nx format:check` |
| `lint` | ESLint across affected projects, including `@nx/enforce-module-boundaries` | `pnpm nx affected -t lint` |
| `type-check` | TypeScript strict, no emit | `pnpm nx affected -t type-check` |
| `test` | Unit/component tests | Vitest (front, ADR-0004) and Jest (back, ADR-0005), via `pnpm nx affected -t test` |
| `build` | Production builds of affected apps and libs | `pnpm nx affected -t build` |
| `audit` | Dependency vulnerabilities | `pnpm audit` + Trivy filesystem scan |
| `secret-scan` | Repo-wide secret detection | `gitleaks` |
| `commit-lint` | Conventional Commits validation on the PR commit range | `commitlint --from <base> --to HEAD` |
| `a11y` | Pending — defined by future a11y ADR (axe-core in e2e) | (placeholder) |
| `perf` | Pending — defined by future perf ADR (Lighthouse CI) | (placeholder) |
**Conventional Commits.** Already enforced locally via the `commit-msg` hook (ADR-0007). In CI, `commitlint --from origin/main --to HEAD` runs against the PR commit range as defense in depth — even if a contributor bypasses the local hook, the CI gate catches it.
**Signed commits.** Recommended but not required in v1. Setup overhead for contributors (GPG or SSH signing key) is non-trivial relative to the marginal value with a single contributor and a well-controlled host. The decision is revisited as part of the GitLab migration ADR (GitLab has stronger built-in tooling for centralised signing policies than current Gitea).
**Logic location — the "thin YAML" pattern.** All non-trivial CI logic lives in `package.json` scripts and Nx targets, *not* in the workflow YAML. The YAML's role is restricted to: checkout, runtime setup, cache restoration, and calling a single high-level script per job. Concretely:
```jsonc
// package.json (excerpt — lands with the scaffold)
"scripts": {
"ci:check": "pnpm exec nx affected -t format:check lint type-check test build",
"ci:scan": "pnpm audit --audit-level=moderate && pnpm exec trivy fs --skip-dirs node_modules --exit-code 1 . && pnpm exec gitleaks detect --no-banner --redact",
"ci:commits": "pnpm exec commitlint --from $COMMIT_LINT_FROM --to HEAD --verbose"
}
```
The migration to GitLab then becomes a rewrite of the YAML wrappers (a few dozen lines) and not a re-derivation of the gates — these scripts are platform-agnostic, runnable locally, and serve as the source of truth.
**Caching.** Two cache surfaces, both portable:
- the `pnpm` store, keyed on `pnpm-lock.yaml`;
- the Nx local cache (`.nx/cache`), keyed on the project graph.
The level-2 implementation wires both via the CI vendor's cache action; the level-1 contract is "these two paths must be cached".
**Secrets policy.**
- Secrets live exclusively in CI vendor variables (Gitea → GitLab later). Naming convention: `SCOPE_PURPOSE` (e.g. `BFF_DATABASE_URL`, `OBO_CACHE_ENCRYPTION_KEY`).
- No secret ever in source. `gitleaks` enforces.
- Rotation procedures, key vault, and operator runbooks belong in the future operations / secret-management ADR.
**Container images / deployment.** Explicitly out of scope of this ADR. CI v1 builds and tests; image build and deploy will land with the on-prem infrastructure ADR (phase 3).
### Level 2 — Gitea-specific implementation
**Engine.** Gitea Actions, available since Gitea 1.19. The workflow YAML is GitHub Actions-syntax-compatible, which means most third-party actions (`actions/checkout`, `actions/setup-node`, `pnpm/action-setup`, etc.) work unchanged. This compatibility is also a partial migration hedge: the same workflows can be ported to GitHub Actions with near-zero changes if the org pivots to GitHub instead of GitLab.
**Runners.** Three self-hosted `act_runner` instances on internal infrastructure. The first runner is deployed to validate the pipeline; the second and third are added before the project hits any non-trivial PR volume. Runners are labelled `self-hosted`, `on-prem`, plus capacity labels (`size:default`) for future job differentiation. Runner image baseline: a Debian image (aligned with the WSL development environment) pinned by SHA and rebuilt on a cadence by a security-scheduled job.
**Workflow file structure.**
- `.gitea/workflows/ci.yml` — runs on `pull_request` and `push` to `main`. Hosts the `check`, `scan`, and `commits` jobs.
- `.gitea/workflows/release.yml` — runs on `push` of a `vX.Y.Z` tag. Builds release artefacts. Empty stub in v1; gains content when the on-prem deploy ADR lands.
- `.gitea/workflows/security-scheduled.yml` — runs weekly via `schedule:` cron. Re-runs Trivy and gitleaks on the full tree (not just affected), and triggers Renovate (configuration covered by the security baseline ADR).
Three files; small surface; clear scope per file.
**Workflow shape (illustrative — final lands with the scaffold):**
```yaml
# .gitea/workflows/ci.yml
name: CI
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
check:
runs-on: [self-hosted, on-prem]
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: nrwl/nx-set-shas@v4
- uses: pnpm/action-setup@v3
- uses: actions/setup-node@v4
with: { node-version-file: '.nvmrc', cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: pnpm ci:check
scan:
runs-on: [self-hosted, on-prem]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
- uses: actions/setup-node@v4
with: { node-version-file: '.nvmrc', cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: pnpm ci:scan
commits:
if: github.event_name == 'pull_request'
runs-on: [self-hosted, on-prem]
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: pnpm/action-setup@v3
- uses: actions/setup-node@v4
with: { node-version-file: '.nvmrc', cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: COMMIT_LINT_FROM=origin/main pnpm ci:commits
```
This is a sketch, not the final file — the final lands with the scaffold and any iteration on it is not by itself an ADR-worthy change.
**Branch protection (Gitea-side configuration):** `Settings → Branches → Add rule` on `main`:
- Disable force push: yes;
- Disable direct push: yes (only PR merge);
- Required status checks: `check`, `scan`, `commits`, plus future `a11y`, `perf`;
- Require pull request reviews: 0 in v1, raise to 1 when a second contributor joins;
- Require linear history: yes;
- Delete head branches after merge: yes.
### Migration to GitLab — what changes, what doesn't
When the GitLab migration happens (618-month horizon), a new ADR will be written that **supersedes only the level-2 sections** of this ADR. The level-1 decisions stand unchanged. Concretely the migration touches:
- `.gitea/workflows/*.yml``.gitlab-ci.yml` (rewrite — the *gates* are the same, the *DSL* is different). Estimate: 12 days.
- Self-hosted `act_runner` → GitLab Runner. Estimate: 0.51 day.
- Gitea branch protection rules → GitLab merge request approval rules. Same concepts, different UI. Estimate: a few hours.
- Secrets re-creation in GitLab CI/CD variables. Same naming convention, copy values. Estimate: a few hours.
The `package.json` scripts (`ci:check`, `ci:scan`, `ci:commits`), the Nx workspace, the dependency manifest, the entire code surface, and the level-1 decisions are unchanged.
### Consequences
* Good, because the gates are written once and run twice (locally via hooks, in CI as defense). Drift is impossible without breaking both layers.
* Good, because thin YAML keeps CI reproducible locally — anyone can run `pnpm ci:check` to mirror what the runner does.
* Good, because Gitea Actions' GHA-compatible syntax doubles as a hedge: the same YAML can land on GitHub Actions if the org's plans change again.
* Good, because squash-merge produces a Conventional-Commits-only history on `main` — clean changelog generation, predictable release notes, smooth semver inference.
* Good, because branch protection is enforced at the platform level, not at convention level.
* Good, because the migration weight is bounded and transparent: ~35 days of dev/ops work, no code rewrite.
* Bad, because the level-2 sections will need to be rewritten at the GitLab migration. This is the explicit trade-off; it is accepted because the alternatives (going GitLab now without the existing tooling, or staying CI-less until GitLab) cost more.
* Bad, because trunk-based with required-reviewers=0 in v1 leaves the project lead as the sole gatekeeper. Mitigated by mandating green CI (which the lead cannot bypass without rewriting branch protection — a deliberate, audit-visible action).
* Bad, because `act_runner` is younger than GitLab Runner; expect occasional rough edges, especially around large action ecosystems. Mitigated by pinning third-party actions by SHA and cadence-rebuilding the runner image.
* Bad, because deferring signed commits to v2 means the v1 history won't carry attribution-grade signatures. Reasonable for a small team; revisit at GitLab migration.
### Confirmation
* `.gitea/workflows/ci.yml`, `release.yml`, `security-scheduled.yml` exist with the structure above. The `release.yml` may be a stub until the deploy ADR lands.
* `package.json` exposes `ci:check`, `ci:scan`, `ci:commits` scripts. Each is runnable locally and produces the same exit code as the CI job.
* Gitea branch protection on `main` has the rules above; configuration is documented in `docs/operations/branch-protection.md` (created with the scaffold).
* `Trivy`, `gitleaks`, and `commitlint` are in `devDependencies` (or available as actions pinned by SHA — both acceptable).
* At least three `act_runner` instances are registered to the org; their bootstrap and update procedure live in an operations doc.
* CI exits non-zero on any gate failure; no gate is `continue-on-error: true`.
* Runner images are pinned by SHA in the workflows. The `security-scheduled.yml` job rebuilds the runner image weekly and reports to the security audit feed.
* A future migration ADR (GitLab) explicitly references this ADR, supersedes only level 2, and inherits level 1.
## Pros and Cons of the Options
### Branch / merge strategy
#### Trunk-based + squash-merge (chosen)
* Good, because clean linear history on `main`, one squash commit per PR.
* Good, because Conventional Commits + squash-merge yields a directly machine-readable changelog.
* Good, because feature branches stay short-lived — pressure against long-lived branches becomes structural, not cultural.
* Bad, because contributors lose granular commit history on the merged branch (the squash collapses it). Mitigated: the PR retains the full history for review purposes.
#### Trunk-based + rebase-merge
* Good, because preserves individual commits without merge bubbles.
* Bad, because contributors must groom every commit to be CI-clean (each commit must compile and pass tests if we want a clean bisect history). High discipline cost; squash-merge gets most of the benefit at lower cost.
#### Trunk-based + merge commit
* Good, because preserves the full history, including the branch topology.
* Bad, because produces messy merge bubbles on `main`; conflicts with the "linear history" branch protection.
#### GitFlow
* Good, because release branches isolate stabilisation.
* Bad, because heavy for a continuously deployable monorepo; redundant with semver tags on a trunk-based main; introduces the "long-lived `develop` branch" anti-pattern.
### CI engine (level 2)
#### Gitea Actions (chosen)
* Good, because built-in to Gitea, no extra deployment.
* Good, because GitHub Actions syntax means transferable skills and a partial portability hedge.
* Good, because actively developed by the Gitea team.
* Bad, because younger ecosystem than GitLab CI or GitHub Actions proper — expect occasional rough edges.
#### Drone CI
* Good, because mature, lean, opinionated.
* Bad, because separate deployment and operational surface; YAML is Drone-specific (less portable than GHA-compatible Gitea Actions).
#### Concourse / Tekton / Buildkite
* Good, because powerful for complex pipelines.
* Bad, because over-engineered for the v1 scope, and orthogonal to the Gitea/GitLab decision axis.
### Required reviewer count
#### 0 in v1, raise later (chosen)
* Good, because doesn't block solo development.
* Good, because the green-CI requirement still prevents the project lead from merging broken code without effort.
* Bad, because relies on the project lead's discipline (and CI's correctness) for code quality.
#### 1 always
* Good, because rigour.
* Bad, because blocks if there's only one contributor — would force the project lead to merge their own PRs by overriding protection, which is the opposite of the intended behaviour.
### Signed commits
#### Optional in v1, revisited at GitLab migration (chosen)
* Good, because no setup overhead for contributors during a phase where the priority is shipping the structural ADRs and scaffolding.
* Bad, because v1 history carries no attribution-grade signatures. Acceptable: the host (Gitea) records the user identity on each commit.
#### Required in v1
* Good, because rigorous attribution from day one.
* Bad, because every contributor must set up GPG or SSH signing — high friction for early stages.
## More Information
* Gitea Actions: https://docs.gitea.com/usage/actions/overview
* `act_runner`: https://gitea.com/gitea/act_runner
* GitHub Actions reference (compatible with Gitea Actions): https://docs.github.com/actions
* Conventional Commits: https://www.conventionalcommits.org/
* commitlint: https://commitlint.js.org/
* Trivy: https://github.com/aquasecurity/trivy
* gitleaks: https://github.com/gitleaks/gitleaks
* `nrwl/nx-set-shas`: https://github.com/nrwl/nx-set-shas
* Related ADRs: [ADR-0002](0002-adopt-nx-monorepo-apps-preset.md) (Nx workspace + `affected`), [ADR-0007](0007-pre-commit-hooks-and-conventional-commits.md) (local hooks + commitlint config), and the future ADRs for security baseline (Trivy / gitleaks / Renovate config), accessibility baseline (`a11y` gate), performance budgets (`perf` gate), on-prem infrastructure stack (deploy pipeline, runners hosting), and GitLab migration (level-2 supersession).
@@ -0,0 +1,244 @@
---
status: accepted
date: 2026-04-30
decision-makers: R&D Lead
tags: [accessibility, frontend, process]
---
# Accessibility baseline — WCAG 2.2 AA + targeted AAA, Angular CDK + spartan-ng + Tailwind, APF panel testing
## Context and Problem Statement
The host organisation is **APF France Handicap**. Accessibility is the organisation's core mission, not a compliance checkbox. A meaningful proportion of the portal's users will be in situations of disability — visual, motor, cognitive, hearing — and will rely on assistive technologies (screen readers, switch controls, eye tracking, voice control, head pointers, mouth sticks). The portal's accessibility quality is therefore a **product attribute**, not a downstream concern.
The default WCAG 2.2 AA baseline applicable to enterprise portals would *under-serve* this user base. We need to fix:
- the conformance target (WCAG level + targeted enhancements);
- the implementation stack (component library, primitives, styling);
- the user-preferences surface (contrast, text size, motion, etc.) the portal must offer;
- the testing strategy, including manual testing with users from APF's internal network;
- the CI gates that prevent regression;
- the legal-disclosure surface (the EU Accessibility Actrequired accessibility statement).
This ADR fixes the framework. Concrete component-by-component implementation lands as the scaffold and features arrive.
## Decision Drivers
* APF's mission elevates accessibility from "compliance" to "product".
* European Accessibility Act (EAA, in application since June 2025) makes WCAG 2.2 AA a legal floor in the EU; the portal will publish an accessibility statement and accept regression as a defect, not as a backlog item.
* RGAA 4.1 (French national standard, derived from WCAG) is the operational reference for accessibility audits in France; alignment is necessary to be auditable by French experts (including APF's own).
* The user base extends beyond screen-reader users: switch controls, eye tracking, voice control, cognitive load tolerance — all must be considered.
* The technical stack is locked on Angular ([ADR-0004](0004-frontend-stack-angular-csr-zoneless-signals.md)). Component-library choice must therefore be Angular-native or framework-agnostic.
* The dual-audience design ([ADR-0008](0008-identity-model-entra-workforce-dual-audience.md)) means a11y must be uniform across audiences — workforce and (future) customer users alike.
* Anti-bricolage: no hand-rolled a11y where a maintained library exists.
## Considered Options
### Conformance target
* WCAG 2.2 **AA** baseline (EU EAA legal floor).
* WCAG 2.2 **AA + targeted AAA** on criteria with high impact for APF's user base. (Chosen.)
* WCAG 2.2 **AAA** uniform.
### UI component stack
* Angular Material (Angular CDK + Material visual layer).
* **Angular CDK + spartan-ng + Tailwind.** (Chosen — see also `notes/argumentaire-stack-ui-spartan-cdk-tailwind.md` for the team-internal rationale.)
* Custom-on-Angular-CDK only (no spartan-ng).
* React-ecosystem libs (shadcn/ui, dice-ui, animate-ui).
### User-preferences scope
* None (rely on browser/OS prefs).
* `prefers-reduced-motion` only.
* **Full preferences panel** — contrast modes, text size, motion, spacing, simplified UI. (Chosen.)
### Testing strategy
* Automated only (axe-core + lint).
* Automated + external annual audit.
* **Automated + manual screen-reader/keyboard/switch testing + APF user panel cadence + annual internal audit.** (Chosen.)
### Documentation surface
* Internal docs only.
* **Public accessibility statement page (legal EAA requirement) + internal patterns library.** (Chosen.)
## Decision Outcome
### Conformance target
**WCAG 2.2 AA** as the universal baseline. **AAA** applied on the criteria that materially affect APF's user base:
| AAA criterion | What it adds vs AA | Why APF |
| --- | --- | --- |
| **1.4.6 Contrast (Enhanced)** | 7:1 normal text / 4.5:1 large text (vs 4.5:1 / 3:1 at AA) | Low-vision users are a primary audience |
| **2.2.3 No Timing** | No time limits except where essential | Cognitive load and motor speed vary widely |
| **2.3.3 Animation from Interactions** | All interaction-driven animation can be disabled | Vestibular and cognitive disorders |
| **3.1.5 Reading Level** | Content readable at lower secondary level *or* a simpler alternative is provided | Cognitive accessibility |
| **1.4.8 Visual Presentation** | User control over text formatting (line spacing, paragraph spacing, line length) | Dyslexia, low vision |
| **2.4.9 Link Purpose (Link Only)** | Each link's purpose understandable from its text alone (no surrounding context) | Screen-reader navigation by link list |
| **3.3.5 Help** | Context-sensitive help available where needed | Cognitive accessibility |
The remaining AAA criteria (e.g. 1.2.6 Sign Language, 1.2.8 Media Alternative) are **not** uniformly required — they apply where the corresponding content type appears, and are addressed case by case. **RGAA 4.1** alignment is maintained as the audit reference for French experts (APF, CNIL audits, public-sector partners).
### User-preferences panel
The portal exposes a **first-class user-preferences panel** accessible from any page (keyboard-discoverable, screen-reader-announced, persisted in the user's session per [ADR-0010](0010-session-management-redis.md)):
| Preference | Options | Default |
| --- | --- | --- |
| Contrast mode | Standard (AA-compliant) / Enhanced (AAA, 7:1) / Maximum (high-contrast OS-style, monochrome) | Standard |
| Text size | 100 % / 125 % / 150 % / 175 % / 200 % | 100 % |
| Motion | Full / Reduced / None | inherited from `prefers-reduced-motion` |
| Text spacing | Default / Generous (line-spacing 1.8, paragraph-spacing 2×, letter-spacing 0.12em) | Default |
| Cognitive simplification | Off / On (reduces secondary chrome, larger primary actions, easier-to-read summaries when authored) | Off |
| Reading focus | Off / Line-by-line | Off |
Preferences are saved per user; an unauthenticated default is read from browser preferences (`prefers-reduced-motion`, `prefers-contrast`, etc.). Changing a preference takes effect immediately, no page reload.
### Component stack
**Angular CDK + spartan-ng + TailwindCSS**. See [`notes/argumentaire-stack-ui-spartan-cdk-tailwind.md`](../notes/argumentaire-stack-ui-spartan-cdk-tailwind.md) for the full rationale produced for the dev team. Summary:
- **Angular CDK** (`@angular/cdk`) — primitives accessibility layer maintained by Google's Angular Material team. Equivalent role to Radix UI in React. Provides `Overlay`, `FocusTrap`, `FocusMonitor`, `LiveAnnouncer`, `A11yModule`, `ListKeyManager`, `Drag and Drop`, etc. Battle-tested since 2017.
- **spartan-ng** (https://www.spartan.ng/) — copy-paste component layer built on Angular CDK, styled with Tailwind. Components are checked into our repo (`libs/shared/ui/`) and modified freely. Equivalent role to shadcn/ui in React.
- **Tailwind CSS** — utility-first styling. Tokens for colour, spacing, contrast tiers live in `libs/shared/tokens/`. The contrast tiers map directly to the user preference modes above.
Angular Material is **rejected** because its visual design system (Material Design) is opinionated in ways that clash with both APF branding and the multi-tier contrast requirements. React-ecosystem libraries (shadcn/ui, dice-ui, animate-ui) are rejected as incompatible with the Angular stack ([ADR-0004](0004-frontend-stack-angular-csr-zoneless-signals.md)).
### Concrete patterns to enforce
| Concern | Rule |
| --- | --- |
| Sémantique HTML | `<header>`, `<main>`, `<nav>`, `<aside>`, `<footer>` landmarks. One `h1` per page. `<button>` for actions, `<a>` for navigation. **No** `<div onclick>` — flagged by ESLint and rejected at PR review |
| Skip-links | "Aller au contenu principal" link as the first focusable element on every page |
| Focus | Always visible (no `outline: none` without an at-least-equivalent replacement). Trapped in modals via Angular CDK `FocusTrap`. Restored after navigation/modal close |
| Forms | Each `<input>` has an associated `<label>`. Errors associated via `aria-describedby`. `autocomplete` attribute correct. Valid HTML5 `type` |
| Touch targets | Minimum **44 × 44 CSS pixels** (WCAG 2.5.5 AAA), default 48 × 48 in spartan-ng tokens for safety |
| Animations | Respect `prefers-reduced-motion` and the user-preferences `motion` setting. No autoplay video. No carousels with auto-rotation |
| Language | `lang` attribute on `<html>` set dynamically per active locale. Inline `lang` on foreign-language fragments |
| Images | All non-decorative images carry meaningful `alt`. Decorative images carry `alt=""` and `role="presentation"` |
| ARIA | Used **only** when native HTML semantics are insufficient. Misuse of ARIA (the most common a11y bug) is treated as a code-quality defect |
| Error messaging | Programmatic association (`aria-describedby`), live regions (`aria-live="polite"`) for server errors, never just colour to convey state |
### Tooling and CI gates
| Layer | Tool | Where it runs | Blocking? |
| --- | --- | --- | --- |
| Static lint on Angular templates | `@angular-eslint/template/*` rules (no-positive-tabindex, click-events-have-key-events, label-has-associated-control, elements-content, no-autofocus, alt-text, valid-aria, button-has-type, …) | local hook + CI (`pnpm ci:check`, [ADR-0015](0015-cicd-gitea-actions.md)) | **Yes** |
| Automated runtime a11y check | `@axe-core/playwright` integrated in e2e tests | CI (`pnpm ci:quality` — the `a11y` gate of [ADR-0015](0015-cicd-gitea-actions.md)) | **Yes** on `critical` or `serious` violations; warning on `moderate`/`minor` |
| Design-token contrast verification | Custom script that walks `libs/shared/tokens/` colour pairs and verifies WCAG AA + AAA contrast ratios | CI | **Yes** |
| Touch-target check | Layout test verifying interactive element minimum size | CI (Playwright) | **Yes** on min 44 × 44 |
| Motion respect check | E2E test that turns motion off and verifies no animation occurs | CI (Playwright) | **Yes** |
| Screen-reader manual testing | NVDA (Windows), VoiceOver (macOS, iOS), TalkBack (Android) | Manual, before merge of any new component or major refactor | **Yes** — checklist in PR template |
| Keyboard-only navigation | Manual | Manual, before merge of any UI change | **Yes** — checklist in PR template |
| Switch-control / eye-tracking | Manual, on flagship features | APF user panel cadence | **Yes** as gate for major releases |
| Cognitive walkthrough | Manual, with user representatives | APF user panel cadence | **Yes** as gate for major releases |
### Manual testing — APF user panel
APF France Handicap has, by design, an active network of users in situations of disability. This is a structural advantage that no consultant can replicate.
- **Cadence** : panel-tested before each major release. Composition: at least one user per primary disability category (visual, motor, cognitive, hearing) drawn from the APF network.
- **Scope** : critical user journeys (sign-in, navigation, integrated apps, user-preferences panel, error-recovery flows) plus any new flagship feature.
- **Format** : moderated session, observation-based, recorded with consent. Findings categorised (blocker / serious / moderate / minor) and triaged like security findings — blockers and serious must be fixed before release.
- **Authority** : a finding by a user-panel session is at least as authoritative as an axe-core report. Disagreements escalate to the R&D lead.
### Documentation
- **Accessibility statement page** at `/accessibility` (English) and `/accessibilite` (French) — required by EAA. Content: conformance level claimed, scope (which routes are covered), known issues with target resolution dates, contact email for accessibility complaints, date of last review, RGAA alignment level. Generated from a maintained source-of-truth file in the repo (e.g. `apps/portal-shell/src/accessibility-statement.md`) so the statement evolves with the code, not separately.
- **Internal patterns library** in `docs/accessibility/` (created when first patterns ship) covering: focus management, ARIA usage, form patterns, error messaging, multi-step flows, modals, notifications. Examples drawn from the codebase, kept in sync via review.
- **Component library docs** : every spartan-ng component checked into `libs/shared/ui/` carries a11y notes (keyboard model, ARIA roles emitted, screen-reader expectations).
### Consequences
* Good, because a11y is structural — encoded in the lint, the gates, the components, the tokens, the user-preferences panel — rather than relying on individual discipline.
* Good, because the chosen stack (CDK + spartan-ng + Tailwind) gives full design control without forfeiting Google-tier a11y primitives, and supports the multi-tier contrast/text-size/motion modes without contortion.
* Good, because the AA + targeted AAA approach concentrates effort where APF's user base benefits most, instead of spreading thin across every AAA criterion.
* Good, because access to APF's user network is a competitive advantage — the panel testing is more rigorous than what most enterprise projects can afford.
* Good, because the accessibility statement and the patterns library make the project's a11y posture *legible* — auditable by external experts, demonstrable to APF stakeholders.
* Bad, because the upfront cost is non-trivial — design tokens with multi-tier contrast, components with theming, user-preferences panel, e2e a11y tests. Estimated +2535 % of UI delivery time vs. an a11y-as-afterthought approach. Acknowledged and accepted given APF's mission.
* Bad, because spartan-ng is younger than Angular Material — occasional rough edges expected. Mitigated by the copy-paste model: components live in our repo, we own them, fallback is custom-on-CDK without lib dependency.
* Bad, because the user-preferences panel must be wired through every component — components that don't honour the active contrast mode or the active text size break the contract. Enforced by review and a custom lint rule per component.
* Bad, because manual testing cadence (panel sessions, screen-reader, keyboard) is real ops effort. Mitigated by APF's internal network; expense kept inside the org.
### Confirmation
* `libs/shared/tokens` exposes design tokens for colour (across at least three contrast tiers), spacing, typography, motion. Token contrast ratios are verified by a CI script.
* `libs/shared/ui` hosts spartan-ng components, copied in and themed against the tokens. Each component carries an a11y note in its docs.
* Angular CDK is the only allowed source of `Overlay`, `FocusTrap`, `FocusMonitor`, `LiveAnnouncer`, `Drag and Drop`. No equivalent rolled by hand.
* `apps/portal-shell` ships a user-preferences panel persisting to the session (Redis); changes apply without reload; default reads from `prefers-*` browser media queries.
* ESLint configuration enables every `@angular-eslint/template/*` a11y rule. CI fails on any violation.
* `@axe-core/playwright` is integrated in the e2e suite. CI's `a11y` gate fails on any `critical` or `serious` violation, warns on `moderate` or `minor`.
* Design-token contrast verifier and touch-target verifier are part of `pnpm ci:quality`.
* PR template includes a manual-checklist section: keyboard navigation tested, screen reader tested (which one), focus order verified, error states verified.
* Accessibility statement page is published, referenced from the footer, and updated at every major release.
* APF user-panel session findings are tracked as issues with a label `a11y/panel-finding` and triaged with the same gravity as security issues.
## Pros and Cons of the Options
### Conformance target
#### AA + targeted AAA (chosen)
* Good, because matches the user base where it matters and stays achievable on a uniform basis.
* Good, because the AAA criteria selected are the ones with measurable user-impact in APF's primary populations.
* Bad, because the selection is judgement-based — open to debate at audit. Mitigated by documenting the rationale per criterion (this ADR).
#### AA only
* Good, because the EU EAA legal floor.
* Bad, because clearly insufficient given APF's mission. Bare AA on contrast (4.5:1) is uncomfortable for a low-vision user; AA on motion is permissive.
#### AAA uniform
* Good, because the highest WCAG level on every criterion.
* Bad, because some AAA criteria (1.2.6 Sign Language, 1.2.8 Media Alternative, 1.4.9 Images of Text) require content production or content forms we may not have. Pretending uniform AAA when not really delivered is worse than an honest AA + targeted AAA.
### UI component stack
#### Angular CDK + spartan-ng + Tailwind (chosen)
* Good, because Google-tier a11y primitives via CDK + copy-paste components via spartan-ng + utility CSS via Tailwind = full control, no design-system lock-in, no proprietary visuals to fight.
* Good, because aligns with the team-internal preference for the shadcn-style pattern (per dev consultation), without having to break the Angular alignment with NestJS.
* Bad, because spartan-ng is younger (2024). Risk mitigated by the copy-paste model — we own the code.
#### Angular Material
* Good, because most mature, Google-maintained.
* Bad, because the Material Design visual language is opinionated and clashes with multi-tier contrast modes and APF branding. Switching themes within Material is harder than switching themes when the components are owned in source.
#### Custom-on-Angular-CDK only
* Good, because zero dependency beyond Google.
* Bad, because every component (button, dialog, tabs, menu, etc.) must be built from CDK primitives — large upfront cost.
#### React-ecosystem libs
* Bad, because incompatible with Angular ([ADR-0004](0004-frontend-stack-angular-csr-zoneless-signals.md)).
### Testing strategy
#### Automated + manual + APF user panel + annual internal audit (chosen)
* Good, because covers the spectrum from regression detection to genuine usability.
* Good, because user panel access is structurally cheaper than what a consultancy would charge.
* Bad, because requires panel scheduling and triage — operational item.
#### Automated only
* Bad, because tools detect ~30 % of real accessibility defects. The remaining 70 % are interaction patterns invisible to static and runtime checks.
#### Automated + external annual audit
* Good, because external perspective.
* Bad, because expensive (515 k€/year for a real audit) and arguably less informed than APF's internal expertise. Reconsidered if APF wants a third-party signature for legal disclosure.
## More Information
* WCAG 2.2 specification: https://www.w3.org/TR/WCAG22/
* WCAG 2.2 quick reference (filterable by level): https://www.w3.org/WAI/WCAG22/quickref/
* European Accessibility Act (EAA): https://ec.europa.eu/social/main.jsp?catId=1202
* RGAA 4.1: https://accessibilite.numerique.gouv.fr/
* APF France Handicap: https://www.apf-francehandicap.org/
* Angular CDK A11y: https://material.angular.io/cdk/a11y/overview
* spartan-ng: https://www.spartan.ng/
* `@angular-eslint`: https://github.com/angular-eslint/angular-eslint
* `@axe-core/playwright`: https://github.com/dequelabs/axe-core-npm/tree/develop/packages/playwright
* Inclusive Components: https://inclusive-components.design/
* Related ADRs: [ADR-0004](0004-frontend-stack-angular-csr-zoneless-signals.md) (Angular stack), [ADR-0010](0010-session-management-redis.md) (preferences persistence), [ADR-0015](0015-cicd-gitea-actions.md) (CI gates `a11y` and `perf`), and the future ADRs for performance budgets, security baseline (ASVS L3 implications cross-cut a11y in some areas — e.g. step-up MFA must remain a11y-conformant), and on-prem infrastructure.
@@ -0,0 +1,260 @@
---
status: accepted
date: 2026-04-30
decision-makers: R&D Lead
tags: [performance, frontend, backend, process]
---
# Performance budgets — Core Web Vitals + Lighthouse CI gates, bundle budgets, BFF p95/p99 SLOs
## Context and Problem Statement
The portal is a CSR Angular SPA ([ADR-0004](0004-frontend-stack-angular-csr-zoneless-signals.md)) — without SSR, perceived performance depends entirely on the JS payload, the rendering path, and the BFF response latency. The host organisation context ([ADR-0016](0016-accessibility-baseline-wcag-aa-targeted-aaa.md)) elevates performance further: users on assistive technologies (screen readers, switch controls, eye tracking) are particularly affected by slow or jumpy interfaces; cognitive-disability tolerance for delayed feedback is reduced.
We need to fix:
- which metrics we track;
- which thresholds bound them;
- which tooling enforces them in CI and in production;
- which gates block merges vs. surface warnings;
- how BFF latency is bounded and observed.
This ADR fixes the framework. Concrete optimisation work happens at feature delivery time; this ADR ensures the bar exists and is enforced.
## Decision Drivers
* CSR-only ([ADR-0004](0004-frontend-stack-angular-csr-zoneless-signals.md)) — perceived perf is entirely client-side load + execution.
* a11y context ([ADR-0016](0016-accessibility-baseline-wcag-aa-targeted-aaa.md)) — perf failure modes hurt the APF user base disproportionately.
* Industry-standard tooling, anti-bricolage.
* Same observability stack as [ADR-0012](0012-observability-pino-opentelemetry.md) — no parallel telemetry path.
* Cohérence with [ADR-0015](0015-cicd-gitea-actions.md) — the `perf` gate slot was reserved for this ADR.
## Considered Options
### Metrics scope
* **Google Core Web Vitals (LCP, INP, CLS) + supplementary (TBT, TTFB) + bundle size.** (Chosen.)
* Custom metrics only (rejected — premature, not benchmarkable across the industry).
* Lighthouse Performance score only (insufficient on its own — score-as-only-metric is gameable).
### Front-end tooling
* **Lighthouse CI (`@lhci/cli`)** for full audit + score, plus Angular `budgets` for bundle size enforcement at build. (Chosen.)
* WebPageTest API.
* Calibre, Speedlify (à la pointe but less mature for CI integration).
### Threshold values
* **Google "Good" Core Web Vitals thresholds + Lighthouse Performance ≥ 90.** (Chosen.)
* Stricter (e.g. LCP ≤ 2 s, score ≥ 95) — rejected as too flaky in CI runners.
* Looser (matches "Needs Improvement") — rejected as insufficient.
### Bundle budgets
* **Angular `budgets` in `project.json`, type `error`, blocking the build on overshoot.** (Chosen.)
* No budget (rejected — invariably leads to silent bloat).
### Back-end SLOs
* **Per-endpoint-family p95/p99 budgets, observed via OTel spans (already in place by [ADR-0012](0012-observability-pino-opentelemetry.md)), enforced at review and in scheduled reports.** (Chosen.)
* No back-end perf budget.
### Real-user monitoring (RUM)
* **None in v1** — rely on OTel server-side spans + Lighthouse CI in CI + scheduled prod Lighthouse runs. (Chosen.)
* Front-side RUM SDK (Sentry, Datadog Browser, custom OTel-Web RUM).
## Decision Outcome
### Metrics and thresholds (front-end)
Core Web Vitals — Google "Good" thresholds, measured by Lighthouse CI:
| Metric | Threshold | Source |
| --- | --- | --- |
| **LCP** (Largest Contentful Paint) | ≤ **2.5 s** | https://web.dev/lcp/ |
| **INP** (Interaction to Next Paint) | ≤ **200 ms** | https://web.dev/inp/ — replaces FID since March 2024 |
| **CLS** (Cumulative Layout Shift) | ≤ **0.1** | https://web.dev/cls/ |
| **TBT** (Total Blocking Time, lab proxy of INP) | ≤ **200 ms** | https://web.dev/tbt/ |
| **TTFB** (Time to First Byte) | ≤ **800 ms** | https://web.dev/ttfb/ |
| **Lighthouse Performance score** | ≥ **90** on critical routes | Lighthouse 12+ |
Bundle budgets (`apps/portal-shell/project.json`, Angular `budgets` array, `type: "error"` — blocking at `nx build`):
| Bundle | Budget |
| --- | --- |
| Initial bundle (gzip) | ≤ **300 KB** |
| Any lazy chunk (gzip) | ≤ **100 KB** |
| Per-component CSS | ≤ **6 KB** |
| Total stylesheet | ≤ **150 KB** |
These values are deliberately conservative for a zoneless + Signals Angular setup, which is compact by construction. Actual measurements may show we have headroom; the budget can be tightened (never loosened without ADR amendment) at quarterly review.
### Critical routes for Lighthouse CI
Lighthouse CI runs against a curated list of routes that represent the user journey:
- `/auth/login` — sign-in landing, first-impression critical;
- `/` — post-auth home/dashboard;
- `/accessibility` and `/accessibilite` — must themselves be perf-and-a11y exemplary (they are the portal's public proof);
- a flagship route per integrated feature, added as features land (one route per feature lib).
Each route is scored against the thresholds above. **Failure on any threshold is blocking** — the merge is rejected.
### Where Lighthouse CI runs
| Environment | Cadence | Purpose | Blocking? |
| --- | --- | --- | --- |
| **CI on every PR** | per push | Catch regressions before merge | **Yes** |
| **CI scheduled (weekly)** on prod env | cron in `security-scheduled.yml` from [ADR-0015](0015-cicd-gitea-actions.md), extended to cover perf | Detect drift / regressions in real environment | Reports as alerts; doesn't block, but triggers triage |
| **Local dev** | manual via `pnpm nx run portal-shell:lighthouse` | Developer-side feedback | Non-blocking |
### Variability mitigation
Lighthouse scores fluctuate ±25 points run-to-run on the same code due to runner variance. The CI configuration:
- runs **3 iterations** per route and uses the **median** (configurable via `lighthouserc.js`);
- uses a fixed runtime profile (CPU throttling, network throttling matching "Slow 4G" baseline) so scores are comparable across runs and across machines;
- pins the Lighthouse version per release of the runner image (avoids score shifts from Lighthouse updates).
### Bundle analysis — diagnosis tooling
`source-map-explorer` is wired as an Nx target (`pnpm nx run portal-shell:analyze`) to investigate budget breaches when they happen. Not part of CI — diagnostic tool only. The CI signal is the budget failure; `analyze` tells the developer **where** the weight comes from.
### Back-end perf budgets
Per-endpoint-family p95 / p99 SLOs, observed via the OpenTelemetry span data already produced by [ADR-0012](0012-observability-pino-opentelemetry.md). Initial budgets:
| Endpoint family | p95 | p99 |
| --- | --- | --- |
| `GET /auth/me` | 50 ms | 150 ms |
| `GET /auth/login` (redirect) | 80 ms | 200 ms |
| `GET /auth/callback` (token exchange) | 600 ms | 1500 ms — bound by Entra |
| Read endpoints (DB-backed, simple) | 80 ms | 250 ms |
| Read endpoints (DB-backed, complex) | 300 ms | 800 ms |
| Write endpoints | 200 ms | 600 ms |
| Downstream-API-orchestrating endpoints | bound by downstream + 50 ms BFF overhead | bound by downstream + 200 ms BFF overhead |
These budgets are **not** enforced as hard CI gates — load profile in CI is unrepresentative. They are enforced as:
- **alerting thresholds** in the production observability backend (chosen in the future infrastructure ADR);
- **review-time signals** on PRs that touch hot paths (the OTel data from staging informs the reviewer);
- **quarterly perf review** — tightened or loosened with ADR amendment.
### Performance regressions are bugs, not backlog
A regression on any of the front-end gates blocks the merge. A regression on a back-end SLO that surfaces in production (alert fires) is triaged with the same priority as a security finding — root-caused, fixed, post-mortemed if it took the SLO out for more than 24 h.
### a11y / perf trade-off
Performance optimisation must not hurt accessibility. Some specific patterns to avoid:
- skipping FOUC prevention (briefly unstyled content disorients users with cognitive disabilities);
- removing focus-visible polyfills "for bundle size";
- aggressive lazy-loading that delays critical interactive elements;
- compression of imagery to the point of losing visual clarity required by low-vision users.
When the perf budget and the a11y bar conflict, **a11y wins** — the perf budget is then re-evaluated at the next quarterly review with the data point.
### CI integration
`package.json` script:
```jsonc
"scripts": {
"ci:perf": "pnpm exec nx build portal-shell --configuration=production && pnpm exec lhci autorun --config=./lighthouserc.js"
}
```
`lighthouserc.js` (illustrative, lands with the scaffold) declares:
- the URLs to test (the critical-routes list);
- 3 iterations, median report;
- assertions matching the thresholds in this ADR;
- upload target = local filesystem in CI (HTML reports kept as build artefacts).
The `perf` gate from [ADR-0015](0015-cicd-gitea-actions.md) is now wired and blocking.
### RUM strategy
No browser-side RUM SDK in v1. The OTel browser tracing from [ADR-0012](0012-observability-pino-opentelemetry.md) provides distributed-trace data for full user actions; combined with the Lighthouse CI scheduled prod runs and the BFF OTel spans, this gives sufficient signal to detect regressions. A v2 ADR will revisit if a real RUM tool is needed (concrete trigger: incidents that staging + Lighthouse + OTel didn't catch).
### Consequences
* Good, because performance discipline is structural — every PR is measured against the same bar, on every route that matters.
* Good, because Core Web Vitals + Lighthouse CI is the industry-standard combo; results are comparable to public benchmarks and easy to explain to non-developers.
* Good, because Angular `budgets` blocks bundle bloat at build time, before it ever reaches a user.
* Good, because the BFF SLOs reuse the OTel pipeline already shipped by [ADR-0012](0012-observability-pino-opentelemetry.md) — no parallel telemetry.
* Good, because the explicit a11y/perf trade-off rule prevents "performance regressions" from being introduced under the guise of optimisation.
* Bad, because Lighthouse score variability (±25 points) is real; CI must mitigate it with median-of-3 runs and pinned tooling. Otherwise gates flake.
* Bad, because tightening budgets at quarterly review requires discipline — quarterly meetings to actually happen, data to be reviewed, ADRs to be amended. Mitigated by making it a recurring calendar item.
* Bad, because the back-end SLOs are advisory in CI (not enforced) — the only enforcement is in production alerting, which means a hot-path regression can ship and only fire later. Acknowledged: enforcing perf in CI requires a representative load profile we don't have.
### Confirmation
* `lighthouserc.js` exists at the repo root with the critical-routes list, the assertions matching the thresholds above, and a 3-iteration median configuration.
* `apps/portal-shell/project.json` declares `budgets` of type `"error"` with the values above.
* `package.json` exposes `ci:perf`, runnable locally with the same exit code as CI.
* CI's `perf` gate from [ADR-0015](0015-cicd-gitea-actions.md) calls `pnpm ci:perf` and is blocking.
* `apps/portal-shell` exposes a Nx target `analyze` invoking `source-map-explorer` against the production build's source maps.
* The BFF p95/p99 budgets are documented in `apps/portal-bff/README.md` and translate into alert rules in the production observability backend (configured per future infrastructure ADR).
* The `security-scheduled.yml` workflow includes a weekly Lighthouse CI run against the prod URL set, with reports uploaded as build artefacts and an alert on any threshold breach.
* Quarterly performance review is on the team calendar; the agenda includes (a) review of the past quarter's reports, (b) decision to tighten budgets if achievable, (c) ADR amendment if budgets are adjusted.
## Pros and Cons of the Options
### Metrics scope
#### Google Core Web Vitals + supplementary + bundle size (chosen)
* Good, because aligned with the industry, comparable to public benchmarks, well-documented.
* Good, because INP captures real user interaction latency (replaces FID since March 2024).
* Good, because bundle size is the leading indicator that catches issues before they manifest as bad CWV.
#### Custom metrics only
* Bad, because un-benchmarkable, easy to be wrong about. Premature.
#### Lighthouse score only
* Bad, because the score is a weighted aggregate; gameable, hides which metric is failing. Use it as a top-line confirmation, not as the only metric.
### Front-end tooling
#### Lighthouse CI (chosen)
* Good, because mature, free, the standard for automated CWV in CI.
* Good, because integrates trivially with any CI runner; uploads HTML reports as build artefacts for triage.
* Bad, because score variability needs mitigation (median of 3, pinned version) — covered.
#### WebPageTest API
* Good, because more granular network/profile control.
* Bad, because requires a paid account or self-hosted instance; less plug-and-play in CI.
#### Calibre / Speedlify
* Good, because modern, well-presented dashboards.
* Bad, because younger / less established than Lighthouse CI; reconsidered later if Lighthouse CI proves insufficient.
### Threshold values
#### Google "Good" + Lighthouse ≥ 90 (chosen)
* Good, because the de facto industry baseline; exceeding it is a clear positive signal.
* Bad, because not the strictest possible — a brand whose mission is accessibility might warrant tighter values. Mitigated by quarterly tightening.
#### Stricter (LCP ≤ 2 s, score ≥ 95)
* Good, because reflects an a11y-first mission.
* Bad, because flaky in CI runners — false negatives cost more than the marginal user benefit, especially given runner variance.
### RUM
#### None in v1 (chosen)
* Good, because zero v1 ops surface; no privacy disclosure to make about browser-side data collection.
* Bad, because real-user perf data is genuinely useful. Acceptable to defer because Lighthouse CI scheduled prod runs cover the gap.
#### RUM SDK (Sentry, Datadog Browser)
* Good, because real data, real users, real distribution.
* Bad, because adds a runtime SDK to the SPA, sends data to a third party (or our own collector with cost), and requires a privacy-disclosure update to the accessibility/legal pages.
## More Information
* Web Vitals overview: https://web.dev/vitals/
* Core Web Vitals (Google): https://web.dev/articles/vitals
* Lighthouse CI: https://github.com/GoogleChrome/lighthouse-ci
* Angular budgets: https://angular.dev/tools/cli/build#configuring-size-budgets
* `source-map-explorer`: https://github.com/danvk/source-map-explorer
* Related ADRs: [ADR-0004](0004-frontend-stack-angular-csr-zoneless-signals.md) (Angular stack — CSR perf profile), [ADR-0012](0012-observability-pino-opentelemetry.md) (OTel — basis for BFF SLOs), [ADR-0015](0015-cicd-gitea-actions.md) (CI gates — `perf` slot now wired), [ADR-0016](0016-accessibility-baseline-wcag-aa-targeted-aaa.md) (a11y / perf trade-off rule).
+3
View File
@@ -58,3 +58,6 @@ ADRs are listed in numerical order. To slice by topic, filter on the `Tags` colu
| [0012](0012-observability-pino-opentelemetry.md) | Observability — Pino structured logs + OpenTelemetry tracing, W3C Trace Context propagation, stdout + collector | accepted | `observability`, `backend`, `frontend` | 2026-04-29 | | [0012](0012-observability-pino-opentelemetry.md) | Observability — Pino structured logs + OpenTelemetry tracing, W3C Trace Context propagation, stdout + collector | accepted | `observability`, `backend`, `frontend` | 2026-04-29 |
| [0013](0013-audit-trail-separated-postgres-append-only.md) | Audit trail — separated append-only Postgres schema, decoupled from app logs | accepted | `security`, `observability`, `data` | 2026-04-29 | | [0013](0013-audit-trail-separated-postgres-append-only.md) | Audit trail — separated append-only Postgres schema, decoupled from app logs | accepted | `security`, `observability`, `data` | 2026-04-29 |
| [0014](0014-downstream-api-access-obo-pattern.md) | Downstream API access — On-Behalf-Of pattern, unified `DownstreamApiClient`, audience-aware authorization | accepted | `security`, `backend` | 2026-04-29 | | [0014](0014-downstream-api-access-obo-pattern.md) | Downstream API access — On-Behalf-Of pattern, unified `DownstreamApiClient`, audience-aware authorization | accepted | `security`, `backend` | 2026-04-29 |
| [0015](0015-cicd-gitea-actions.md) | CI/CD pipeline — Gitea Actions, trunk-based + squash-merge, thin YAML over portable scripts | accepted | `infrastructure`, `process` | 2026-04-30 |
| [0016](0016-accessibility-baseline-wcag-aa-targeted-aaa.md) | Accessibility baseline — WCAG 2.2 AA + targeted AAA, Angular CDK + spartan-ng + Tailwind, APF panel testing | accepted | `accessibility`, `frontend`, `process` | 2026-04-30 |
| [0017](0017-performance-budgets-lighthouse-ci.md) | Performance budgets — Core Web Vitals + Lighthouse CI gates, bundle budgets, BFF p95/p99 SLOs | accepted | `performance`, `frontend`, `backend`, `process` | 2026-04-30 |
+9 -9
View File
@@ -1,10 +1,10 @@
# 🏗️ Bootstrap the Nx monorepo for adastra-portal # 🏗️ Bootstrap the Nx monorepo for apf-portal
> **Status.** Aligned with phase-1 ADRs ([0002](../../decisions/0002-adopt-nx-monorepo-apps-preset.md), [0003](../../decisions/0003-workspace-and-app-naming-convention.md), [0004](../../decisions/0004-frontend-stack-angular-csr-zoneless-signals.md), [0005](../../decisions/0005-backend-stack-nestjs.md), [0006](../../decisions/0006-persistence-postgresql-prisma.md)). Replaces the original placeholder-based draft. > **Status.** Aligned with phase-1 ADRs ([0002](../../decisions/0002-adopt-nx-monorepo-apps-preset.md), [0003](../../decisions/0003-workspace-and-app-naming-convention.md), [0004](../../decisions/0004-frontend-stack-angular-csr-zoneless-signals.md), [0005](../../decisions/0005-backend-stack-nestjs.md), [0006](../../decisions/0006-persistence-postgresql-prisma.md)). Replaces the original placeholder-based draft.
## Goal ## Goal
Scaffold the `adastra-portal` Nx monorepo containing: Scaffold the `apf-portal` Nx monorepo containing:
- `apps/portal-shell` — Angular SPA frontend (CSR, standalone, zoneless, Signals, Vitest, SCSS, strict TS). - `apps/portal-shell` — Angular SPA frontend (CSR, standalone, zoneless, Signals, Vitest, SCSS, strict TS).
- `apps/portal-bff` — NestJS BFF backend (Express adapter, global `ValidationPipe`, Jest test runner). - `apps/portal-bff` — NestJS BFF backend (Express adapter, global `ValidationPipe`, Jest test runner).
@@ -19,7 +19,7 @@ Out of scope here (covered by later phases): authentication (Entra ID), sessions
- WSL + Zsh — see [01-wsl-terminal-setup.md](01-wsl-terminal-setup.md). - WSL + Zsh — see [01-wsl-terminal-setup.md](01-wsl-terminal-setup.md).
- Node.js latest LTS + pnpm via corepack — see [02-dev-web-stack.md](02-dev-web-stack.md). - Node.js latest LTS + pnpm via corepack — see [02-dev-web-stack.md](02-dev-web-stack.md).
- Working tree under the WSL filesystem (`~/dev/`, `~/Works/`), never `/mnt/c/`. - Working tree under the WSL filesystem (`~/dev/`, `~/Works/`), never `/mnt/c/`.
- The repository `adastra-portal` already exists locally with the ADRs and docs committed (you are reading one of them). - The repository `apf-portal` already exists locally with the ADRs and docs committed (you are reading one of them).
--- ---
@@ -30,21 +30,21 @@ The repository already carries documentation, ADRs, `.vscode/`, and `.gitignore`
```bash ```bash
# 1. Generate a clean Nx workspace next to the repo # 1. Generate a clean Nx workspace next to the repo
cd ~/dev # or any scratch location outside the repo cd ~/dev # or any scratch location outside the repo
pnpm dlx create-nx-workspace@latest adastra-portal-bootstrap \ pnpm dlx create-nx-workspace@latest apf-portal-bootstrap \
--preset=apps \ --preset=apps \
--pm=pnpm \ --pm=pnpm \
--ci=skip \ --ci=skip \
--useGitHub=false --useGitHub=false
# 2. Copy the workspace files into the existing repo # 2. Copy the workspace files into the existing repo
cd adastra-portal-bootstrap cd apf-portal-bootstrap
cp -v package.json pnpm-workspace.yaml nx.json tsconfig.base.json \ cp -v package.json pnpm-workspace.yaml nx.json tsconfig.base.json \
.prettierrc .prettierignore .editorconfig \ .prettierrc .prettierignore .editorconfig \
eslint.config.mjs \ eslint.config.mjs \
~/Works/adastra_portal/ ~/Works/apf_portal/
# 3. Drop the scratch workspace # 3. Drop the scratch workspace
cd ~ && rm -rf ~/dev/adastra-portal-bootstrap cd ~ && rm -rf ~/dev/apf-portal-bootstrap
``` ```
The `apps` preset is generic — it does not privilege a runtime ([ADR-0002](../../decisions/0002-adopt-nx-monorepo-apps-preset.md)). Plugins are added in step 2. The `apps` preset is generic — it does not privilege a runtime ([ADR-0002](../../decisions/0002-adopt-nx-monorepo-apps-preset.md)). Plugins are added in step 2.
@@ -52,7 +52,7 @@ The `apps` preset is generic — it does not privilege a runtime ([ADR-0002](../
Verify: Verify:
```bash ```bash
cd ~/Works/adastra_portal cd ~/Works/apf_portal
pnpm install pnpm install
ls nx.json package.json tsconfig.base.json ls nx.json package.json tsconfig.base.json
``` ```
@@ -314,7 +314,7 @@ module.exports = { extends: ['@commitlint/config-conventional'] };
## 9) CI/CD ## 9) CI/CD
The pipeline targets the Gitea repository (`gitea@git.unespace.com:julien/adastra_portal.git`). Pipeline shape (Gitea Actions vs. third-party runner) and branch protection are deferred to phase-3 ADRs (planned: ADR-0017). Locally, the equivalent of the future CI check is: The pipeline targets the Gitea repository (`gitea@git.unespace.com:julien/apf_portal.git`). Pipeline shape (Gitea Actions vs. third-party runner) and branch protection are deferred to phase-3 ADRs (planned: ADR-0017). Locally, the equivalent of the future CI check is:
```bash ```bash
pnpm nx affected -t lint test build pnpm nx affected -t lint test build
+42
View File
@@ -0,0 +1,42 @@
import nx from '@nx/eslint-plugin';
export default [
...nx.configs['flat/base'],
...nx.configs['flat/typescript'],
...nx.configs['flat/javascript'],
{
ignores: ['**/dist', '**/out-tsc'],
},
{
files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
rules: {
'@nx/enforce-module-boundaries': [
'error',
{
enforceBuildableLibDependency: true,
allow: ['^.*/eslint(\\.base)?\\.config\\.[cm]?[jt]s$'],
depConstraints: [
{
sourceTag: '*',
onlyDependOnLibsWithTags: ['*'],
},
],
},
],
},
},
{
files: [
'**/*.ts',
'**/*.tsx',
'**/*.cts',
'**/*.mts',
'**/*.js',
'**/*.jsx',
'**/*.cjs',
'**/*.mjs',
],
// Override or add rules here
rules: {},
},
];
+6
View File
@@ -0,0 +1,6 @@
import type { Config } from 'jest';
import { getJestProjectsAsync } from '@nx/jest';
export default async (): Promise<Config> => ({
projects: await getJestProjectsAsync(),
});
+3
View File
@@ -0,0 +1,3 @@
const nxPreset = require('@nx/jest/preset').default;
module.exports = { ...nxPreset };
+94
View File
@@ -0,0 +1,94 @@
{
"$schema": "./node_modules/nx/schemas/nx-schema.json",
"namedInputs": {
"default": ["{projectRoot}/**/*", "sharedGlobals"],
"production": [
"default",
"!{projectRoot}/.eslintrc.json",
"!{projectRoot}/eslint.config.mjs",
"!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)",
"!{projectRoot}/tsconfig.spec.json",
"!{projectRoot}/jest.config.[jt]s",
"!{projectRoot}/src/test-setup.[jt]s",
"!{projectRoot}/test-setup.[jt]s"
],
"sharedGlobals": []
},
"plugins": [
{
"plugin": "@nx/js/typescript",
"options": {
"typecheck": {
"targetName": "typecheck"
},
"build": {
"targetName": "build",
"configName": "tsconfig.lib.json",
"buildDepsName": "build-deps",
"watchDepsName": "watch-deps"
}
}
},
{
"plugin": "@nx/playwright/plugin",
"options": {
"targetName": "e2e"
}
},
{
"plugin": "@nx/eslint/plugin",
"options": {
"targetName": "lint"
}
},
{
"plugin": "@nx/webpack/plugin",
"options": {
"buildTargetName": "build",
"serveTargetName": "serve",
"previewTargetName": "preview",
"buildDepsTargetName": "build-deps",
"watchDepsTargetName": "watch-deps",
"serveStaticTargetName": "serve-static"
}
},
{
"plugin": "@nx/jest/plugin",
"options": {
"targetName": "test"
},
"exclude": ["apps/portal-bff-e2e/**/*"]
}
],
"analytics": false,
"targetDefaults": {
"@angular/build:application": {
"cache": true,
"dependsOn": ["^build"],
"inputs": ["production", "^production"]
},
"@nx/eslint:lint": {
"cache": true,
"inputs": [
"default",
"^default",
"{workspaceRoot}/.eslintrc.json",
"{workspaceRoot}/.eslintignore",
"{workspaceRoot}/eslint.config.mjs",
"{workspaceRoot}/tools/eslint-rules/**/*"
]
},
"@angular/build:unit-test": {
"cache": true,
"inputs": ["default", "^production"]
}
},
"generators": {
"@nx/angular:application": {
"e2eTestRunner": "playwright",
"linter": "eslint",
"style": "scss",
"unitTestRunner": "vitest-angular"
}
}
}
+72
View File
@@ -0,0 +1,72 @@
{
"name": "apf-portal",
"version": "0.0.0",
"license": "MIT",
"scripts": {},
"private": true,
"devDependencies": {
"@angular-devkit/core": "~21.2.0",
"@angular-devkit/schematics": "~21.2.0",
"@angular/build": "~21.2.0",
"@angular/cli": "~21.2.0",
"@angular/compiler-cli": "~21.2.0",
"@angular/language-service": "~21.2.0",
"@eslint/js": "^9.8.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.0",
"@nx/angular": "^22.7.1",
"@nx/devkit": "22.7.1",
"@nx/eslint": "^22.7.1",
"@nx/eslint-plugin": "22.7.1",
"@nx/jest": "22.7.1",
"@nx/js": "22.7.1",
"@nx/nest": "^22.7.1",
"@nx/node": "22.7.1",
"@nx/playwright": "22.7.1",
"@nx/vite": "^22.7.1",
"@nx/web": "22.7.1",
"@nx/webpack": "22.7.1",
"@oxc-project/runtime": "^0.115.0",
"@playwright/test": "^1.36.0",
"@schematics/angular": "~21.2.0",
"@swc-node/register": "1.11.1",
"@swc/core": "1.15.8",
"@swc/helpers": "0.5.18",
"@types/jest": "^30.0.0",
"@types/node": "20.19.9",
"@typescript-eslint/utils": "^8.40.0",
"angular-eslint": "^21.2.0",
"eslint": "^9.8.0",
"eslint-config-prettier": "^10.0.0",
"eslint-plugin-playwright": "^1.6.2",
"jest": "^30.0.2",
"jest-environment-node": "^30.0.2",
"jest-util": "^30.0.2",
"jsdom": "^27.1.0",
"nx": "22.7.0",
"prettier": "^3.8.1",
"ts-jest": "^29.4.0",
"ts-node": "10.9.1",
"tslib": "^2.3.0",
"typescript": "~5.9.2",
"typescript-eslint": "^8.40.0",
"vitest": "^4.0.8",
"webpack-cli": "^5.1.4"
},
"dependencies": {
"@angular/common": "~21.2.0",
"@angular/compiler": "~21.2.0",
"@angular/core": "~21.2.0",
"@angular/forms": "~21.2.0",
"@angular/platform-browser": "~21.2.0",
"@angular/router": "~21.2.0",
"@nestjs/common": "^11.0.0",
"@nestjs/core": "^11.0.0",
"@nestjs/platform-express": "^11.0.0",
"axios": "^1.6.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"reflect-metadata": "^0.1.13",
"rxjs": "~7.8.0"
}
}
+17211
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
packages:
- 'apps/*'
- 'libs/**'
+28
View File
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"rootDir": ".",
"baseUrl": ".",
"paths": {},
"sourceMap": true,
"declaration": false,
"importHelpers": true,
"isolatedModules": true,
"lib": ["es2022", "dom"],
"module": "esnext",
"moduleResolution": "bundler",
"target": "es2022",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"useDefineForClassFields": false,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noUnusedLocals": true,
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noPropertyAccessFromIndexSignature": true
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.base.json",
"compileOnSave": false,
"files": [],
"references": []
}