diff --git a/.agents/skills/angular-developer/SKILL.md b/.agents/skills/angular-developer/SKILL.md new file mode 100644 index 000000000..f332e3e08 --- /dev/null +++ b/.agents/skills/angular-developer/SKILL.md @@ -0,0 +1,130 @@ +--- +name: angular-developer +description: Generates Angular code and provides architectural guidance. Trigger when creating projects, components, or services, or for best practices on reactivity (signals, linkedSignal, resource), forms, dependency injection, routing, SSR, accessibility (ARIA), animations, styling (component styles, Tailwind CSS), testing, or CLI tooling. +license: MIT +metadata: + author: Copyright 2026 Google LLC + version: '1.0' +--- + +# Angular Developer Guidelines + +1. Always analyze the project's Angular version before providing guidance, as best practices and available features can vary significantly between versions. If creating a new project with Angular CLI, do not specify a version unless prompted by the user. + +2. When generating code, follow Angular's style guide and best practices for maintainability and performance. Use the Angular CLI for scaffolding components, services, directives, pipes, and routes to ensure consistency. + +3. Once you finish generating code, run `ng build` to ensure there are no build errors. If there are errors, analyze the error messages and fix them before proceeding. Do not skip this step, as it is critical for ensuring the generated code is correct and functional. + +## Creating New Projects + +If no guidelines are provided by the user, here are same default rules to follow when creating a new Angular project: + +1. Use the latest stable version of Angular unless the user specifies otherwise. +2. Use Signals Forms for form management in new projects (available in Angular v21 and newer) [Find out more](references/signal-forms.md). + +**Execution Rules for `ng new`:** +When asked to create a new Angular project, you must determine the correct execution command by following these strict steps: + +**Step 1: Check for an explicit user version.** + +- **IF** the user requests a specific version (e.g., Angular 15), bypass local installations and strictly use `npx`. +- **Command:** `npx @angular/cli@ new ` + +**Step 2: Check for an existing Angular installation.** + +- **IF** no specific version is requested, run `ng version` in the terminal to check if the Angular CLI is already installed on the system. +- **IF** the command succeeds and returns an installed version, use the local/global installation directly. +- **Command:** `ng new ` + +**Step 3: Fallback to Latest.** + +- **IF** no specific version is requested AND the `ng version` command fails (indicating no Angular installation exists), you must use `npx` to fetch the latest version. +- **Command:** `npx @angular/cli@latest new ` + +## Components + +When working with Angular components, consult the following references based on the task: + +- **Fundamentals**: Anatomy, metadata, core concepts, and template control flow (@if, @for, @switch). Read [components.md](references/components.md) +- **Inputs**: Signal-based inputs, transforms, and model inputs. Read [inputs.md](references/inputs.md) +- **Outputs**: Signal-based outputs and custom event best practices. Read [outputs.md](references/outputs.md) +- **Host Elements**: Host bindings and attribute injection. Read [host-elements.md](references/host-elements.md) + +If you require deeper documentation not found in the references above, read the documentation at `https://angular.dev/guide/components`. + +## Reactivity and Data Management + +When managing state and data reactivity, use Angular Signals and consult the following references: + +- **Signals Overview**: Core signal concepts (`signal`, `computed`), reactive contexts, and `untracked`. Read [signals-overview.md](references/signals-overview.md) +- **Dependent State (`linkedSignal`)**: Creating writable state linked to source signals. Read [linked-signal.md](references/linked-signal.md) +- **Async Reactivity (`resource`)**: Fetching asynchronous data directly into signal state. Read [resource.md](references/resource.md) +- **Side Effects (`effect`)**: Logging, third-party DOM manipulation (`afterRenderEffect`), and when NOT to use effects. Read [effects.md](references/effects.md) + +## Forms + +In most cases for new apps, **prefer signal forms**. When making a forms decision, analyze the project and consider the following guidelines: + +- if the application is using v21 or newer and this is a new form, **prefer signal forms**. + -For older applications or when working with existing forms, use the appropriate form type that matches the applications current form strategy. + +- **Signal Forms**: Use signals for form state management. Read [signal-forms.md](references/signal-forms.md) +- **Template-driven forms**: Use for simple forms. Read [template-driven-forms.md](references/template-driven-forms.md) +- **Reactive forms**: Use for complex forms. Read [reactive-forms.md](references/reactive-forms.md) + +## Dependency Injection + +When implementing dependency injection in Angular, follow these guidelines: + +- **Fundamentals**: Overview of Dependency Injection, services, and the `inject()` function. Read [di-fundamentals.md](references/di-fundamentals.md) +- **Creating and Using Services**: Creating services, the `providedIn: 'root'` option, and injecting into components or other services. Read [creating-services.md](references/creating-services.md) +- **Defining Dependency Providers**: Automatic vs manual provision, `InjectionToken`, `useClass`, `useValue`, `useFactory`, and scopes. Read [defining-providers.md](references/defining-providers.md) +- **Injection Context**: Where `inject()` is allowed, `runInInjectionContext`, and `assertInInjectionContext`. Read [injection-context.md](references/injection-context.md) +- **Hierarchical Injectors**: The `EnvironmentInjector` vs `ElementInjector`, resolution rules, modifiers (`optional`, `skipSelf`), and `providers` vs `viewProviders`. Read [hierarchical-injectors.md](references/hierarchical-injectors.md) + +## Angular Aria + +When building accessible custom components for any of the following patterns: Accordion, Listbox, Combobox, Menu, Tabs, Toolbar, Tree, Grid, consult the following reference: + +- **Angular Aria Components**: Building headless, accessible components (Accordion, Listbox, Combobox, Menu, Tabs, Toolbar, Tree, Grid) and styling ARIA attributes. Read [angular-aria.md](references/angular-aria.md) + +## Routing + +When implementing navigation in Angular, consult the following references: + +- **Define Routes**: URL paths, static vs dynamic segments, wildcards, and redirects. Read [define-routes.md](references/define-routes.md) +- **Route Loading Strategies**: Eager vs lazy loading, and context-aware loading. Read [loading-strategies.md](references/loading-strategies.md) +- **Show Routes with Outlets**: Using ``, nested outlets, and named outlets. Read [show-routes-with-outlets.md](references/show-routes-with-outlets.md) +- **Navigate to Routes**: Declarative navigation with `RouterLink` and programmatic navigation with `Router`. Read [navigate-to-routes.md](references/navigate-to-routes.md) +- **Control Route Access with Guards**: Implementing `CanActivate`, `CanMatch`, and other guards for security. Read [route-guards.md](references/route-guards.md) +- **Data Resolvers**: Pre-fetching data before route activation with `ResolveFn`. Read [data-resolvers.md](references/data-resolvers.md) +- **Router Lifecycle and Events**: Chronological order of navigation events and debugging. Read [router-lifecycle.md](references/router-lifecycle.md) +- **Rendering Strategies**: CSR, SSG (Prerendering), and SSR with hydration. Read [rendering-strategies.md](references/rendering-strategies.md) +- **Route Transition Animations**: Enabling and customizing the View Transitions API. Read [route-animations.md](references/route-animations.md) + +If you require deeper documentation or more context, visit the [official Angular Routing guide](https://angular.dev/guide/routing). + +## Styling and Animations + +When implementing styling and animations in Angular, consult the following references: + +- **Using Tailwind CSS with Angular**: Integrating Tailwind CSS into Angular projects. Read [tailwind-css.md](references/tailwind-css.md) +- **Angular Animations**: Using native CSS (recommended) or the legacy DSL for dynamic effects. Read [angular-animations.md](references/angular-animations.md) +- **Styling components**: Best practices for component styles and encapsulation. Read [component-styling.md](references/component-styling.md) + +## Testing + +When writing or updating tests, consult the following references based on the task: + +- **Fundamentals**: Best practices for unit testing (Vitest), async patterns, and `TestBed`. Read [testing-fundamentals.md](references/testing-fundamentals.md) +- **Component Harnesses**: Standard patterns for robust component interaction. Read [component-harnesses.md](references/component-harnesses.md) +- **Router Testing**: Using `RouterTestingHarness` for reliable navigation tests. Read [router-testing.md](references/router-testing.md) +- **End-to-End (E2E) Testing**: Best practices for E2E tests with Cypress. Read [e2e-testing.md](references/e2e-testing.md) + +## Tooling + +When working with Angular tooling, consult the following references: + +- **Angular CLI**: Creating applications, generating code (components, routes, services), serving, and building. Read [cli.md](references/cli.md) +- **Code Modernization**: Automatically refactoring to modern standards using migrations. Read [migrations.md](references/migrations.md) +- **Angular MCP Server**: Available tools, configuration, and experimental features. Read [mcp.md](references/mcp.md) diff --git a/.agents/skills/angular-developer/references/angular-animations.md b/.agents/skills/angular-developer/references/angular-animations.md new file mode 100644 index 000000000..c96c4d9c6 --- /dev/null +++ b/.agents/skills/angular-developer/references/angular-animations.md @@ -0,0 +1,160 @@ +# Angular Animations + +When animating elements in Angular, **first analyze the project's Angular version** in `package.json`. +For modern applications (**Angular v20.2 and above**), prefer using native CSS with `animate.enter` and `animate.leave`. For older applications, you may need to use the deprecated `@angular/animations` package. + +## 1. Native CSS Animations (v20.2+ Recommended) + +Modern Angular provides `animate.enter` and `animate.leave` to animate elements as they enter or leave the DOM. They apply CSS classes at the appropriate times. + +### `animate.enter` and `animate.leave` + +Use these directly on elements to apply CSS classes during the enter or leave phase. Angular automatically removes the enter classes when the animation completes. For `animate.leave`, Angular waits for the animation to finish before removing the element from the DOM. + +`animate.enter` example: + +```html +@if (isShown()) { +
+

The box is entering.

+
+} +``` + +```css +/* Ensure you have a starting style if using transitions instead of keyframes */ +.enter-container { + border: 1px solid #dddddd; + margin-top: 1em; + padding: 20px; + font-weight: bold; + font-size: 20px; +} +.enter-container p { + margin: 0; +} +.enter-animation { + animation: slide-fade 1s; +} +@keyframes slide-fade { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} +``` + +_Note: `animate.leave` may be added to child elements being removed._ + +### Event Bindings and Third-party Libraries + +You can bind to `(animate.enter)` and `(animate.leave)` to call functions or use JS libraries like GSAP. + +```html +@if(show()) { +
...
+} +``` + +```ts +import { AnimationCallbackEvent } from '@angular/core'; + +onLeave(event: AnimationCallbackEvent) { + // Custom animation logic here + // CRITICAL: You MUST call animationComplete() when done so Angular removes the element! + event.animationComplete(); +} +``` + +## 2. Advanced CSS Animations + +CSS offers robust tools for advanced animation sequences. + +### Animating State and Styles + +Toggle CSS classes on elements using property binding to trigger transitions. + +```html +
...
+``` + +```css +div { + transition: height 0.3s ease-out; + height: 100px; +} +div.open { + height: 200px; +} +``` + +### Animating Auto Height + +You can use `css-grid` to animate to auto height. + +```css +.container { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 0.3s; +} +.container.open { + grid-template-rows: 1fr; +} +.container > div { + overflow: hidden; +} +``` + +### Staggering and Parallel Animations + +- **Staggering**: Use `animation-delay` or `transition-delay` with different values for items in a list. +- **Parallel**: Apply multiple animations in the `animation` shorthand (e.g., `animation: rotate 3s, fade-in 2s;`). + +### Programmatic Control + +Retrieve animations directly using standard Web APIs: + +```ts +const animations = element.getAnimations(); +animations.forEach((anim) => anim.pause()); +``` + +## 3. Legacy Animations DSL (Deprecated) + +For older projects (pre v20.2 or where `@angular/animations` is already heavily used), you use the component metadata DSL. + +**Important:** Do not mix legacy animations and `animate.enter`/`leave` in the same component. + +### Setup + +```ts +bootstrapApplication(App, { + providers: [provideAnimationsAsync()], +}); +``` + +### Defining Transitions + +```ts +import {signal} from '@angular/core'; +import {trigger, state, style, animate, transition} from '@angular/animations'; + +@Component({ + animations: [ + trigger('openClose', [ + state('open', style({opacity: 1})), + state('closed', style({opacity: 0})), + transition('open <=> closed', [animate('0.5s')]), + ]), + ], + template: `
...
`, +}) +export class OpenClose { + isOpen = signal(true); +} +``` diff --git a/.agents/skills/angular-developer/references/angular-aria.md b/.agents/skills/angular-developer/references/angular-aria.md new file mode 100644 index 000000000..2dd0cf451 --- /dev/null +++ b/.agents/skills/angular-developer/references/angular-aria.md @@ -0,0 +1,410 @@ +# Angular Aria + +Angular Aria (`@angular/aria`) is a collection of headless, accessible directives that implement common WAI-ARIA patterns. These directives handle keyboard interactions, ARIA attributes, focus management, and screen reader support. + +**As an AI Agent, your role is to provide the HTML structure and CSS styling**, while the directives handle the complex accessibility logic. + +## Styling Headless Components + +Because Angular Aria components are headless, they do not come with default styles. You **must** use CSS to style different states based on the ARIA attributes or structural classes the directives automatically apply. + +Common ARIA attributes to target in CSS: + +- `[aria-expanded="true"]` / `[aria-expanded="false"]` +- `[aria-selected="true"]` +- `[aria-disabled="true"]` +- `[aria-current="page"]` (for navigation) + +--- + +**CRITICAL**: Before using this package, it must be installed via the package manager. Confirm that it has been installed in the project. Use `npm install @angular/aria` to install if necessary. + +## 1. Accordion + +Organizes related content into expandable/collapsible sections. + +**Usage:** The Accordion is a layout component designed to organize content into logical groups that users can expand one at a time to reduce scrolling on content-heavy pages. Use it for FAQs, long forms, or progressive disclosure of information, but avoid it for primary navigation or scenarios where users must view multiple sections of content simultaneously. + +**Imports:** `import { AccordionContent, AccordionGroup, AccordionPanel, AccordionTrigger } from '@angular/aria/accordion';` + +**Directives:** `ngAccordionGroup`, `ngAccordionTrigger`, `ngAccordionPanel`, `ngAccordionContent` (for lazy loading). + +```ts +@Component({ + selector: 'app-cmp', + imports: [AccordionContent, AccordionGroup, AccordionPanel, AccordionTrigger], + template: `...`, + styles: [], +}) +export class App { + protected readonly title = signal('angular-app'); +} +``` + +```html +
+
+ +
+ +

Lazy loaded content here.

+
+
+
+
+``` + +**Styling Strategy:** +Target the `[aria-expanded]` attribute on the trigger to rotate icons, and style the panel visibility. + +```css +.accordion-header[aria-expanded='true'] .icon { + transform: rotate(180deg); +} + +/* The panel directive handles DOM removal, but you can style the transition */ +.accordion-panel { + padding: 1rem; + border-top: 1px solid #ccc; +} +``` + +--- + +## 2. Listbox + +A foundational directive for displaying a list of options. Used for visible selection lists (not dropdowns). + +**Usage:** Visible selectable lists (single or multi-select). + +**Imports:** `import {Listbox, Option} from '@angular/aria/listbox';` + +**Directives:** `ngListbox`, `ngOption`. + +```ts +@Component({ + selector: 'app-cmp', + imports: [Listbox, Option], + template: `...`, + styles: [], +}) +export class App { + protected readonly title = signal('angular-app'); +} +``` + +```html + +
    +
  • Apple
  • +
  • Banana
  • +
+``` + +**Styling Strategy:** +Target `[aria-selected="true"]` for selected state and `:focus-visible` or `[data-active]` for the focused item (Angular Aria uses roving tabindex or activedescendant). + +```css +.option { + padding: 8px; + cursor: pointer; +} +.option[aria-selected='true'] { + background: #e0f7fa; + font-weight: bold; +} +/* Focus state managed by aria */ +.option:focus-visible { + outline: 2px solid blue; +} +``` + +--- + +## 3. Combobox, Select, and Multiselect + +These patterns combine `ngCombobox` with a popup containing an `ngListbox`. + +- **Combobox**: Text input + popup (used for Autocomplete). +- **Select**: Readonly Combobox + single-select Listbox. +- **Multiselect**: Readonly Combobox + multi-select Listbox. + +**Usage:** The Combobox is a low-level primitive directive that synchronizes a text input with a popup, serving as the foundational logic for autocomplete, select, and multiselect patterns. Use it specifically for building custom filtering, unique selection requirements, or specialized input-to-popup coordination that deviates from standard, documented components. + +**Imports:** + +``` + import {Combobox, ComboboxInput, ComboboxPopupContainer} from '@angular/aria/combobox'; + import {Listbox, Option} from '@angular/aria/listbox'; +``` + +**Directives:** `ngCombobox`, `ngComboboxInput`, `ngComboboxPopupContainer`, `ngListbox`, `ngOption`. + +```html + +
+ + + + + +
+``` + +**Styling Strategy:** +Style the popup container to look like a dropdown floating above content (often paired with CDK Overlay). + +```css +.select-trigger { + width: 200px; + padding: 8px; + text-align: left; +} +.dropdown-menu { + list-style: none; + padding: 0; + margin: 0; + border: 1px solid #ccc; + background: white; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} +``` + +--- + +## 4. Menu and Menubar + +For actions, commands, and context menus (not for form selection). + +**Usage:** The Menubar is a high-level navigation pattern designed for building desktop-style application command bars (e.g., File, Edit, View) that stay persistent across an interface. It is best utilized for organizing complex commands into logical top-level categories with full horizontal keyboard support, but it should be avoided for simple standalone action lists or mobile-first layouts where horizontal space is constrained. + +**Imports:** `import {MenuBar, Menu, MenuContent, MenuItem} from '@angular/aria/menu';` + +**Directives:** `ngMenuBar`, `ngMenu`, `ngMenuItem`, `ngMenuTrigger`. + +```html + + + + +``` + +**Styling Strategy:** +Use flexbox for the menubar. Hide/show submenus based on the trigger's state. + +```css +.menubar { + display: flex; + gap: 10px; + list-style: none; + padding: 0; +} +.menu { + background: white; + border: 1px solid #ccc; + padding: 5px 0; +} +.menu li { + padding: 5px 15px; + cursor: pointer; +} +``` + +--- + +## 5. Tabs + +Layered content sections where only one panel is visible. + +**Usage:** The Tabs component is used to organize related content into distinct, navigable sections, allowing users to switch between categories or views without leaving the page. It is ideal for settings panels, multi-topic documentation, or dashboards, but should be avoided for sequential workflows (steppers) or when navigation involves more than 7–8 sections. + +**Imports:** `import {Tab, Tabs, TabList, TabPanel, TabContent} from '@angular/aria/tabs';` + +**Directives:** `ngTabs`, `ngTabList`, `ngTab`, `ngTabPanel`, `ngTabContent`. + +```html +
+
    +
  • Profile
  • +
  • Security
  • +
+ +
+ Profile Settings +
+
+ Security Settings +
+
+``` + +**Styling Strategy:** +Target `[aria-selected="true"]` on the tab buttons. + +```css +.tab-list { + display: flex; + border-bottom: 2px solid #ccc; + list-style: none; + padding: 0; +} +.tab-btn { + padding: 10px 20px; + cursor: pointer; + border-bottom: 2px solid transparent; +} +.tab-btn[aria-selected='true'] { + border-bottom-color: blue; + font-weight: bold; +} +.tab-panel { + padding: 20px; +} +``` + +--- + +## 6. Toolbar + +Groups related controls (like text formatting). + +**Usage:** The Toolbar is an organizational component designed to group frequently accessed, related controls into a single logical container. It is best used to enhance keyboard efficiency (via arrow-key navigation) and visual structure for workflows requiring repeated actions, such as text formatting or media controls. + +**Imports:** `import {Toolbar, ToolbarWidget, ToolbarWidgetGroup} from '@angular/aria/toolbar';` + +**Directives:** `ngToolbar`, `ngToolbarWidget`, `ngToolbarWidgetGroup`. + +```html +
+
+ + +
+
+``` + +**Styling Strategy:** +Target `[aria-pressed="true"]` (for toggle buttons) or `[aria-checked="true"]` (for radio groups) within the toolbar. + +```css +.toolbar { + display: flex; + gap: 5px; + padding: 8px; + background: #f5f5f5; +} +.tool-btn { + padding: 5px 10px; + border: 1px solid #ccc; +} +.tool-btn[aria-pressed='true'], +.tool-btn[aria-checked='true'] { + background: #ddd; +} +``` + +--- + +## 7. Tree + +Displays hierarchical data (file systems, nested nav). + +**Usage:** The Tree component is designed for navigating and displaying deeply nested, hierarchical data structures like file systems, organization charts, or complex site architectures. It should be used specifically for multi-level relationships where users need to expand or collapse branches, but it should be avoided for flat lists, data tables, or simple selection menus. + +**Imports:** `import {Tree, TreeItem, TreeItemGroup} from '@angular/aria/tree';` + +**Directives:** `ngTree`, `ngTreeItem`, `ngTreeGroup`. + +```html +
    +
  • + Documents +
      +
    • Resume.pdf
    • +
    +
  • +
+``` + +**Styling Strategy:** +Target `[aria-expanded]` to show/hide children or rotate chevron icons. Use `padding-left` on nested groups to show hierarchy. + +```css +.tree, +.tree-group { + list-style: none; + padding-left: 20px; +} +.tree-label::before { + content: '▶ '; + display: inline-block; + transition: transform 0.2s; +} +li[aria-expanded='true'] > .tree-label::before { + transform: rotate(90deg); +} +``` + +## 8. Grid + +A two-dimensional interactive collection of cells enabling navigation via arrow keys. + +**Usage:** Data tables, calendars, spreadsheets, and layout patterns for interactive elements. +**Directives:** `ngGrid`, `ngGridRow`, `ngGridCell`, `ngGridCellWidget`. + +```html + + + + + + + + + +
NameStatus
Project A + +
+``` + +**Styling Strategy:** +Target `[aria-selected="true"]` for selected cells and `:focus-visible` for the active cell (roving tabindex) or `[aria-activedescendant]` on the container. + +```css +.grid-table { + border-collapse: collapse; +} +[ngGridCell] { + padding: 8px; + border: 1px solid #ddd; +} +[ngGridCell][aria-selected='true'] { + background: #e3f2fd; +} +/* Focus state managed by roving tabindex */ +[ngGridCell]:focus-visible { + outline: 2px solid #2196f3; + outline-offset: -2px; +} +``` + +## General Rules for Agents + +1. **Never use native HTML elements like ` + +
+ +
+ +
+ @for (alias of aliases.controls; track $index) { + + } +
+ + + +``` + +## Accessing Controls + +Use getters for easy access to controls, especially for `FormArray`. + +```ts +get aliases() { + return this.profileForm.get('aliases') as FormArray; +} + +addAlias() { + this.aliases.push(this.fb.control('')); +} +``` + +## Updating Values + +- `patchValue()`: Updates only the specified properties. Fails silently on structural mismatches. +- `setValue()`: Replaces the entire model. Strictly enforces the form structure. + +```ts +updateProfile() { + this.profileForm.patchValue({ + firstName: 'Nancy', + address: { street: '123 Drew Street' } + }); +} +``` + +## Unified Change Events + +Modern Angular (v18+) provides a single `events` observable on all controls to track value, status, pristine, touched, reset, and submit events. + +```ts +import {ValueChangeEvent, StatusChangeEvent} from '@angular/forms'; + +this.profileForm.events.subscribe((event) => { + if (event instanceof ValueChangeEvent) { + console.log('New value:', event.value); + } +}); +``` + +## Manual State Management + +- `markAsTouched()` / `markAllAsTouched()`: Useful for showing validation errors on submit. +- `markAsDirty()` / `markAsPristine()`: Tracks if the value has been modified. +- `updateValueAndValidity()`: Manually triggers recalculation of value and status. +- Options `{ emitEvent: false }` or `{ onlySelf: true }` can be passed to most methods to control propagation. diff --git a/.agents/skills/angular-developer/references/rendering-strategies.md b/.agents/skills/angular-developer/references/rendering-strategies.md new file mode 100644 index 000000000..3b4260022 --- /dev/null +++ b/.agents/skills/angular-developer/references/rendering-strategies.md @@ -0,0 +1,44 @@ +# Rendering Strategies + +Angular supports multiple rendering strategies to optimize for SEO, performance, and interactivity. + +## 1. Client-Side Rendering (CSR) + +**Default Strategy.** Content is rendered entirely in the browser. + +- **Use case**: Interactive dashboards, internal tools. +- **Pros**: Simplest to configure, low server cost. +- **Cons**: Poor SEO, slower initial content visibility (must wait for JS). + +## 2. Static Site Generation (SSG / Prerendering) + +Content is pre-rendered into static HTML files at **build time**. + +- **Use case**: Marketing pages, blogs, documentation. +- **Pros**: Fastest initial load, excellent SEO, CDN-friendly. +- **Cons**: Requires rebuild for content updates, not for user-specific data. + +## 3. Server-Side Rendering (SSR) + +Content is rendered on the server for the **initial request**. Subsequent navigations happen client-side (SPA style). + +- **Use case**: E-commerce product pages, news sites, personalized dynamic content. +- **Pros**: Excellent SEO, fast initial content visibility. +- **Cons**: Requires a server (Node.js), higher server cost/latency. + +## Hydration + +Hydration is the process of making server-rendered HTML interactive in the browser. + +- **Full Hydration**: The entire app becomes interactive at once. +- **Incremental Hydration**: (Advanced) Parts become interactive as needed using `@defer` blocks. +- **Event Replay**: Captures and replays user events that happened before hydration finished. + +## Decision Matrix + +| Requirement | Strategy | +| :------------------------------ | :------------------- | +| **SEO + Static Content** | SSG | +| **SEO + Dynamic Content** | SSR | +| **No SEO + High Interactivity** | CSR | +| **Mixed** | Hybrid (Route-based) | diff --git a/.agents/skills/angular-developer/references/resource.md b/.agents/skills/angular-developer/references/resource.md new file mode 100644 index 000000000..e356ea51b --- /dev/null +++ b/.agents/skills/angular-developer/references/resource.md @@ -0,0 +1,77 @@ +# Async Reactivity with `resource` + +> [!IMPORTANT] +> The `resource` API is currently experimental in Angular. + +A `Resource` incorporates asynchronous data fetching into Angular's signal-based reactivity. It executes an async loader function whenever its dependencies change, exposing the status and result as synchronous signals. + +## Basic Usage + +The `resource` function accepts an options object with two main properties: + +1. `params`: A reactive computation (like `computed`). When signals read here change, the resource re-fetches. +2. `loader`: An async function that fetches data based on the parameters. + +```ts +import { Component, resource, signal, computed } from '@angular/core'; + +@Component({...}) +export class UserProfile { + userId = signal('123'); + + userResource = resource({ + // Reactively tracking userId + params: () => ({ id: this.userId() }), + + // Executes whenever params change + loader: async ({ params, abortSignal }) => { + const response = await fetch(`/api/users/${params.id}`, { signal: abortSignal }); + if (!response.ok) throw new Error('Network error'); + return response.json(); + } + }); + + // Use the resource value in computed signals + userName = computed(() => { + if (this.userResource.hasValue()) { + return this.userResource.value()?.name; + } else { + return 'Loading...'; + } + }); +} +``` + +## Aborting Requests + +If the `params` signal changes while a previous loader is still running, the `Resource` will attempt to abort the outstanding request using the provided `abortSignal`. **Always pass `abortSignal` to your `fetch` calls.** + +## Reloading Data + +You can imperatively force the resource to re-run the loader without the params changing by calling `.reload()`. + +```ts +this.userResource.reload(); +``` + +## Resource Status Signals + +The `Resource` object provides several signals to read its current state: + +- `value()`: The resolved data, or `undefined`. +- `hasValue()`: Type-guard boolean. `true` if a value exists. +- `isLoading()`: Boolean indicating if the loader is currently running. +- `error()`: The error thrown by the loader, or `undefined`. +- `status()`: A string constant representing the exact state (`'idle'`, `'loading'`, `'resolved'`, `'error'`, `'reloading'`, `'local'`). + +## Local Mutation + +You can optimistically update the resource's value directly. This changes the status to `'local'`. + +```ts +this.userResource.value.set({name: 'Optimistic Update'}); +``` + +## Reactive Data Fetching with `httpResource` + +If you are using Angular's `HttpClient`, prefer using `httpResource`. It is a specialized wrapper that leverages the Angular HTTP stack (including interceptors) while providing the same signal-based resource API. diff --git a/.agents/skills/angular-developer/references/route-animations.md b/.agents/skills/angular-developer/references/route-animations.md new file mode 100644 index 000000000..56cebbede --- /dev/null +++ b/.agents/skills/angular-developer/references/route-animations.md @@ -0,0 +1,56 @@ +# Route Transition Animations + +Angular Router supports the browser's **View Transitions API** for smooth visual transitions between routes. + +## Enabling View Transitions + +Add `withViewTransitions()` to your router configuration. + +```ts +provideRouter(routes, withViewTransitions()); +``` + +This is a **progressive enhancement**. In browsers that don't support the API, the router will still work but without the transition animation. + +## How it Works + +1. Browser takes a screenshot of the old state. +2. Router updates the DOM (activates new component). +3. Browser takes a screenshot of the new state. +4. Browser animates between the two states. + +## Customizing with CSS + +Transitions are customized in **global CSS files** (not component-scoped CSS). + +Use the `::view-transition-old()` and `::view-transition-new()` pseudo-elements. + +```css +/* Example: Cross-fade + Slide */ +::view-transition-old(root) { + animation: 90ms cubic-bezier(0.4, 0, 1, 1) both fade-out; +} +::view-transition-new(root) { + animation: 210ms cubic-bezier(0, 0, 0.2, 1) 90ms both fade-in; +} +``` + +## Advanced Control + +Use `onViewTransitionCreated` to skip transitions or customize behavior based on the navigation context. + +```ts +withViewTransitions({ + onViewTransitionCreated: ({transition, from, to}) => { + // Skip animation for specific routes + if (to.url === '/no-animation') { + transition.skipTransition(); + } + }, +}); +``` + +## Best Practices + +- **Global Styles**: Always define transition animations in `styles.css` to avoid view encapsulation issues. +- **View Transition Names**: Assign unique `view-transition-name` to elements that should transition smoothly across routes (e.g., a header image). diff --git a/.agents/skills/angular-developer/references/route-guards.md b/.agents/skills/angular-developer/references/route-guards.md new file mode 100644 index 000000000..9169d5431 --- /dev/null +++ b/.agents/skills/angular-developer/references/route-guards.md @@ -0,0 +1,52 @@ +# Route Guards + +Route guards control whether a user can navigate to or leave a route. + +## Types of Guards + +- **`CanActivate`**: Can the user access this route? (e.g., Auth check). +- **`CanActivateChild`**: Can the user access children of this route? +- **`CanDeactivate`**: Can the user leave this route? (e.g., Unsaved changes). +- **`CanMatch`**: Should this route even be considered for matching? (e.g., Feature flags). If it returns `false`, the router continues checking other routes. + +## Creating a Guard + +Guards are typically functional since Angular 15. + +```ts +export const authGuard: CanActivateFn = (route, state) => { + const authService = inject(AuthService); + const router = inject(Router); + + if (authService.isLoggedIn()) { + return true; + } + + // Redirect to login + return router.parseUrl('/login'); +}; +``` + +## Applying Guards + +Add them to the route configuration as an array. They execute in order. + +```ts +{ + path: 'admin', + component: Admin, + canActivate: [authGuard], + canActivateChild: [adminChildGuard], + canDeactivate: [unsavedChangesGuard] +} +``` + +## Return Values + +- `boolean`: `true` to allow, `false` to block. +- `UrlTree` or `RedirectCommand`: Redirect to a different route. +- `Observable` or `Promise`: Resolves to the above types. + +## Security Note + +**Client-side guards are NOT a substitute for server-side security.** Always verify permissions on the server. diff --git a/.agents/skills/angular-developer/references/router-lifecycle.md b/.agents/skills/angular-developer/references/router-lifecycle.md new file mode 100644 index 000000000..be9aeb6ec --- /dev/null +++ b/.agents/skills/angular-developer/references/router-lifecycle.md @@ -0,0 +1,45 @@ +# Router Lifecycle and Events + +Angular Router emits events through the `Router.events` observable, allowing you to track the navigation lifecycle from start to finish. + +## Common Router Events (Chronological) + +1. **`NavigationStart`**: Navigation begins. +2. **`RoutesRecognized`**: Router matches the URL to a route. +3. **`GuardsCheckStart` / `End`**: Evaluation of `canActivate`, `canMatch`, etc. +4. **`ResolveStart` / `End`**: Data resolution phase (fetching data via resolvers). +5. **`NavigationEnd`**: Navigation completed successfully. +6. **`NavigationCancel`**: Navigation canceled (e.g., guard returned `false`). +7. **`NavigationError`**: Navigation failed (e.g., error in resolver). + +## Subscribing to Events + +Inject the `Router` and filter the `events` observable. + +```ts +import {Router, NavigationStart, NavigationEnd} from '@angular/router'; + +export class MyService { + private router = inject(Router); + + constructor() { + this.router.events.pipe(filter((e) => e instanceof NavigationEnd)).subscribe((event) => { + console.log('Navigated to:', event.url); + }); + } +} +``` + +## Debugging + +Enable detailed console logging of all routing events during application bootstrap. + +```ts +provideRouter(routes, withDebugTracing()); +``` + +## Common Use Cases + +- **Loading Indicators**: Show a spinner when `NavigationStart` fires and hide it on `NavigationEnd`/`Cancel`/`Error`. +- **Analytics**: Track page views by listening for `NavigationEnd`. +- **Scroll Management**: Respond to `Scroll` events for custom scroll behavior. diff --git a/.agents/skills/angular-developer/references/router-testing.md b/.agents/skills/angular-developer/references/router-testing.md new file mode 100644 index 000000000..fc18d9cad --- /dev/null +++ b/.agents/skills/angular-developer/references/router-testing.md @@ -0,0 +1,87 @@ +# Testing with the RouterTestingHarness + +When testing components that involve routing, it is crucial **not to mock the Router or related services**. Instead, use the `RouterTestingHarness`, which provides a robust and reliable way to test routing logic in an environment that closely mirrors a real application. + +Using the harness ensures you are testing the actual router configuration, guards, and resolvers, leading to more meaningful tests. + +## Setting Up for Router Testing + +The `RouterTestingHarness` is the primary tool for testing routing scenarios. You also need to provide your test routes using the `provideRouter` function in your `TestBed` configuration. + +### Example Setup + +```ts +import {TestBed} from '@angular/core/testing'; +import {provideRouter} from '@angular/router'; +import {RouterTestingHarness} from '@angular/router/testing'; +import {Dashboard} from './dashboard.component'; +import {HeroDetail} from './hero-detail.component'; + +describe('Dashboard Component Routing', () => { + let harness: RouterTestingHarness; + + beforeEach(async () => { + // 1. Configure TestBed with test routes + await TestBed.configureTestingModule({ + providers: [ + // Use provideRouter with your test-specific routes + provideRouter([ + {path: '', component: Dashboard}, + {path: 'heroes/:id', component: HeroDetail}, + ]), + ], + }).compileComponents(); + + // 2. Create the RouterTestingHarness + harness = await RouterTestingHarness.create(); + }); +}); +``` + +### Key Concepts + +1. **`provideRouter([...])`**: Provide a test-specific routing configuration. This should include the routes necessary for the component-under-test to function correctly. +2. **`RouterTestingHarness.create()`**: Asynchronously creates and initializes the harness and performs an initial navigation to the root URL (`/`). + +## Writing Router Tests + +Once the harness is created, you can use it to drive navigation and make assertions on the state of the router and the activated components. + +### Example: Testing Navigation + +```ts +it('should navigate to a hero detail when a hero is selected', async () => { + // 1. Navigate to the initial component and get its instance + const dashboard = await harness.navigateByUrl('/', Dashboard); + + // Suppose the dashboard has a method to select a hero + const heroToSelect = {id: 42, name: 'Test Hero'}; + dashboard.selectHero(heroToSelect); + + // Wait for stability after the action that triggers navigation + await harness.fixture.whenStable(); + + // 2. Assert on the URL + expect(harness.router.url).toEqual('/heroes/42'); + + // 3. Get the activated component after navigation + const heroDetail = await harness.getHarness(HeroDetail); + + // 4. Assert on the state of the new component + expect(await heroDetail.componentInstance.hero.name).toBe('Test Hero'); +}); + +it('should get the activated component directly', async () => { + // Navigate and get the component instance in one step + const dashboardInstance = await harness.navigateByUrl('/', Dashboard); + + expect(dashboardInstance).toBeInstanceOf(Dashboard); +}); +``` + +### Best Practices + +- **Navigate with the Harness:** Always use `harness.navigateByUrl()` to simulate navigation. This method returns a promise that resolves with the instance of the activated component. +- **Access the Router State:** Use `harness.router` to access the live router instance and assert on its state (e.g., `harness.router.url`). +- **Get Activated Components:** Use `harness.getHarness(ComponentType)` to get an instance of a component harness for the currently activated routed component, or `harness.routeDebugElement` to get the `DebugElement`. +- **Wait for Stability:** After performing an action that causes navigation, always `await harness.fixture.whenStable()` to ensure the routing is complete before making assertions. diff --git a/.agents/skills/angular-developer/references/show-routes-with-outlets.md b/.agents/skills/angular-developer/references/show-routes-with-outlets.md new file mode 100644 index 000000000..af43f014f --- /dev/null +++ b/.agents/skills/angular-developer/references/show-routes-with-outlets.md @@ -0,0 +1,68 @@ +# Show Routes with Outlets + +The `RouterOutlet` directive is a placeholder where Angular renders the component for the current URL. + +## Basic Usage + +Include `` in your template. Angular inserts the routed component as a sibling immediately following the outlet. + +```html + + + +``` + +## Nested Outlets + +Child routes require their own `` within the parent component's template. + +```ts +// Parent component template +

Settings

+ +``` + +## Named Outlets (Secondary Routes) + +Pages can have multiple outlets. Assign a `name` to an outlet to target it specifically. The default name is `'primary'`. + +```html + + + + +``` + +Define the `outlet` in the route config: + +```ts +{ + path: 'chat', + component: Chat, + outlet: 'sidebar' +} +``` + +## Outlet Lifecycle Events + +`RouterOutlet` emits events when components are changed: + +- `activate`: New component instantiated. +- `deactivate`: Component destroyed. +- `attach` / `detach`: Used with `RouteReuseStrategy`. + +```html + +``` + +## Passing Data via `routerOutletData` + +You can pass contextual data to the routed component using the `routerOutletData` input. The component accesses this via the `ROUTER_OUTLET_DATA` injection token as a signal. + +```ts +// In Parent + + +// In Routed Component +outletData = inject(ROUTER_OUTLET_DATA) as Signal<{ theme: string }>; +``` diff --git a/.agents/skills/angular-developer/references/signal-forms.md b/.agents/skills/angular-developer/references/signal-forms.md new file mode 100644 index 000000000..992fab10c --- /dev/null +++ b/.agents/skills/angular-developer/references/signal-forms.md @@ -0,0 +1,897 @@ +# Signal Forms + +Signal Forms are the recommended approach for handling forms in modern Angular applications (v21+). They provide a reactive, type-safe, and model-driven way to manage form state using Angular Signals. + +**CRITICAL**: You MUST use Angular's new Signal Forms API for all form-related functionality. Do NOT use null as a value or type of any fields. + +## Imports + +You can import the following from `@angular/forms/signals`: + +```ts +import { + form, + FormField, + submit, + // Rules for field state + disabled, + hidden, + readonly, + debounce, + // Schema helpers + applyWhen, + applyEach, + schema, + // Custom validation + validate, + validateHttp, + validateStandardSchema, + // Metadata + metadata, +} from '@angular/forms/signals'; +``` + +## Creating a Form + +Use the `form()` function with a Signal model. The structure of the form is derived directly from the model. + +```ts +import {Component, signal} from '@angular/core'; +import {form, FormField} from '@angular/forms/signals'; + +@Component({ + // ... + imports: [FormField], +}) +export class Example { + // 1. Define your model with initial values (avoid undefined) + userModel = signal({ + name: '', // CRITICAL: NEVER use null or undefined as initial values + email: '', + age: 0, // Use 0 for numbers, NOT null + address: { + street: '', + city: '', + }, + hobbies: [] as string[], // Use [] for arrays, NOT null + }); + + // WRONG - DO NOT DO THIS: + // badModel = signal({ + // name: null, // ERROR: use '' instead + // age: null, // ERROR: use 0 instead + // items: null // ERROR: use [] instead + // }); + + // 2. Create the form + userForm = form(this.userModel); +} +``` + +## Validation + +Import validators from `@angular/forms/signals`. + +```ts +import {required, email, min, max, minLength, maxLength, pattern} from '@angular/forms/signals'; +``` + +Use them in the schema function passed to `form()`: + +```ts +userForm = form(this.userModel, (schemaPath) => { + // Required + required(schemaPath.name, {message: 'Name is required'}); + + // Conditional required. + required(schemaPath.name, { + when({valueOf}) { + return valueOf(schemaPath.age) > 10; + }, + }); + // when is only available for required + // Do NOT do this: pattern(p.name, /xxx/, {when /* ERROR */) + + // Email + email(schemaPath.email, {message: 'Invalid email'}); + + // Min/Max for numbers + min(schemaPath.age, 18); + max(schemaPath.age, 100); + + // MinLength/MaxLength for strings/arrays + minLength(schemaPath.password, 8); + maxLength(schemaPath.description, 500); + + // Pattern (Regex) + pattern(schemaPath.zipCode, /^\d{5}$/); +}); +``` + +## FieldState vs FormField: The Parental Requirement + +It's important to understand the difference between **FormField** (the structure) and **FieldState** (the actual data/signals). + +**RULE**: You must **CALL** a field as a function to access its state signals (valid, touched, dirty, hidden, etc.). + +```ts +// f is a FormField (structural) +const f = form(signal({cat: {name: 'pirojok-the-cat', age: 5}})); + +f.cat.name; // FormField: You can't get flags from here! +f.cat.name.touched(); // ERROR: touched() does not exist on FormField + +f.cat.name(); // FieldState: Calling it gives you access to signals +f.cat.name().touched(); // VALID: Accessing the signal +f.cat().name.touched(); // ERROR: f.cat() is state, it doesn't have children! +``` + +Similarly in a template: + +```html + +@if (bookingForm.hotelDetails.hidden()) { ... } + + +@if (bookingForm.hotelDetails().hidden()) { ... } +``` + +## Disabled / Readonly / Hidden + +Control field status using rules in the schema. + +```ts +import {disabled, readonly, hidden} from '@angular/forms/signals'; + +userForm = form(this.userModel, (schemaPath) => { + // Conditionally disabled + disabled(schemaPath.password, ({valueOf}) => !valueOf(schemaPath.createAccount)); + + // Conditionally hidden (does NOT remove from model, just marks as hidden) + hidden(schemaPath.shippingAddress, ({valueOf}) => valueOf(schemaPath.sameAsBilling)); + + // Readonly + readonly(schemaPath.username); +}); +``` + +## Binding + +Import `FormField` and use the `[formField]` directive. + +```ts +import {FormField} from '@angular/forms/signals'; +``` + +All props on state, such as `disabled`, `hidden`, `readonly` and `name` are bound automatically. +Do _NOT_ bind the `name` field. + +**CRITICAL: FORBIDDEN ATTRIBUTES** +When using `[formField]`, you MUST NOT set the following attributes in the template (either static or bound): + +- `min`, `max` (Use validators in the schema instead) +- `value`, `[value]`, `[attr.value]` (Already handled by `[formField]`) +- `[attr.min]`, `[attr.max]` +- `[disabled]`, `[readonly]` (Already handled by `[formField]`) + +Do NOT do this: `` or ``. + +```html + + + + + + + + + + + +``` + +## Reactive Forms + +**Do NOT import** `FormControl`, `FormGroup`, `FormArray`, or `FormBuilder` from `@angular/forms`. Signal Forms replace these concepts entirely. +Signal forms does NOT have a builder. + +## Accessing State + +Each field in the form is a function that returns its state. + +```ts +// Access the field by calling it +const emailState = this.userForm.email(); + +// Value (WritableSignal) +const value = this.userForm().value(); + +// Validation State (Signals) +const isValid = this.userForm().valid(); +const isInvalid = this.userForm().invalid(); +const errors = this.userForm().errors(); // Array of errors +const isPending = this.userForm().pending(); // Async validation pending + +// Interaction State (Signals) +const isTouched = this.userForm().touched(); +const isDirty = this.userForm().dirty(); + +// Availability State (Signals) +const isDisabled = this.userForm().disabled(); +const isHidden = this.userForm().hidden(); +const isReadonly = this.userForm().readonly(); +``` + +IMPORTANT!: Make sure to call the field to get it state. + +```ts +form().invalid() +form.field().dirty() +form.field.subfield().touched() +form.a.b.c.d().value() +form.address.ssn().pending() +form().reset() + +// The only exception is length: +form.children.length +form.length // NOTE: no parenthesis! +form.client.addresses.length // No "()" + +@for (income of form.addresses; track $index) {/**/} +``` + +## Submitting + +Use the `submit()` function. It automatically marks all fields as touched before running the action. + +**CRITICAL**: The callback to `submit()` MUST be `async` and MUST return a Promise. + +```ts +import { submit } from '@angular/forms/signals'; + +// CORRECT - async callback +onSubmit() { + submit(this.userForm, async () => { + // This only runs if the form is valid + await this.apiService.save(this.userModel()); + console.log('Saved!'); + }); +} + +// WRONG - missing async keyword +onSubmit() { + submit(this.userForm, () => { // ERROR: must be async + console.log('Saved!'); + }); +} +``` + +## Handling Errors + +`field().errors()` returns the errors array of ValidationError: + +```ts +interface ValidationError { + readonly kind: string; + readonly message?: string; +} +``` + +Do _NOT_ return null from validators. +When there are no errors, return undefined + +### Context + +Functions passed to rules like `validate()`, `disabled()`, `applyWhen` take a context object. It is **CRITICAL** to understand its structure: + +```ts +validate( + schemaPath.username, + ({ + value, // Signal: Writable current value of the field + fieldTree, // FieldTree: Sub-fields (if it's a group/array) + state, // FieldState: Access flags like state.valid(), state.dirty() + valueOf, // (path) => T: Read values of OTHER fields (tracking dependencies), e.g. valueOf(schemaPath.password) + stateOf, // (path) => FieldState: Access state (valid/dirty) of OTHER fields, e.g. stateOf(schemaPath.password).valid() + pathKeys, // Signal: Path from root to this field + }) => { + // WRONG: if (touched()) ... (touched is not in context) + // RIGHT: if (state.touched()) ... + + if (value() === 'admin') { + return {kind: 'reserved', message: 'Username admin is reserved'}; + } + }, +); +``` + +### IMPORTANT: Paths are NOT Signals + +Inside the `form()` callback, `schemaPath` and its children (e.g., `schemaPath.user.name`) are **NOT** signals and are **NOT** callable. + +```ts +// WRONG - This will throw an error: +applyWhen(p.ssn, () => p.ssn().touched(), (ssnField) => { ... }); + +// RIGHT - Use stateOf() to get the state of a path: +applyWhen(p.ssn, ({ stateOf }) => stateOf(p.ssn).touched(), (ssnField) => { ... }); + +// RIGHT - Use valueOf() to get the value of a path: +applyWhen(p.ssn, ({ valueOf }) => valueOf(p.ssn) !== '', (ssnField) => { ... }); +``` + +### Multiple Items + +- Use `applyEach` for applying rules per item. +- **CRITICAL**: `applyEach` callback takes ONLY ONE argument (the item path), NOT two: + +```ts +// CORRECT - single argument +applyEach(s.items, (item) => { + required(item.name); +}); + +// WRONG - do NOT pass index +applyEach(s.items, (item, index) => { + // ERROR: callback takes 1 argument + required(item.name); +}); +``` + +- In the template use `@for` to iterate over the items. +- To remove an item from an array, just remove appropriate item from the array in the data. +- **`select` binding**: You CAN bind to `` (string[]) | Use checkboxes for array fields | +| **readonly attribute** | `` | Use `readonly()` rule in schema | +| **min/max attributes** | `` | Use `min()` and `max()` rules in schema | +| **value binding** | `` | Do NOT use `[value]` with `[formField]` | +| **when option** | `pattern(p.x, /.../, {when: ...})` | `when` only works with `required()` | +| **Submit callback** | `submit(form, () => { ... })` | `submit(form, async () => { ... })` | +| **Async params** | `params: s.field` | `params: ({ value }) => value()` | +| **Async onError** | Omitting `onError` | `onError` is REQUIRED in `validateAsync` | +| **resource() API** | `request: signal` | `params: signal` | +| **applyEach args** | `applyEach(s.items, (item, index) => ...)` | `applyEach(s.items, (item) => ...)` | +| **Nested @for** | `$parent.$index` | Use `let outerIndex = $index` | +| **FormState import** | `import { FormState }` | `FormState` does not exist, use `FieldState` | +| **Null in model** | `signal({ name: null })` | `signal({ name: '' })` or `signal({ age: 0 })` | +| **Validate syntax** | `validate(s.field, { value } => ...)` | `validate(s.field, ({ value }) => ...)` | +| **Checkbox Array** | `[formField]="form.tags"` (string[]) | Checkboxes ONLY bind to `boolean` | + +## Big Form Example + +### `src/app/app.ts` + +```ts +import {Component, signal, ChangeDetectionStrategy} from '@angular/core'; +import { + form, + FormField, + submit, + required, + email, + min, + hidden, + applyEach, + validate, +} from '@angular/forms/signals'; + +@Component({ + selector: 'app-root', + standalone: true, + imports: [FormField], + templateUrl: './app.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class App { + model = signal({ + personalInfo: { + firstName: '', + lastName: '', + email: '', + age: 0, + }, + tripDetails: { + destination: 'Mars', + launchDate: '', + }, + package: { + tier: 'economy', + extras: [] as string[], + }, + companions: [] as Array<{name: string; relation: string}>, + }); + + bookingForm = form(this.model, (s) => { + required(s.personalInfo.firstName, {message: 'First name is required'}); + required(s.personalInfo.lastName, {message: 'Last name is required'}); + required(s.personalInfo.email, {message: 'Email is required'}); + email(s.personalInfo.email, {message: 'Invalid email address'}); + required(s.personalInfo.age, {message: 'Age is required'}); + min(s.personalInfo.age, 18, {message: 'Must be at least 18'}); + + required(s.tripDetails.destination); + required(s.tripDetails.launchDate); + validate(s.tripDetails.launchDate, ({value}) => { + const date = new Date(value()); + if (isNaN(date.getTime())) return undefined; + const today = new Date(); + if (date < today) { + return {kind: 'pastData', message: 'Launch date must be in the future'}; + } + return undefined; + }); + + // valueOf is used to access values of other fields in rules + hidden(s.package.extras, ({valueOf}) => valueOf(s.package.tier) === 'economy'); + + applyEach(s.companions, (companion) => { + required(companion.name, {message: 'Companion name required'}); + required(companion.relation, {message: 'Relation required'}); + }); + }); + + addCompanion() { + this.model.update((m) => ({ + ...m, + companions: [...m.companions, {name: '', relation: ''}], + })); + } + + removeCompanion(index: number) { + this.model.update((m) => ({ + ...m, + companions: m.companions.filter((_, i) => i !== index), + })); + } + + onSubmit() { + // CRITICAL: submit callback MUST be async + submit(this.bookingForm, async () => { + console.log('Booking Confirmed:', this.model()); + // If you need to do async work: + // await this.apiService.save(this.model()); + }); + } +} +``` + +### `src/app/app.html` + +```html +
+

Interstellar Booking

+ +
+

Personal Info

+ + + + + + + + +
+ +
+

Trip Details

+ + + + +
+ +
+

Package

+ + + + + + @if (!bookingForm.package.extras().hidden()) { +
+

Extras

+ + +
+ } +
+ +
+

Companions

+ + + @for (companion of bookingForm.companions; track $index) { +
+ + @if (companion.name().touched() && companion.name().errors().length) { + {{ companion.name().errors()[0].message }} + } + + + @if (companion.relation().touched() && companion.relation().errors().length) { + {{ companion.relation().errors()[0].message }} + } + + +
+ } +
+ + +
+``` + +## Recovering from Build Errors + +If you encounter build errors, here are the most common fixes: + +### `Property 'value' does not exist on type 'FieldTree'` + +**Problem**: Accessing `.value()` directly on a field without calling it first. + +```ts +// WRONG +const val = this.form.field.value(); +// RIGHT +const val = this.form.field().value(); +``` + +### `Property 'set' does not exist on type 'FieldTree'` + +**Problem**: Trying to set values on the form tree. Signal Forms are model-driven. + +```ts +// WRONG +this.form.address.street.set('Main St'); +// RIGHT - update the model signal instead +this.model.update((m) => ({...m, address: {...m.address, street: 'Main St'}})); +``` + +### `Type 'string[]' is not assignable to type 'string'` + +**Problem**: Binding `[formField]` to an array field with a single-value ` + ... + + + + +``` + +### `NG8022: Setting the 'readonly/min/max/value' attribute is not allowed` + +**Problem**: Conflict between HTML attributes and `[formField]` directive. + +```html + + + + + +min(s.age, 18); max(s.age, 99); // Then just: + +``` + +### `TS2322: Type 'string[]' is not assignable to type 'boolean'` + +**Problem**: Binding a checkbox to an array field instead of a boolean field. + +```html + + + + + + + +model = signal({ hasWifi: false, hasGym: false }); + +``` + +### `'when' does not exist in type` for pattern/email/min/max + +**Problem**: Using `when` option with validators other than `required`. + +```ts +// WRONG - when only works with required +pattern(s.ssn, /^\d{3}-\d{2}-\d{4}$/, {when: isJoint}); + +// RIGHT - use applyWhen for conditional non-required validators +applyWhen(s.ssn, isJoint, (ssnPath) => { + pattern(ssnPath, /^\d{3}-\d{2}-\d{4}$/); +}); +``` + +### `Expected 3 arguments, but got 2` for applyWhen + +**Problem**: Missing the path argument in `applyWhen`. + +```ts +// WRONG +applyWhen(isJoint, () => { ... }); + +// RIGHT - applyWhen(path, condition, schemaFn) +applyWhen(s.spouse, ({valueOf}) => valueOf(s.status) === 'joint', (spousePath) => { + required(spousePath.name); +}); +``` + +### `Module has no exported member 'FormState'` + +**Problem**: Importing a non-existent type. + +```ts +// WRONG +import {FormState} from '@angular/forms/signals'; + +// FormState does not exist. If you need type access, the form +// instance provides all necessary state through field().valid(), etc. +``` + +### `No pipe found with name 'number'` / `'json'` / `'date'` + +**Problem**: Using pipes in templates. + +```html + +{{ totalPrice() | number:'1.2-2' }} + + +totalPriceFormatted = computed(() => this.totalPrice().toFixed(2)); + +{{ totalPriceFormatted() }} +``` + +### `$parent.$index` in nested @for loops + +**Problem**: Angular doesn't have `$parent`. + +```html + +@for (item of items; track $index) { @for (sub of item.subs; track $index) { + +} } + + +@for (item of items; track $index; let outerIdx = $index) { @for (sub of item.subs; track $index) { + +} } +``` diff --git a/.agents/skills/angular-developer/references/signals-overview.md b/.agents/skills/angular-developer/references/signals-overview.md new file mode 100644 index 000000000..07fb6ed9d --- /dev/null +++ b/.agents/skills/angular-developer/references/signals-overview.md @@ -0,0 +1,94 @@ +# Angular Signals Overview + +Signals are the foundation of reactivity in modern Angular applications. A **signal** is a wrapper around a value that notifies interested consumers when that value changes. + +## Writable Signals (`signal`) + +Use `signal()` to create state that can be directly updated. + +```ts +import {signal} from '@angular/core'; + +// Create a writable signal +const count = signal(0); + +// Read the value (always requires calling the getter function) +console.log(count()); + +// Update the value directly +count.set(3); + +// Update based on the previous value +count.update((value) => value + 1); +``` + +### Exposing as Readonly + +When exposing state from a service, it is a best practice to expose a readonly version to prevent external mutation. + +```ts +private readonly _count = signal(0); +// Consumers can read this, but cannot call .set() or .update() +readonly count = this._count.asReadonly(); +``` + +## Computed Signals (`computed`) + +Use `computed()` to create read-only signals that derive their value from other signals. + +- **Lazily Evaluated**: The derivation function doesn't run until the computed signal is read. +- **Memoized**: The result is cached. It only recalculates when one of the signals it depends on changes. +- **Dynamic Dependencies**: Only the signals _actually read_ during the derivation are tracked. + +```ts +import {signal, computed} from '@angular/core'; + +const count = signal(0); +const doubleCount = computed(() => count() * 2); + +// doubleCount automatically updates when count changes. +``` + +## Reactive Contexts + +A **reactive context** is a runtime state where Angular monitors signal reads to establish a dependency. + +Angular automatically enters a reactive context when evaluating: + +- `computed` signals +- `effect` callbacks +- `linkedSignal` computations +- Component templates + +### Untracked Reads (`untracked`) + +If you need to read a signal inside a reactive context _without_ creating a dependency (so that the context doesn't re-run when the signal changes), use `untracked()`. + +```ts +import {effect, untracked} from '@angular/core'; + +effect(() => { + // This effect only runs when currentUser changes. + // It does NOT run when counter changes, even though counter is read here. + console.log(`User: ${currentUser()}, Count: ${untracked(counter)}`); +}); +``` + +### Async Operations in Reactive Contexts + +The reactive context is only active for **synchronous** code. Signal reads after an `await` will not be tracked. **Always read signals before asynchronous boundaries.** + +```ts +// ❌ INCORRECT: theme() is not tracked because it is read after await +effect(async () => { + const data = await fetchUserData(); + console.log(theme()); +}); + +// ✅ CORRECT: Read the signal before the await +effect(async () => { + const currentTheme = theme(); + const data = await fetchUserData(); + console.log(currentTheme); +}); +``` diff --git a/.agents/skills/angular-developer/references/tailwind-css.md b/.agents/skills/angular-developer/references/tailwind-css.md new file mode 100644 index 000000000..9139fcdca --- /dev/null +++ b/.agents/skills/angular-developer/references/tailwind-css.md @@ -0,0 +1,69 @@ +# Using Tailwind CSS with Angular + +Tailwind CSS is a utility-first CSS framework that integrates seamlessly with Angular. + +**CRITICAL AGENT GUIDANCE: ALWAYS focus on Tailwind CSS v4 practices. DO NOT revert to old Tailwind v3 patterns (like creating `tailwind.config.js` with `@tailwind` directives) as this will break the application build. Modern Angular projects use Tailwind v4.** + +## Automated Setup (Recommended) + +The easiest way to add Tailwind CSS to an Angular project is via the Angular CLI: + +```shell +ng add tailwindcss +``` + +This will automatically: + +1. Install `tailwindcss` and peer dependencies. +2. Configure the project to use Tailwind CSS. +3. Add the proper import to your global styles. + +## Manual Setup (Tailwind v4) + +If setting up manually, use the following Tailwind v4 pattern: + +### 1. Install Dependencies + +Install Tailwind CSS and PostCSS: + +```shell +npm install tailwindcss @tailwindcss/postcss postcss +``` + +### 2. Configure PostCSS + +Create a `.postcssrc.json` file in the project root: + +```json +{ + "plugins": { + "@tailwindcss/postcss": {} + } +} +``` + +_(Do NOT create a `tailwind.config.js` file! Configuration in v4 is handled through CSS variables)._ + +### 3. Import Tailwind CSS + +In your global styles file (e.g., `src/styles.css`), add the standard v4 import: + +```css +@import 'tailwindcss'; +``` + +_(If using SCSS, use `@use 'tailwindcss';` instead)._ + +### 4. Use Utility Classes + +You can now use Tailwind classes directly in your component templates: + +```html +

Hello world!

+``` + +## Summary for AI Agents + +- **Do not use `@tailwind base; @tailwind components; @tailwind utilities;`**. Use `@import 'tailwindcss';`. +- **Do not create `tailwind.config.js`**. Configuration is managed directly in CSS via theme variables or using PostCSS configurations. +- Stick strictly to v4 syntax and workflows. diff --git a/.agents/skills/angular-developer/references/template-driven-forms.md b/.agents/skills/angular-developer/references/template-driven-forms.md new file mode 100644 index 000000000..1907eeb99 --- /dev/null +++ b/.agents/skills/angular-developer/references/template-driven-forms.md @@ -0,0 +1,114 @@ +# Template-Driven Forms + +Template-driven forms use two-way data binding (`[(ngModel)]`) to update the data model in the component as changes are made in the template and vice versa. They are ideal for simple forms and use directives in the HTML template to manage form state and validation. + +## Core Directives + +Template-driven forms rely on the `FormsModule` which provides these key directives: + +- `NgModel`: Reconciles value changes in the form element with the data model (`[(ngModel)]`). +- `NgForm`: Automatically creates a top-level `FormGroup` bound to the `
` tag. +- `NgModelGroup`: Creates a nested `FormGroup` bound to a DOM element. + +## Setup + +First, import `FormsModule` into your component or module. + +```ts +import {Component} from '@angular/core'; +import {FormsModule} from '@angular/forms'; + +@Component({ + selector: 'app-user-form', + imports: [FormsModule], + templateUrl: './user-form.component.html', +}) +export class UserForm { + user = {name: '', role: 'Guest'}; + + onSubmit() { + console.log('Form submitted!', this.user); + } +} +``` + +## Building the Form Template + +### Two-Way Binding with `[(ngModel)]` + +Use `[(ngModel)]` on input elements. **Every element using `[(ngModel)]` MUST have a `name` attribute.** Angular uses the `name` attribute to register the control with the parent `NgForm`. + +```html + + +
+ + +
+ + +
+ + +
+ + + +
+``` + +## Form and Control State + +Angular automatically applies CSS classes to controls and forms based on their state: + +| State | Class if True | Class if False | +| :------------- | :-------------------------------- | :------------- | +| Visited | `ng-touched` | `ng-untouched` | +| Value Changed | `ng-dirty` | `ng-pristine` | +| Value is Valid | `ng-valid` | `ng-invalid` | +| Form Submitted | `ng-submitted` (on `
` only) | - | + +You can use these classes to provide visual feedback in your CSS: + +```css +.ng-valid[required], +.ng-valid.required { + border-left: 5px solid #42a948; /* green */ +} +.ng-invalid:not(form) { + border-left: 5px solid #a94442; /* red */ +} +``` + +## Validation and Error Messages + +To display error messages conditionally, export the `ngModel` directive to a template reference variable (e.g., `#nameCtrl="ngModel"`). + +```html + + + +@if (nameCtrl.invalid && (nameCtrl.dirty || nameCtrl.touched)) { +
+ @if (nameCtrl.errors?.['required']) { +
Name is required.
+ } +
+} +``` + +## Submitting the Form + +1. Use the `(ngSubmit)` event on the `` element. +2. Bind the submit button's disabled state to the overall form validity using the `NgForm` template reference variable (e.g., `[disabled]="!userForm.form.valid"`). + +## Resetting the Form + +To programmatically reset the form to its pristine state (clearing values and validation flags), use the `reset()` method on the `NgForm` instance. + +```html + +``` diff --git a/.agents/skills/angular-developer/references/testing-fundamentals.md b/.agents/skills/angular-developer/references/testing-fundamentals.md new file mode 100644 index 000000000..d58ada3a5 --- /dev/null +++ b/.agents/skills/angular-developer/references/testing-fundamentals.md @@ -0,0 +1,66 @@ +# Testing Fundamentals + +This guide covers the fundamental principles and practices for writing unit tests in this repository, which uses Vitest as the test runner. + +## Core Philosophy: Zoneless & Async-First + +This project follows a modern, zoneless testing approach. State changes schedule updates asynchronously, and tests must account for this. + +**Do NOT** use `fixture.detectChanges()` to manually trigger updates. +**ALWAYS** use the "Act, Wait, Assert" pattern: + +1. **Act:** Update state or perform an action (e.g., set a component input, click a button). +2. **Wait:** Use `await fixture.whenStable()` to allow the framework to process the scheduled update and render the changes. +3. **Assert:** Verify the outcome. + +### Basic Test Structure Example + +```ts +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {MyComponent} from './my.component'; + +describe('MyComponent', () => { + let component: MyComponent; + let fixture: ComponentFixture; + let h1: HTMLElement; + + beforeEach(async () => { + // 1. Configure the test module + await TestBed.configureTestingModule({ + imports: [MyComponent], + }).compileComponents(); + + // 2. Create the component fixture + fixture = TestBed.createComponent(MyComponent); + component = fixture.componentInstance; + h1 = fixture.nativeElement.querySelector('h1'); + }); + + it('should display the default title', async () => { + // ACT: (Implicit) Component is created with default state. + // WAIT for initial data binding. + await fixture.whenStable(); + // ASSERT the initial state. + expect(h1.textContent).toContain('Default Title'); + }); + + it('should display a different title after a change', async () => { + // ACT: Change the component's title property. + component.title.set('New Test Title'); + + // WAIT for the asynchronous update to complete. + await fixture.whenStable(); + + // ASSERT the DOM has been updated. + expect(h1.textContent).toContain('New Test Title'); + }); +}); +``` + +## TestBed and ComponentFixture + +- **`TestBed`**: The primary utility for creating a test-specific Angular module. Use `TestBed.configureTestingModule({...})` in your `beforeEach` to declare components, provide services, and set up imports needed for your test. +- **`ComponentFixture`**: A handle on the created component instance and its environment. + - `fixture.componentInstance`: Access the component's class instance. + - `fixture.nativeElement`: Access the component's root DOM element. + - `fixture.debugElement`: An Angular-specific wrapper around the `nativeElement` that provides safer, platform-agnostic ways to query the DOM (e.g., `debugElement.query(By.css('p'))`). diff --git a/.claude/skills/angular-developer b/.claude/skills/angular-developer new file mode 120000 index 000000000..f6e9d2af3 --- /dev/null +++ b/.claude/skills/angular-developer @@ -0,0 +1 @@ +../../.agents/skills/angular-developer \ No newline at end of file diff --git a/.prettierignore b/.prettierignore index 27e509393..5524c76f6 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,3 +1,4 @@ +/.agents/skills/angular-developer /.nx/cache /.nx/workspace-data /apps/client/src/polyfills.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 536453dbf..460f54d3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Added `angular-developer` skills + ### Changed - Upgraded `stripe` from version `20.4.1` to `21.0.1` diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 000000000..a15c56b59 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "skills": { + "angular-developer": { + "source": "angular/skills", + "sourceType": "github", + "skillPath": "angular-developer/SKILL.md", + "computedHash": "28eb592b92e5a24c4e3a1c0229a854069f0b8c49bed7b8d2bf6b852812dbe214" + } + } +}