Artemis client conventions

SkillDev tools

Applies Artemis coding conventions when editing Angular app code, migrating components, or fixing lint issues.

Available today. Use it from your connected AI after setup.

Add ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.

Then ask your AI: use the Artemis client conventions skill

About this skill

Apply Artemis conventions when changing Angular application or TUM AET UI code, migrating components, or fixing client lint violations.

What this skill tells your AI

The instructions your AI receives, as published by ls1intum/artemis in skills/client-conventions/SKILL.md and read by ahel’s review.

Use reference/migration-recipes.md for migration examples. Check changes with pnpm run lint and pnpm run prettier:check. Prefer standalone components.

Signals are mandatory for new code

Use input() / input.required(), output(), viewChild() / viewChild.required(), viewChildren(), signal(), computed(), effect(), and inject() for dependency injection.

The legacy decorators @Input, @Output, @ViewChild, @ViewChildren, @ContentChild, and @ContentChildren are banned throughout the application, including co-located specs and test helpers. localRules/enforce-signal-apis (rules/enforce-signal-apis.mjs) enforces this under src/main/webapp/app/ and src/test/javascript/. Use signal APIs when changing an existing component; there is no unmigrated-module exception.

A computed(), linkedSignal(), effect() or afterRenderEffect() must read a signal, or it never re-runs; a value that reads none is a constant and belongs in a plain readonly field (@angular-eslint/reactive-context-must-read-signal).

Read a signal with () when checking its value; @angular-eslint/no-uncalled-signals catches accidental checks of the signal function. Keep Angular lifecycle hooks synchronous. Delegate work that awaits to a separate async method (@angular-eslint/no-async-lifecycle-method). Do not repeat a declaration in a component's metadata arrays (@angular-eslint/no-duplicates-in-metadata-arrays).

Injection and services

Declare every inject() field before any other class member (@angular-eslint/inject-at-top). Fields initialize in declaration order, so a getter called from an earlier initializer would read undefined from a service declared further down.

Declare an application-wide service with @Service(), not @Injectable({ providedIn: 'root' }) (@angular-eslint/prefer-service-decorator, autofixable). @Service() rejects constructor injection and cannot share a class with another Angular decorator. The rule still reports a @Pipe that is also injected as a service, and its autofix then breaks ng build with NG1006 (Vitest compiles JIT and does not notice), so keep @Injectable on such a pipe with a justified line-level disable and do not autofix it. Other provider metadata also keeps @Injectable.

Checking access before a request

Ask AccountService before sending a request that needs more than a signed-in user, above all one the client sends on its own (on sign-in, on page init, from the navbar): a refused request shows a 403 alert on the current page. Use its high-level methods such as hasEditorAccess() rather than hasAnyAuthorityDirect(IS_AT_LEAST_EDITOR), which counts an administrator whose session the server does not grant the administrator rights. When no method fits, add one to AccountService and MockAccountService instead of combining authorities, module features and passkey state in the caller.

ngOnChanges is banned

Use computed() or effect(). Enforced at error level by localRules/prefer-signal-reactivity-over-ngonchanges (rules/prefer-signal-reactivity-over-ngonchanges.mjs) across src/main/webapp/app, packages/tum-aet-ui/src/lib, and src/test/javascript, including specs and undecorated base classes.

This is a consistency ban, not a correctness fix. Angular does call inherited ngOnChanges hooks and does fire them for signal inputs, so existing uses are not dead code.

A genuinely unavoidable case, meaning you need SimpleChanges.previousValue or isFirstChange(), or ordering before child initialisation, needs a detailed comment and a justified line-level eslint-disable-next-line. ngOnInit and ngOnDestroy are unaffected.

Template control flow

Use @if, @for, @switch. Never *ngIf, *ngFor, *ngSwitch.

Every @switch has a @default (@angular-eslint/template/require-switch-default). Use @default never; when the cases cover the whole union or enum, so the strict template check reports a missing case, and an empty @default {} otherwise. Both render nothing for an unmatched value.

Bind styles with [style.prop], [style.prop.unit] or [style], never [ngStyle] (@angular-eslint/template/prefer-style-binding); a constant is a static style attribute. Never bind outerHTML (@angular-eslint/template/no-outerhtml).

Do not use $any() in templates (@angular-eslint/template/no-any). Prefer a typed template reference for DOM input values and a typed component method for library event payloads.

Images and keyboard order

Use NgOptimizedImage with ngSrc for images with known intrinsic dimensions or a positioned, sized container for fill. Mark an image priority only when it is expected to be the largest visible image on initial load. For arbitrary user images that must retain their intrinsic layout, keep native src and use appropriate loading and decoding hints.

Give every <img> a useful, localized text alternative, or alt="" when the image is decorative or already described next to it (@angular-eslint/template/alt-text). Keep keyboard focus in DOM order; do not use a positive tabindex to reorder controls (@angular-eslint/template/no-positive-tabindex). Move markup when the DOM order is wrong.

Redirecting from guards and resolvers

A guard returns or emits router.createUrlTree(...), or new RedirectCommand(urlTree, options) when it needs replaceUrl, skipLocationChange or state. This also applies inside RxJS and promise callbacks: return the redirect rather than throw it. The guards in one canActivate array run together, and the first emitted result that is not true, in array order, wins. A returned or emitted redirect waits for every guard ahead of it to return true; a thrown RedirectCommand bypasses that ordering and can redirect before an earlier authority check rejects the route.

A resolver returns or emits a RedirectCommand; a UrlTree returned from a resolver becomes route data and does not redirect. Inside a resolver's RxJS operator or promise callback, it can throw the RedirectCommand instead. The router cancels the running navigation with a redirect that keeps its replaceUrl and skipLocationChange, and alerts shown before the throw still appear.

Never call router.navigate() or navigateByUrl() in a guard or resolver. It cancels the running navigation on the spot and starts a new one, so the original replaceUrl and skipLocationChange are lost (Back then redirects forward again), a caller awaiting the original navigation receives false, and the navigation still happens when another guard rejects the route. A return false or EMPTY after the call changes nothing.

localRules/no-navigation-in-guard-or-resolver (rules/no-navigation-in-guard-or-resolver.mjs) enforces this at error level under src/main/webapp. It follows the guard into nested callbacks, into methods of its own class reached through this, and into functions of the same file. It is file-local and does not resolve types, so it misses navigation in an injected service the guard calls, a Router from a base-class field, const router = this.router or injector.get(Router), namespace imports, static helper calls and route objects without a marker key such as path. Moving the call into a service silences the rule without fixing anything. A catchError after a thrown redirect must rethrow what it does not handle.

In specs, use the real router (TestBed.inject(Router), no MockRouter) and assert the result: router.serializeUrl(result as UrlTree) for a returned redirect, or an error callback that receives a RedirectCommand for a thrown one. Do not assert a navigate spy. For guard combinations and browser history, route with provideRouter(...) and provideLocationMocks() as in src/main/webapp/app/localci/shared/localci-guard.spec.ts.

Form labels

Associate each visible form label with its native input using a matching for and id, or wrap the input in the label. For a custom control, connect the label to the input inside the component, not its host element. Use a heading or span for informational text that does not label a control; give groups of controls an accessible group name. The @angular-eslint/template/label-has-associated-control rule checks the label association, not the element choice or group names, in TUM AET UI and the client template areas listed in eslint.config.mjs.

Copying objects

In production src/main/webapp/app/**/*.ts, use the wrappers in src/main/webapp/app/foundation/util/deep-clone.util.ts:

  • deepClone(x) detaches nested state while preserving supported prototypes.
  • cloneWith(x, { a, b }) deep-clones the source and applies overrides by reference.
  • hydrate(new Course(), dto) gives a parsed DTO its prototype.

rules/prefer-deep-clone.mjs bans object spread, Object.assign and structuredClone in that scope, even for plain objects; specs are exempt. eslint.config.mjs also restricts direct lodash cloning imports. Array spread and object rest remain allowed.

Shallow copies share nested state; structuredClone loses custom prototypes such as dayjs. Do not clone merely to notify a signal if nested identity must survive. For that case and the child-input identity boundary, read the cloning section of reference/migration-recipes.md.

packages/tum-aet-ui is outside these application rules and must not import app/ utilities. Choose copying behavior appropriate to the package's data and identity requirements.

Styling

Use TUM AET UI components (@tumaet/ui-angular) and Tailwind v4 utilities. Do not add Bootstrap or ng-bootstrap in new work.

Colours use semantic tokens. Use TUM AET UI component variants, or text-state-danger, text-state-success, text-state-warning, text-state-info for plain markup. Never --p-<color>-N primitives, never text-red-500, never text-danger, never the superseded arbitrary text-(--danger) form.

localRules/no-raw-tailwind-color-palette enforces the palette part across src/main/webapp/app/**/*.html and packages/tum-aet-ui/src/lib/**/*.html. The Bootstrap ban is only partly enforced: localRules/no-bootstrap-classes covers the migrated directories listed in eslint.config.mjs. The convention applies throughout the client even where lint does not enforce it. Add newly migrated directories to that list.

Never hand-write PrimeNG root classes such as class="p-button" or class="p-inputtext". Render the real PrimeNG component so its styles load deterministically. Enforced by localRules/no-primeng-component-classes.

PrimeNG itself is a transitional fallback, used only when a TUM AET UI gap cannot reasonably be closed in the same change. Explain the contained fallback in the pull request.

If TUM AET UI lacks a reusable capability, add or evolve a package component around native HTML, Angular Aria (@angular/aria, for composite widgets such as menus and tabs), or stable Angular CDK primitives, and keep Artemis-specific composition in the application. See documentation/docs/developer/guidelines/tum-aet-ui-kit.mdx.

Other rules worth knowing

Prefer undefined over null. Aim for full type safety; localRules/no-as-any-cast and localRules/no-as-unknown-cast block the usual escape hatches. Filenames are kebab-case.

Full guidance: documentation/docs/developer/guidelines/client-development.mdx.

Signals

GitHub stars
813
Forks
396
Last commit
Sep 2026
Advanced
Item type
skill
Key
client-conventions
Source
github.com/ls1intum/artemis