diff --git a/.agents/AGENTS.md b/.agents/AGENTS.md new file mode 100644 index 0000000..ea99700 --- /dev/null +++ b/.agents/AGENTS.md @@ -0,0 +1,51 @@ +# Global Agent Instructions + +## Git Sign-off + +When creating commits with DCO sign-off (`git commit -s`), always use: + +``` +Signed-off-by: Prasanth Baskar +``` + +Never hardcode or guess the sign-off email. Always use `bupdprasanth@gmail.com`. + +## Harbor Worktree Push Rules + +See `~/.agents/harbor-worktree-rules.md` for full details. Summary: + +**CRITICAL: Wrong remote = private code leaked to public repos.** + +Harbor (`~/code/OSS/harbor/`) is a bare repo with worktrees named `{remote}-{description}`. The prefix before the first `-` IS the push target remote. Derive: `basename "$PWD" | cut -d'-' -f1` + +- `8gcr-*` → `8gcr` (**PRIVATE**) | `next-*` → `next` (**PRIVATE**) | `bupd-*` → `bupd` (fork) | `upstream-*` → `upstream` (**PUBLIC**) | `glab-*` → `glab` (**PRIVATE**) + +1. ONLY push to the remote matching the directory prefix. +2. NEVER push `8gcr` or `next` code to `upstream`. +3. When unsure, ask. Confirm remote + branch before force-push. + +## Neovim PR Review + +When the user provides a GitHub PR URL or PR number and wants local Neovim review, use the `neovim-pr-review` skill or run: + +```bash +python ~/.agents/skills/neovim-pr-review/scripts/prepare_pr_review.py +``` + +This workflow requires a clean worktree, creates a dedicated review branch, and leaves the PR changes unstaged with `git reset --mixed` so Neovim highlights the changed files. Do not use `git reset --hard` for this workflow. + +For a raw PR patch, prefer GitHub CLI auth: + +```bash +gh pr diff -R --patch > pr.patch +``` + +For public repositories, `.patch` and `.diff` also work. + +## Shared Agent Skills + +Shared skills live under `~/.agents/skills//SKILL.md`. Each skill uses YAML frontmatter with `name` and `description`; optional references, scripts, and assets live beside that `SKILL.md`. + +Agents that support skills should index `~/.agents/skills/*/SKILL.md` at session start and autoload a skill when the user's request matches its description. Load only the matching `SKILL.md` first, then load bundled files referenced by that skill as needed. + +For Codex, OpenCode, Claude-compatible agents, or custom tools without native skill indexing, use `~/.agents/skills/README.md` and `~/.agents/skills/registry.json` as discovery aids. Do not load every skill body by default. diff --git a/.agents/README.md b/.agents/README.md new file mode 100644 index 0000000..ec4b50c --- /dev/null +++ b/.agents/README.md @@ -0,0 +1,10 @@ +# Shared Agent Rules + +Canonical rules for all AI agents. See `~/dotfiles/AGENT-CONFIG.md` for the full management plan. + +- `AGENTS.md` — Global agent instructions (source of truth, symlinked by `.claude/CLAUDE.md` and `.codex/AGENTS.md`) +- `harbor-worktree-rules.md` — Harbor multi-remote push safety +- `git-signoff.md` — DCO sign-off identity +- `skills/` — Shared skills with `SKILL.md` frontmatter for agent autoloading +- `skills/README.md` — Human-readable skill discovery index +- `skills/registry.json` — Machine-readable skill discovery manifest diff --git a/.agents/git-signoff.md b/.agents/git-signoff.md new file mode 100644 index 0000000..dc99ac2 --- /dev/null +++ b/.agents/git-signoff.md @@ -0,0 +1,5 @@ +# Git Sign-off + +Use `git commit -s` with: `Signed-off-by: Prasanth Baskar ` + +Never hardcode or guess the sign-off email. Always use `bupdprasanth@gmail.com`. diff --git a/.agents/harbor-worktree-rules.md b/.agents/harbor-worktree-rules.md new file mode 100644 index 0000000..f1d33e9 --- /dev/null +++ b/.agents/harbor-worktree-rules.md @@ -0,0 +1,23 @@ +# Harbor Worktree Push Rules + +**CRITICAL: Wrong remote = private code leaked to public repos.** + +Harbor (`~/code/OSS/harbor/`) is a bare Git repo with worktrees named `{remote}-{description}`. +The prefix before the first `-` in the directory name IS the Git remote to push to. + +Derive remote: `basename "$PWD" | cut -d'-' -f1` + +## Remotes + +- `8gcr-*` → `8gcr` (container-registry/8gcr — **PRIVATE**) +- `next-*` → `next` (container-registry/harbor-next — **PRIVATE**) +- `bupd-*` → `bupd` (bupd/harbor — personal fork) +- `upstream-*` → `upstream` (goharbor/harbor — **PUBLIC**) +- `glab-*` → `glab` (8gears/container-registry/harbor — **PRIVATE**) + +## Rules + +1. ONLY push to the remote matching the directory prefix. +2. NEVER push `8gcr` or `next` code to `upstream` — that is public OSS. +3. When unsure, ask before pushing. +4. Confirm both remote AND branch before any force-push. diff --git a/.agents/skills/README.md b/.agents/skills/README.md new file mode 100644 index 0000000..d5be9ce --- /dev/null +++ b/.agents/skills/README.md @@ -0,0 +1,31 @@ +# Shared Skills + +This directory is the shared skill root for OpenCode, Codex, Claude-compatible agents, and custom agent tooling. + +## Discovery + +- Canonical source: `~/.agents/skills//SKILL.md` +- Metadata: YAML frontmatter `name` and `description` +- Autoload rule: match the user's task to a skill description, then load only that skill's `SKILL.md` +- Bundled resources: load referenced files from the same skill directory only when needed +- Machine-readable manifest: `~/.agents/skills/registry.json` + +## Matt Pocock Skills + +Installed from `mattpocock/skills`: + +- `obsidian-vault` - Search, create, and organize Obsidian notes. +- `edit-article` - Restructure and tighten article drafts. +- `caveman` - Ultra-compressed communication mode. +- `handoff` - Produce handoff notes for another agent. +- `write-a-skill` - Create new agent skills. +- `diagnose` - Disciplined debugging and regression diagnosis. +- `grill-with-docs` - Stress-test plans against docs, domain language, and ADRs. +- `triage` - Triage issues through project workflow states. +- `improve-codebase-architecture` - Find architecture and refactoring opportunities. +- `setup-matt-pocock-skills` - Configure repos for the engineering skill set. +- `tdd` - Use red-green-refactor development. +- `to-issues` - Break plans into implementation issues. +- `to-prd` - Turn conversation context into a PRD. +- `zoom-out` - Explain code at a higher abstraction level. +- `prototype` - Build throwaway prototypes for logic or UI questions. diff --git a/.agents/skills/angular-component/SKILL.md b/.agents/skills/angular-component/SKILL.md new file mode 100644 index 0000000..3e7e621 --- /dev/null +++ b/.agents/skills/angular-component/SKILL.md @@ -0,0 +1,288 @@ +--- +name: angular-component +description: Create modern Angular standalone components following v20+ best practices. Use for building UI components with signal-based inputs/outputs, OnPush change detection, host bindings, content projection, and lifecycle hooks. Triggers on component creation, refactoring class-based inputs to signals, adding host bindings, or implementing accessible interactive components. +--- + +# Angular Component + +Create standalone components for Angular v20+. Components are standalone by default—do NOT set `standalone: true`. + +## Component Structure + +```typescript +import { Component, ChangeDetectionStrategy, input, output, computed } from '@angular/core'; + +@Component({ + selector: 'app-user-card', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + 'class': 'user-card', + '[class.active]': 'isActive()', + '(click)': 'handleClick()', + }, + template: ` + +

{{ name() }}

+ @if (showEmail()) { +

{{ email() }}

+ } + `, + styles: ` + :host { display: block; } + :host.active { border: 2px solid blue; } + `, +}) +export class UserCard { + // Required input + name = input.required(); + + // Optional input with default + email = input(''); + showEmail = input(false); + + // Input with transform + isActive = input(false, { transform: booleanAttribute }); + + // Computed from inputs + avatarUrl = computed(() => `https://api.example.com/avatar/${this.name()}`); + + // Output + selected = output(); + + handleClick() { + this.selected.emit(this.name()); + } +} +``` + +## Signal Inputs + +```typescript +// Required - must be provided by parent +name = input.required(); + +// Optional with default value +count = input(0); + +// Optional without default (undefined allowed) +label = input(); + +// With alias for template binding +size = input('medium', { alias: 'buttonSize' }); + +// With transform function +disabled = input(false, { transform: booleanAttribute }); +value = input(0, { transform: numberAttribute }); +``` + +## Signal Outputs + +```typescript +import { output, outputFromObservable } from '@angular/core'; + +// Basic output +clicked = output(); +selected = output(); + +// With alias +valueChange = output({ alias: 'change' }); + +// From Observable (for RxJS interop) +scroll$ = new Subject(); +scrolled = outputFromObservable(this.scroll$); + +// Emit values +this.clicked.emit(); +this.selected.emit(item); +``` + +## Host Bindings + +Use the `host` object in `@Component`—do NOT use `@HostBinding` or `@HostListener` decorators. + +```typescript +@Component({ + selector: 'app-button', + host: { + // Static attributes + 'role': 'button', + + // Dynamic class bindings + '[class.primary]': 'variant() === "primary"', + '[class.disabled]': 'disabled()', + + // Dynamic style bindings + '[style.--btn-color]': 'color()', + + // Attribute bindings + '[attr.aria-disabled]': 'disabled()', + '[attr.tabindex]': 'disabled() ? -1 : 0', + + // Event listeners + '(click)': 'onClick($event)', + '(keydown.enter)': 'onClick($event)', + '(keydown.space)': 'onClick($event)', + }, + template: ``, +}) +export class Button { + variant = input<'primary' | 'secondary'>('primary'); + disabled = input(false, { transform: booleanAttribute }); + color = input('#007bff'); + + clicked = output(); + + onClick(event: Event) { + if (!this.disabled()) { + this.clicked.emit(); + } + } +} +``` + +## Content Projection + +```typescript +@Component({ + selector: 'app-card', + template: ` +
+ +
+
+ +
+
+ +
+ `, +}) +export class Card {} + +// Usage: +// +//

Title

+//

Main content

+// +//
+``` + +## Lifecycle Hooks + +```typescript +import { OnDestroy, OnInit, afterNextRender, afterRender } from '@angular/core'; + +export class My implements OnInit, OnDestroy { + constructor() { + // For DOM manipulation after render (SSR-safe) + afterNextRender(() => { + // Runs once after first render + }); + + afterRender(() => { + // Runs after every render + }); + } + + ngOnInit() { /* Component initialized */ } + ngOnDestroy() { /* Cleanup */ } +} +``` + +## Accessibility Requirements + +Components MUST: +- Pass AXE accessibility checks +- Meet WCAG AA standards +- Include proper ARIA attributes for interactive elements +- Support keyboard navigation +- Maintain visible focus indicators + +```typescript +@Component({ + selector: 'app-toggle', + host: { + 'role': 'switch', + '[attr.aria-checked]': 'checked()', + '[attr.aria-label]': 'label()', + 'tabindex': '0', + '(click)': 'toggle()', + '(keydown.enter)': 'toggle()', + '(keydown.space)': 'toggle(); $event.preventDefault()', + }, + template: ``, +}) +export class Toggle { + label = input.required(); + checked = input(false, { transform: booleanAttribute }); + checkedChange = output(); + + toggle() { + this.checkedChange.emit(!this.checked()); + } +} +``` + +## Template Syntax + +Use native control flow—do NOT use `*ngIf`, `*ngFor`, `*ngSwitch`. + +```html + +@if (isLoading()) { + +} @else if (error()) { + +} @else { + +} + + +@for (item of items(); track item.id) { + +} @empty { +

No items found

+} + + +@switch (status()) { + @case ('pending') { Pending } + @case ('active') { Active } + @default { Unknown } +} +``` + +## Class and Style Bindings + +Do NOT use `ngClass` or `ngStyle`. Use direct bindings: + +```html + +
Single class
+
Class string
+ + +
Styled text
+
With unit
+``` + +## Images + +Use `NgOptimizedImage` for static images: + +```typescript +import { NgOptimizedImage } from '@angular/common'; + +@Component({ + imports: [NgOptimizedImage], + template: ` + + + `, +}) +export class Hero { + imageUrl = input.required(); +} +``` + +For detailed patterns, see [references/component-patterns.md](references/component-patterns.md). diff --git a/.agents/skills/angular-component/references/component-patterns.md b/.agents/skills/angular-component/references/component-patterns.md new file mode 100644 index 0000000..68e4e33 --- /dev/null +++ b/.agents/skills/angular-component/references/component-patterns.md @@ -0,0 +1,358 @@ +# Angular Component Patterns + +## Table of Contents +- [Model Inputs (Two-Way Binding)](#model-inputs-two-way-binding) +- [View Queries](#view-queries) +- [Content Queries](#content-queries) +- [Dependency Injection in Components](#dependency-injection-in-components) +- [Component Communication Patterns](#component-communication-patterns) +- [Dynamic Components](#dynamic-components) + +## Model Inputs (Two-Way Binding) + +For two-way binding with `[(value)]` syntax: + +```typescript +import { Component, model } from '@angular/core'; + +@Component({ + selector: 'app-slider', + host: { + '(input)': 'onInput($event)', + }, + template: ` + + {{ value() }} + `, +}) +export class Slider { + // Model creates both input and output + value = model(0); + min = input(0); + max = input(100); + + onInput(event: Event) { + const target = event.target as HTMLInputElement; + this.value.set(Number(target.value)); + } +} + +// Usage: +``` + +Required model: + +```typescript +value = model.required(); +``` + +## View Queries + +Query elements and components in the template: + +```typescript +import { Component, viewChild, viewChildren, ElementRef } from '@angular/core'; + +@Component({ + selector: 'app-gallery', + template: ` + + `, +}) +export class Gallery { + images = input.required(); + + // Query single element + container = viewChild.required>('container'); + + // Query single component (optional) + firstCard = viewChild(ImageCard); + + // Query all matching components + allCards = viewChildren(ImageCard); +} +``` + +## Content Queries + +Query projected content: + +```typescript +import { Component, contentChild, contentChildren, effect, signal } from '@angular/core'; + +@Component({ + selector: 'app-tabs', + template: ` +
+ @for (tab of tabs(); track tab.label()) { + + } +
+
+ +
+ `, +}) +export class Tabs { + // Query all projected Tab children + tabs = contentChildren(Tab); + + // Query single projected element + header = contentChild('tabHeader'); + + activeTab = signal(undefined); + + constructor() { + // Set first tab as active when tabs are available + effect(() => { + const firstTab = this.tabs()[0]; + if (firstTab && !this.activeTab()) { + this.activeTab.set(firstTab); + } + }); + } + + selectTab(tab: Tab) { + this.activeTab.set(tab); + } +} + +@Component({ + selector: 'app-tab', + template: ``, + host: { + '[class.active]': 'isActive()', + '[style.display]': 'isActive() ? "block" : "none"', + }, +}) +export class Tab { + label = input.required(); + isActive = input(false); +} +``` + +## Dependency Injection in Components + +Use `inject()` function instead of constructor injection: + +```typescript +import { Component, inject } from '@angular/core'; +import { Router } from '@angular/router'; + +@Component({ + selector: 'app-dashboard', + template: `...`, +}) +export class Dashboard { + private router = inject(Router); + private userService = inject(User); + private config = inject(APP_CONFIG); + + // Optional injection + private analytics = inject(Analytics, { optional: true }); + + // Self-only injection + private localService = inject(Local, { self: true }); + + navigateToProfile() { + this.router.navigate(['/profile']); + } +} +``` + +## Component Communication Patterns + +### Parent to Child (Inputs) + +```typescript +// Parent +@Component({ + template: ``, +}) +export class Parent { + parentData = signal({ name: 'Test' }); + config = { theme: 'dark' }; +} + +// Child +@Component({ selector: 'app-child' }) +export class Child { + data = input.required(); + config = input(); +} +``` + +### Child to Parent (Outputs) + +```typescript +// Child +@Component({ + selector: 'app-child', + template: ``, +}) +export class Child { + saved = output(); + + save() { + this.saved.emit({ id: 1, name: 'Item' }); + } +} + +// Parent +@Component({ + template: ``, +}) +export class Parent { + onSaved(data: Data) { + console.log('Saved:', data); + } +} +``` + +### Shared Service Pattern + +```typescript +// Shared state service +@Injectable({ providedIn: 'root' }) +export class Cart { + private items = signal([]); + + readonly items$ = this.items.asReadonly(); + readonly total = computed(() => + this.items().reduce((sum, item) => sum + item.price, 0) + ); + + addItem(item: CartItem) { + this.items.update(items => [...items, item]); + } + + removeItem(id: string) { + this.items.update(items => items.filter(i => i.id !== id)); + } +} + +// Component A +@Component({ template: `` }) +export class Product { + private cart = inject(Cart); + product = input.required(); + + add() { + this.cart.addItem({ ...this.product(), quantity: 1 }); + } +} + +// Component B +@Component({ template: `Total: {{ cart.total() }}` }) +export class CartSummary { + cart = inject(Cart); +} +``` + +## Dynamic Components + +Using `@defer` for lazy loading: + +```typescript +@Component({ + template: ` + @defer (on viewport) { + + } @placeholder { +
Loading chart...
+ } @loading (minimum 500ms) { + + } @error { +

Failed to load chart

+ } + `, +}) +export class Dashboard { + chartData = input.required(); +} +``` + +Defer triggers: +- `on viewport` - When element enters viewport +- `on idle` - When browser is idle +- `on interaction` - On user interaction (click, focus) +- `on hover` - On mouse hover +- `on immediate` - Immediately after non-deferred content +- `on timer(500ms)` - After specified delay +- `when condition` - When expression becomes true + +```typescript +@Component({ + template: ` + @defer (on interaction; prefetch on idle) { + + } @placeholder { + + } + `, +}) +export class Post { + postId = input.required(); +} +``` + +## Attribute Directives on Components + +```typescript +@Directive({ + selector: '[appHighlight]', + host: { + '[style.backgroundColor]': 'color()', + }, +}) +export class Highlight { + color = input('yellow', { alias: 'appHighlight' }); +} + +// Usage on component +@Component({ + imports: [Highlight], + template: ``, +}) +export class Page {} +``` + +## Error Boundaries + +```typescript +@Component({ + selector: 'app-error-boundary', + template: ` + @if (hasError()) { +
+

Something went wrong

+ +
+ } @else { + + } + `, +}) +export class ErrorBoundary { + hasError = signal(false); + private errorHandler = inject(ErrorHandler); + + retry() { + this.hasError.set(false); + } +} +``` diff --git a/.agents/skills/angular-di/SKILL.md b/.agents/skills/angular-di/SKILL.md new file mode 100644 index 0000000..0b9150b --- /dev/null +++ b/.agents/skills/angular-di/SKILL.md @@ -0,0 +1,387 @@ +--- +name: angular-di +description: Implement dependency injection in Angular v20+ using inject(), injection tokens, and provider configuration. Use for service architecture, providing dependencies at different levels, creating injectable tokens, and managing singleton vs scoped services. Triggers on service creation, configuring providers, using injection tokens, or understanding DI hierarchy. +--- + +# Angular Dependency Injection + +Configure and use dependency injection in Angular v20+ with `inject()` and providers. + +## Basic Injection + +### Using inject() + +Prefer `inject()` over constructor injection: + +```typescript +import { Component, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { User } from './user.service'; + +@Component({ + selector: 'app-user-list', + template: `...`, +}) +export class UserList { + // Inject dependencies + private http = inject(HttpClient); + private userService = inject(User); + + // Can use immediately + users = this.userService.getUsers(); +} +``` + +### Injectable Services + +```typescript +import { Injectable, inject, signal } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; + +@Injectable({ + providedIn: 'root', // Singleton at root level +}) +export class User { + private http = inject(HttpClient); + + private users = signal([]); + readonly users$ = this.users.asReadonly(); + + async loadUsers() { + const users = await firstValueFrom( + this.http.get('/api/users') + ); + this.users.set(users); + } +} +``` + +## Provider Scopes + +### Root Level (Singleton) + +```typescript +// Recommended: providedIn +@Injectable({ + providedIn: 'root', +}) +export class Auth {} + +// Alternative: in app.config.ts +export const appConfig: ApplicationConfig = { + providers: [ + Auth, + ], +}; +``` + +### Component Level (Instance per Component) + +```typescript +@Component({ + selector: 'app-editor', + providers: [EditorState], // New instance for each component + template: `...`, +}) +export class Editor { + private editorState = inject(EditorState); +} +``` + +### Route Level + +```typescript +export const routes: Routes = [ + { + path: 'admin', + providers: [Admin], // Shared within this route tree + children: [ + { path: '', component: AdminDashboard }, + { path: 'users', component: AdminUsers }, + ], + }, +]; +``` + +## Injection Tokens + +### Creating Tokens + +```typescript +import { InjectionToken } from '@angular/core'; + +// Simple value token +export const API_URL = new InjectionToken('API_URL'); + +// Object token +export interface AppConfig { + apiUrl: string; + features: { + darkMode: boolean; + analytics: boolean; + }; +} + +export const APP_CONFIG = new InjectionToken('APP_CONFIG'); + +// Token with factory (self-providing) +export const WINDOW = new InjectionToken('Window', { + providedIn: 'root', + factory: () => window, +}); + +export const LOCAL_STORAGE = new InjectionToken('LocalStorage', { + providedIn: 'root', + factory: () => localStorage, +}); +``` + +### Providing Token Values + +```typescript +// app.config.ts +export const appConfig: ApplicationConfig = { + providers: [ + { provide: API_URL, useValue: 'https://api.example.com' }, + { + provide: APP_CONFIG, + useValue: { + apiUrl: 'https://api.example.com', + features: { darkMode: true, analytics: true }, + }, + }, + ], +}; +``` + +### Injecting Tokens + +```typescript +@Injectable({ providedIn: 'root' }) +export class Api { + private apiUrl = inject(API_URL); + private config = inject(APP_CONFIG); + private window = inject(WINDOW); + + getBaseUrl(): string { + return this.apiUrl; + } +} +``` + +## Provider Types + +### useClass + +```typescript +// Provide implementation +{ provide: Logger, useClass: ConsoleLogger } + +// Conditional implementation +{ + provide: Logger, + useClass: environment.production + ? ProductionLogger + : ConsoleLogger, +} +``` + +### useValue + +```typescript +// Static values +{ provide: API_URL, useValue: 'https://api.example.com' } + +// Configuration objects +{ provide: APP_CONFIG, useValue: { theme: 'dark', language: 'en' } } +``` + +### useFactory + +```typescript +// Factory with dependencies +{ + provide: User, + useFactory: (http: HttpClient, config: AppConfig) => { + return new User(http, config.apiUrl); + }, + deps: [HttpClient, APP_CONFIG], +} + +// Async factory (not recommended - use provideAppInitializer) +{ + provide: CONFIG, + useFactory: () => fetch('/config.json').then(r => r.json()), +} +``` + +### useExisting + +```typescript +// Alias to existing provider +{ provide: AbstractLogger, useExisting: ConsoleLogger } + +// Multiple tokens pointing to same instance +providers: [ + ConsoleLogger, + { provide: Logger, useExisting: ConsoleLogger }, + { provide: ErrorLogger, useExisting: ConsoleLogger }, +] +``` + +## Injection Options + +### Optional Injection + +```typescript +@Component({...}) +export class My { + // Returns null if not provided + private analytics = inject(Analytics, { optional: true }); + + trackEvent(name: string) { + this.analytics?.track(name); + } +} +``` + +### Self, SkipSelf, Host + +```typescript +@Component({ + providers: [Local], +}) +export class Parent { + // Only look in this component's injector + private local = inject(Local, { self: true }); +} + +@Component({...}) +export class Child { + // Skip this component, look in parent + private parentService = inject(ParentSvc, { skipSelf: true }); + + // Only look up to host component + private hostService = inject(Host, { host: true }); +} +``` + +## Multi Providers + +Collect multiple values for same token: + +```typescript +// Token for multiple validators +export const VALIDATORS = new InjectionToken('Validators'); + +// Provide multiple values +providers: [ + { provide: VALIDATORS, useClass: RequiredValidator, multi: true }, + { provide: VALIDATORS, useClass: EmailValidator, multi: true }, + { provide: VALIDATORS, useClass: MinLengthValidator, multi: true }, +] + +// Inject as array +@Injectable() +export class Validation { + private validators = inject(VALIDATORS); // Validator[] + + validate(value: string): ValidationError[] { + return this.validators + .map(v => v.validate(value)) + .filter(Boolean); + } +} +``` + +### HTTP Interceptors (Multi Provider) + +```typescript +// Interceptors use multi providers internally +export const appConfig: ApplicationConfig = { + providers: [ + provideHttpClient( + withInterceptors([ + authInterceptor, + loggingInterceptor, + errorInterceptor, + ]) + ), + ], +}; +``` + +## App Initializers + +Run async code before app starts using `provideAppInitializer`: + +```typescript +import { provideAppInitializer, inject } from '@angular/core'; + +export const appConfig: ApplicationConfig = { + providers: [ + Config, + provideAppInitializer(() => { + const configService = inject(Config); + return configService.loadConfig(); + }), + ], +}; +``` + +### Multiple Initializers + +```typescript +providers: [ + provideAppInitializer(() => { + const config = inject(Config); + return config.load(); + }), + provideAppInitializer(() => { + const auth = inject(Auth); + return auth.checkSession(); + }), +] +``` + +## Environment Injector + +Create injectors programmatically: + +```typescript +import { createEnvironmentInjector, EnvironmentInjector, inject } from '@angular/core'; + +@Injectable({ providedIn: 'root' }) +export class Plugin { + private parentInjector = inject(EnvironmentInjector); + + loadPlugin(providers: Provider[]): EnvironmentInjector { + return createEnvironmentInjector(providers, this.parentInjector); + } +} +``` + +## runInInjectionContext + +Run code with injection context: + +```typescript +import { runInInjectionContext, EnvironmentInjector, inject } from '@angular/core'; + +@Injectable({ providedIn: 'root' }) +export class Utility { + private injector = inject(EnvironmentInjector); + + executeWithDI(fn: () => T): T { + return runInInjectionContext(this.injector, fn); + } +} + +// Usage +utilityService.executeWithDI(() => { + const http = inject(HttpClient); + // Use http... +}); +``` + +For advanced patterns, see [references/di-patterns.md](references/di-patterns.md). diff --git a/.agents/skills/angular-di/references/di-patterns.md b/.agents/skills/angular-di/references/di-patterns.md new file mode 100644 index 0000000..03a9397 --- /dev/null +++ b/.agents/skills/angular-di/references/di-patterns.md @@ -0,0 +1,519 @@ +# Angular Dependency Injection Patterns + +## Table of Contents +- [Service Patterns](#service-patterns) +- [Abstract Classes as Tokens](#abstract-classes-as-tokens) +- [Hierarchical Injection](#hierarchical-injection) +- [Dynamic Providers](#dynamic-providers) +- [Testing with DI](#testing-with-di) +- [DestroyRef and Cleanup](#destroyref-and-cleanup) + +## Service Patterns + +### Facade Service + +Combine multiple services into a single API: + +```typescript +@Injectable({ providedIn: 'root' }) +export class ShopFacade { + private productService = inject(Product); + private cartService = inject(Cart); + private orderService = inject(Order); + + // Expose combined state + readonly products = this.productService.products; + readonly cart = this.cartService.items; + readonly cartTotal = this.cartService.total; + + // Unified actions + addToCart(productId: string, quantity: number) { + const product = this.productService.getById(productId); + if (product) { + this.cartService.add(product, quantity); + } + } + + async checkout() { + const items = this.cartService.items(); + const order = await this.orderService.create(items); + this.cartService.clear(); + return order; + } +} +``` + +### State Service Pattern + +```typescript +interface UserState { + user: User | null; + loading: boolean; + error: string | null; +} + +@Injectable({ providedIn: 'root' }) +export class UserState { + private state = signal({ + user: null, + loading: false, + error: null, + }); + + // Selectors + readonly user = computed(() => this.state().user); + readonly loading = computed(() => this.state().loading); + readonly error = computed(() => this.state().error); + readonly isAuthenticated = computed(() => this.state().user !== null); + + // Actions + setUser(user: User) { + this.state.update(s => ({ ...s, user, loading: false, error: null })); + } + + setLoading() { + this.state.update(s => ({ ...s, loading: true, error: null })); + } + + setError(error: string) { + this.state.update(s => ({ ...s, loading: false, error })); + } + + clear() { + this.state.set({ user: null, loading: false, error: null }); + } +} +``` + +### Repository Pattern + +```typescript +// Generic repository interface +export abstract class Repository { + abstract getAll(): Promise; + abstract getById(id: string): Promise; + abstract create(item: Omit): Promise; + abstract update(id: string, item: Partial): Promise; + abstract delete(id: string): Promise; +} + +// HTTP implementation +@Injectable() +export class HttpUserRepo extends Repository { + private http = inject(HttpClient); + private apiUrl = inject(API_URL); + + async getAll(): Promise { + return firstValueFrom(this.http.get(`${this.apiUrl}/users`)); + } + + async getById(id: string): Promise { + return firstValueFrom( + this.http.get(`${this.apiUrl}/users/${id}`).pipe( + catchError(() => of(null)) + ) + ); + } + + async create(user: Omit): Promise { + return firstValueFrom(this.http.post(`${this.apiUrl}/users`, user)); + } + + async update(id: string, user: Partial): Promise { + return firstValueFrom(this.http.patch(`${this.apiUrl}/users/${id}`, user)); + } + + async delete(id: string): Promise { + await firstValueFrom(this.http.delete(`${this.apiUrl}/users/${id}`)); + } +} + +// Provide implementation +{ provide: Repository, useClass: HttpUserRepo } +``` + +## Abstract Classes as Tokens + +Use abstract classes for better type safety: + +```typescript +// Abstract service definition +export abstract class Logger { + abstract log(message: string): void; + abstract error(message: string, error?: Error): void; + abstract warn(message: string): void; +} + +// Console implementation +@Injectable() +export class ConsoleLog extends Logger { + log(message: string) { + console.log(`[LOG] ${message}`); + } + + error(message: string, error?: Error) { + console.error(`[ERROR] ${message}`, error); + } + + warn(message: string) { + console.warn(`[WARN] ${message}`); + } +} + +// Remote implementation +@Injectable() +export class RemoteLog extends Logger { + private http = inject(HttpClient); + + log(message: string) { + this.send('log', message); + } + + error(message: string, error?: Error) { + this.send('error', message, error); + } + + warn(message: string) { + this.send('warn', message); + } + + private send(level: string, message: string, error?: Error) { + this.http.post('/api/logs', { level, message, error: error?.message }).subscribe(); + } +} + +// Provide based on environment +{ + provide: Logger, + useClass: environment.production ? RemoteLog : ConsoleLog, +} + +// Inject using abstract class +@Injectable({ providedIn: 'root' }) +export class User { + private logger = inject(Logger); + + createUser(user: UserData) { + this.logger.log(`Creating user: ${user.email}`); + // ... + } +} +``` + +## Hierarchical Injection + +### Component Tree Injection + +```typescript +// Parent provides service +@Component({ + selector: 'app-form-container', + providers: [FormState], + template: ` + + + + `, +}) +export class FormContainer { + private formState = inject(FormState); +} + +// Children share same instance +@Component({ + selector: 'app-form-body', + template: `...`, +}) +export class FormBody { + // Gets same instance as parent + private formState = inject(FormState); +} + +// Grandchildren also share +@Component({ + selector: 'app-form-field', + template: `...`, +}) +export class FormField { + // Gets same instance from ancestor + private formState = inject(FormState); +} +``` + +### viewProviders vs providers + +```typescript +@Component({ + selector: 'app-tabs', + // providers: Available to component AND content children + providers: [TabsSvc], + + // viewProviders: Available to component AND view children only + // NOT available to content children () + viewProviders: [InternalTabs], + + template: ` +
+ +
+ `, +}) +export class Tabs {} +``` + +## Dynamic Providers + +### Feature Flags + +```typescript +export const FEATURE_FLAGS = new InjectionToken('FeatureFlags'); + +interface FeatureFlags { + newDashboard: boolean; + betaFeatures: boolean; + experimentalApi: boolean; +} + +// Load from API +{ + provide: FEATURE_FLAGS, + useFactory: async () => { + const response = await fetch('/api/features'); + return response.json(); + }, +} + +// Use in components +@Component({...}) +export class Dashboard { + private features = inject(FEATURE_FLAGS); + + showNewDashboard = this.features.newDashboard; +} +``` + +### Platform-Specific Services + +```typescript +export abstract class Storage { + abstract get(key: string): string | null; + abstract set(key: string, value: string): void; + abstract remove(key: string): void; +} + +@Injectable() +export class BrowserStorage extends Storage { + get(key: string) { return localStorage.getItem(key); } + set(key: string, value: string) { localStorage.setItem(key, value); } + remove(key: string) { localStorage.removeItem(key); } +} + +@Injectable() +export class ServerStorage extends Storage { + private store = new Map(); + + get(key: string) { return this.store.get(key) ?? null; } + set(key: string, value: string) { this.store.set(key, value); } + remove(key: string) { this.store.delete(key); } +} + +// Provide based on platform +import { PLATFORM_ID, isPlatformBrowser } from '@angular/common'; + +{ + provide: Storage, + useFactory: (platformId: object) => { + return isPlatformBrowser(platformId) + ? new BrowserStorage() + : new ServerStorage(); + }, + deps: [PLATFORM_ID], +} +``` + +## Testing with DI + +### Mocking Services + +```typescript +describe('UserCmpt', () => { + let userServiceSpy: jasmine.SpyObj; + + beforeEach(async () => { + userServiceSpy = jasmine.createSpyObj('User', ['getUser', 'updateUser']); + userServiceSpy.getUser.and.returnValue(of({ id: '1', name: 'Test' })); + + await TestBed.configureTestingModule({ + imports: [UserCmpt], + providers: [ + { provide: User, useValue: userServiceSpy }, + ], + }).compileComponents(); + }); + + it('should load user', () => { + const fixture = TestBed.createComponent(UserCmpt); + fixture.detectChanges(); + + expect(userServiceSpy.getUser).toHaveBeenCalled(); + }); +}); +``` + +### Overriding Providers + +```typescript +describe('with different config', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [App], + }) + .overrideProvider(APP_CONFIG, { + useValue: { apiUrl: 'http://test-api.com' }, + }) + .compileComponents(); + }); +}); +``` + +### Testing Injection Tokens + +```typescript +describe('API_URL token', () => { + it('should provide correct URL', () => { + TestBed.configureTestingModule({ + providers: [ + { provide: API_URL, useValue: 'https://api.test.com' }, + ], + }); + + const apiUrl = TestBed.inject(API_URL); + expect(apiUrl).toBe('https://api.test.com'); + }); +}); +``` + +## DestroyRef and Cleanup + +### Automatic Cleanup + +```typescript +import { DestroyRef, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; + +@Component({...}) +export class Data { + private destroyRef = inject(DestroyRef); + private dataService = inject(DataSvc); + + constructor() { + // Auto-unsubscribe when component destroys + this.dataService.data$ + .pipe(takeUntilDestroyed()) + .subscribe(data => { + console.log(data); + }); + } + + // Or use DestroyRef directly + ngOnInit() { + const subscription = this.dataService.updates$.subscribe(); + + this.destroyRef.onDestroy(() => { + subscription.unsubscribe(); + console.log('Cleaned up!'); + }); + } +} +``` + +### In Services + +```typescript +@Injectable() +export class WebSocket { + private destroyRef = inject(DestroyRef); + private socket: WebSocket | null = null; + + constructor() { + this.destroyRef.onDestroy(() => { + this.socket?.close(); + }); + } + + connect(url: string) { + this.socket = new WebSocket(url); + } +} +``` + +### takeUntilDestroyed Outside Constructor + +```typescript +@Component({...}) +export class My { + private destroyRef = inject(DestroyRef); + + loadData() { + // Pass destroyRef when using outside constructor + this.http.get('/api/data') + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(); + } +} +``` + +## Injection Context Utilities + +### assertInInjectionContext + +```typescript +import { assertInInjectionContext, inject } from '@angular/core'; + +export function injectLogger(): Logger { + assertInInjectionContext(injectLogger); + return inject(Logger); +} + +// Usage - must be called in injection context +@Component({...}) +export class My2 { + private logger = injectLogger(); // OK + + someMethod() { + // injectLogger(); // ERROR - not in injection context + } +} +``` + +### Custom inject Functions + +```typescript +// Create reusable injection utilities +export function injectRouteParam(param: string): Signal { + assertInInjectionContext(injectRouteParam); + + const route = inject(ActivatedRoute); + return toSignal( + route.paramMap.pipe(map(params => params.get(param))), + { initialValue: null } + ); +} + +export function injectQueryParam(param: string): Signal { + assertInInjectionContext(injectQueryParam); + + const route = inject(ActivatedRoute); + return toSignal( + route.queryParamMap.pipe(map(params => params.get(param))), + { initialValue: null } + ); +} + +// Usage +@Component({...}) +export class UserCmpt { + userId = injectRouteParam('id'); + tab = injectQueryParam('tab'); +} +``` diff --git a/.agents/skills/angular-directives/SKILL.md b/.agents/skills/angular-directives/SKILL.md new file mode 100644 index 0000000..78ab674 --- /dev/null +++ b/.agents/skills/angular-directives/SKILL.md @@ -0,0 +1,437 @@ +--- +name: angular-directives +description: Create custom directives in Angular v20+ for DOM manipulation and behavior extension. Use for attribute directives that modify element behavior/appearance, structural directives for portals/overlays, and host directives for composition. Triggers on creating reusable DOM behaviors, extending element functionality, or composing behaviors across components. Note - use native @if/@for/@switch for control flow, not custom structural directives. +--- + +# Angular Directives + +Create custom directives for reusable DOM manipulation and behavior in Angular v20+. + +## Attribute Directives + +Modify the appearance or behavior of an element: + +```typescript +import { Directive, input, effect, inject, ElementRef } from '@angular/core'; + +@Directive({ + selector: '[appHighlight]', +}) +export class Highlight { + private el = inject(ElementRef); + + // Input with alias matching selector + color = input('yellow', { alias: 'appHighlight' }); + + constructor() { + effect(() => { + this.el.nativeElement.style.backgroundColor = this.color(); + }); + } +} + +// Usage:

Highlighted text

+// Usage:

Default yellow highlight

+``` + +### Using host Property + +Prefer `host` over `@HostBinding`/`@HostListener`: + +```typescript +@Directive({ + selector: '[appTooltip]', + host: { + '(mouseenter)': 'show()', + '(mouseleave)': 'hide()', + '[attr.aria-describedby]': 'tooltipId', + }, +}) +export class Tooltip { + text = input.required({ alias: 'appTooltip' }); + position = input<'top' | 'bottom' | 'left' | 'right'>('top'); + + tooltipId = `tooltip-${crypto.randomUUID()}`; + private tooltipEl: HTMLElement | null = null; + private el = inject(ElementRef); + + show() { + this.tooltipEl = document.createElement('div'); + this.tooltipEl.id = this.tooltipId; + this.tooltipEl.className = `tooltip tooltip-${this.position()}`; + this.tooltipEl.textContent = this.text(); + this.tooltipEl.setAttribute('role', 'tooltip'); + document.body.appendChild(this.tooltipEl); + this.positionTooltip(); + } + + hide() { + this.tooltipEl?.remove(); + this.tooltipEl = null; + } + + private positionTooltip() { + // Position logic based on this.position() and this.el + } +} + +// Usage: +``` + +### Class and Style Manipulation + +```typescript +@Directive({ + selector: '[appButton]', + host: { + 'class': 'btn', + '[class.btn-primary]': 'variant() === "primary"', + '[class.btn-secondary]': 'variant() === "secondary"', + '[class.btn-sm]': 'size() === "small"', + '[class.btn-lg]': 'size() === "large"', + '[class.disabled]': 'disabled()', + '[attr.disabled]': 'disabled() || null', + }, +}) +export class Button { + variant = input<'primary' | 'secondary'>('primary'); + size = input<'small' | 'medium' | 'large'>('medium'); + disabled = input(false, { transform: booleanAttribute }); +} + +// Usage: +``` + +### Event Handling + +```typescript +@Directive({ + selector: '[appClickOutside]', + host: { + '(document:click)': 'onDocumentClick($event)', + }, +}) +export class ClickOutside { + private el = inject(ElementRef); + + clickOutside = output(); + + onDocumentClick(event: MouseEvent) { + if (!this.el.nativeElement.contains(event.target as Node)) { + this.clickOutside.emit(); + } + } +} + +// Usage:
...
+``` + +### Keyboard Shortcuts + +```typescript +@Directive({ + selector: '[appShortcut]', + host: { + '(document:keydown)': 'onKeydown($event)', + }, +}) +export class Shortcut { + key = input.required({ alias: 'appShortcut' }); + ctrl = input(false, { transform: booleanAttribute }); + shift = input(false, { transform: booleanAttribute }); + alt = input(false, { transform: booleanAttribute }); + + triggered = output(); + + onKeydown(event: KeyboardEvent) { + const keyMatch = event.key.toLowerCase() === this.key().toLowerCase(); + const ctrlMatch = this.ctrl() ? event.ctrlKey || event.metaKey : !event.ctrlKey && !event.metaKey; + const shiftMatch = this.shift() ? event.shiftKey : !event.shiftKey; + const altMatch = this.alt() ? event.altKey : !event.altKey; + + if (keyMatch && ctrlMatch && shiftMatch && altMatch) { + event.preventDefault(); + this.triggered.emit(event); + } + } +} + +// Usage: +``` + +## Structural Directives + +Use structural directives for DOM manipulation beyond control flow (portals, overlays, dynamic insertion points). For conditionals and loops, use native `@if`, `@for`, `@switch`. + +### Portal Directive + +Render content in a different DOM location: + +```typescript +import { Directive, inject, TemplateRef, ViewContainerRef, OnInit, OnDestroy, input } from '@angular/core'; + +@Directive({ + selector: '[appPortal]', +}) +export class Portal implements OnInit, OnDestroy { + private templateRef = inject(TemplateRef); + private viewContainerRef = inject(ViewContainerRef); + private viewRef: EmbeddedViewRef | null = null; + + // Target container selector or element + target = input('body', { alias: 'appPortal' }); + + ngOnInit() { + const container = this.getContainer(); + if (container) { + this.viewRef = this.viewContainerRef.createEmbeddedView(this.templateRef); + this.viewRef.rootNodes.forEach(node => container.appendChild(node)); + } + } + + ngOnDestroy() { + this.viewRef?.destroy(); + } + + private getContainer(): HTMLElement | null { + const target = this.target(); + if (typeof target === 'string') { + return document.querySelector(target); + } + return target; + } +} + +// Usage: Render modal at body level +//
+// +//
+``` + +### Lazy Render Directive + +Defer rendering until condition is met (one-time): + +```typescript +@Directive({ + selector: '[appLazyRender]', +}) +export class LazyRender { + private templateRef = inject(TemplateRef); + private viewContainer = inject(ViewContainerRef); + private rendered = false; + + condition = input.required({ alias: 'appLazyRender' }); + + constructor() { + effect(() => { + // Only render once when condition becomes true + if (this.condition() && !this.rendered) { + this.viewContainer.createEmbeddedView(this.templateRef); + this.rendered = true; + } + }); + } +} + +// Usage: Render heavy component only when tab is first activated +//
+// +//
+``` + +### Template Outlet with Context + +```typescript +interface TemplateContext { + $implicit: T; + item: T; + index: number; +} + +@Directive({ + selector: '[appTemplateOutlet]', +}) +export class TemplateOutlet { + private viewContainer = inject(ViewContainerRef); + private currentView: EmbeddedViewRef> | null = null; + + template = input.required>>({ alias: 'appTemplateOutlet' }); + context = input.required({ alias: 'appTemplateOutletContext' }); + index = input(0, { alias: 'appTemplateOutletIndex' }); + + constructor() { + effect(() => { + const template = this.template(); + const context = this.context(); + const index = this.index(); + + if (this.currentView) { + this.currentView.context.$implicit = context; + this.currentView.context.item = context; + this.currentView.context.index = index; + this.currentView.markForCheck(); + } else { + this.currentView = this.viewContainer.createEmbeddedView(template, { + $implicit: context, + item: context, + index, + }); + } + }); + } +} + +// Usage: Custom list with template +// +//
{{ i }}: {{ item.name }}
+//
+// +``` + +## Host Directives + +Compose directives on components or other directives: + +```typescript +// Reusable behavior directives +@Directive({ + selector: '[focusable]', + host: { + 'tabindex': '0', + '(focus)': 'onFocus()', + '(blur)': 'onBlur()', + '[class.focused]': 'isFocused()', + }, +}) +export class Focusable { + isFocused = signal(false); + + onFocus() { this.isFocused.set(true); } + onBlur() { this.isFocused.set(false); } +} + +@Directive({ + selector: '[disableable]', + host: { + '[class.disabled]': 'disabled()', + '[attr.aria-disabled]': 'disabled()', + }, +}) +export class Disableable { + disabled = input(false, { transform: booleanAttribute }); +} + +// Component using host directives +@Component({ + selector: 'app-custom-button', + hostDirectives: [ + Focusable, + { + directive: Disableable, + inputs: ['disabled'], + }, + ], + host: { + 'role': 'button', + '(click)': 'onClick($event)', + '(keydown.enter)': 'onClick($event)', + '(keydown.space)': 'onClick($event)', + }, + template: ``, +}) +export class CustomButton { + private disableable = inject(Disableable); + + clicked = output(); + + onClick(event: Event) { + if (!this.disableable.disabled()) { + this.clicked.emit(); + } + } +} + +// Usage: Click me +``` + +### Exposing Host Directive Outputs + +```typescript +@Directive({ + selector: '[hoverable]', + host: { + '(mouseenter)': 'onEnter()', + '(mouseleave)': 'onLeave()', + '[class.hovered]': 'isHovered()', + }, +}) +export class Hoverable { + isHovered = signal(false); + + hoverChange = output(); + + onEnter() { + this.isHovered.set(true); + this.hoverChange.emit(true); + } + + onLeave() { + this.isHovered.set(false); + this.hoverChange.emit(false); + } +} + +@Component({ + selector: 'app-card', + hostDirectives: [ + { + directive: Hoverable, + outputs: ['hoverChange'], + }, + ], + template: ``, +}) +export class Card {} + +// Usage: ... +``` + +## Directive Composition API + +Combine multiple behaviors: + +```typescript +// Base directives +@Directive({ selector: '[withRipple]' }) +export class Ripple { + // Ripple effect implementation +} + +@Directive({ selector: '[withElevation]' }) +export class Elevation { + elevation = input(2); +} + +// Composed component +@Component({ + selector: 'app-material-button', + hostDirectives: [ + Ripple, + { + directive: Elevation, + inputs: ['elevation'], + }, + { + directive: Disableable, + inputs: ['disabled'], + }, + ], + template: ``, +}) +export class MaterialButton {} +``` + +For advanced patterns, see [references/directive-patterns.md](references/directive-patterns.md). diff --git a/.agents/skills/angular-directives/references/directive-patterns.md b/.agents/skills/angular-directives/references/directive-patterns.md new file mode 100644 index 0000000..06c02d3 --- /dev/null +++ b/.agents/skills/angular-directives/references/directive-patterns.md @@ -0,0 +1,570 @@ +# Angular Directive Patterns + +## Table of Contents +- [DOM Manipulation](#dom-manipulation) +- [Form Directives](#form-directives) +- [Intersection Observer](#intersection-observer) +- [Resize Observer](#resize-observer) +- [Drag and Drop](#drag-and-drop) +- [Permission Directive](#permission-directive) + +## DOM Manipulation + +### Auto-Focus Directive + +```typescript +@Directive({ + selector: '[appAutoFocus]', +}) +export class AutoFocus { + private el = inject(ElementRef); + + enabled = input(true, { alias: 'appAutoFocus', transform: booleanAttribute }); + delay = input(0); + + constructor() { + afterNextRender(() => { + if (this.enabled()) { + setTimeout(() => { + this.el.nativeElement.focus(); + }, this.delay()); + } + }); + } +} + +// Usage: +// Usage: +``` + +### Text Selection Directive + +```typescript +@Directive({ + selector: '[appSelectAll]', + host: { + '(focus)': 'onFocus()', + '(click)': 'onClick($event)', + }, +}) +export class SelectAll { + private el = inject(ElementRef); + + onFocus() { + // Delay to ensure value is set + setTimeout(() => this.el.nativeElement.select(), 0); + } + + onClick(event: MouseEvent) { + // Select all on first click if not already focused + if (document.activeElement !== this.el.nativeElement) { + this.el.nativeElement.select(); + } + } +} + +// Usage: +``` + +### Copy to Clipboard + +```typescript +@Directive({ + selector: '[appCopyToClipboard]', + host: { + '(click)': 'copy()', + '[style.cursor]': '"pointer"', + }, +}) +export class CopyToClipboard { + text = input.required({ alias: 'appCopyToClipboard' }); + + copied = output(); + error = output(); + + async copy() { + try { + await navigator.clipboard.writeText(this.text()); + this.copied.emit(); + } catch (err) { + this.error.emit(err as Error); + } + } +} + +// Usage: +// +``` + +## Form Directives + +### Trim Input + +```typescript +@Directive({ + selector: 'input[appTrim], textarea[appTrim]', + host: { + '(blur)': 'onBlur()', + }, +}) +export class Trim { + private el = inject(ElementRef); + private ngControl = inject(NgControl, { optional: true, self: true }); + + onBlur() { + const value = this.el.nativeElement.value; + const trimmed = value.trim(); + + if (value !== trimmed) { + this.el.nativeElement.value = trimmed; + this.ngControl?.control?.setValue(trimmed); + } + } +} + +// Usage: +``` + +### Input Mask + +```typescript +@Directive({ + selector: '[appMask]', + host: { + '(input)': 'onInput($event)', + '(keydown)': 'onKeydown($event)', + }, +}) +export class Mask { + private el = inject(ElementRef); + + // Mask pattern: 9 = digit, A = letter, * = any + mask = input.required({ alias: 'appMask' }); + + onInput(event: InputEvent) { + const input = this.el.nativeElement; + const value = input.value; + const masked = this.applyMask(value); + + if (value !== masked) { + input.value = masked; + } + } + + onKeydown(event: KeyboardEvent) { + // Allow navigation keys + if (['Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', 'Tab'].includes(event.key)) { + return; + } + + const input = this.el.nativeElement; + const position = input.selectionStart ?? 0; + const maskChar = this.mask()[position]; + + if (!maskChar) { + event.preventDefault(); + return; + } + + if (!this.isValidChar(event.key, maskChar)) { + event.preventDefault(); + } + } + + private applyMask(value: string): string { + const mask = this.mask(); + let result = ''; + let valueIndex = 0; + + for (let i = 0; i < mask.length && valueIndex < value.length; i++) { + const maskChar = mask[i]; + const inputChar = value[valueIndex]; + + if (maskChar === '9' || maskChar === 'A' || maskChar === '*') { + if (this.isValidChar(inputChar, maskChar)) { + result += inputChar; + valueIndex++; + } else { + valueIndex++; + i--; + } + } else { + result += maskChar; + if (inputChar === maskChar) { + valueIndex++; + } + } + } + + return result; + } + + private isValidChar(char: string, maskChar: string): boolean { + switch (maskChar) { + case '9': return /\d/.test(char); + case 'A': return /[a-zA-Z]/.test(char); + case '*': return /[a-zA-Z0-9]/.test(char); + default: return char === maskChar; + } + } +} + +// Usage: +``` + +### Character Counter + +```typescript +@Directive({ + selector: '[appCharCount]', +}) +export class CharCount { + private el = inject(ElementRef); + + maxLength = input.required({ alias: 'appCharCount' }); + + currentLength = signal(0); + remaining = computed(() => this.maxLength() - this.currentLength()); + isOverLimit = computed(() => this.remaining() < 0); + + constructor() { + effect(() => { + this.currentLength.set(this.el.nativeElement.value.length); + }); + + // Listen for input changes + afterNextRender(() => { + this.el.nativeElement.addEventListener('input', () => { + this.currentLength.set(this.el.nativeElement.value.length); + }); + }); + } +} + +// Usage with template: +// +// {{ counter.remaining() }} characters remaining +``` + +## Intersection Observer + +### Lazy Load Directive + +```typescript +@Directive({ + selector: '[appLazyLoad]', +}) +export class LazyLoad implements OnDestroy { + private el = inject(ElementRef); + private observer: IntersectionObserver | null = null; + + src = input.required({ alias: 'appLazyLoad' }); + placeholder = input('/assets/placeholder.png'); + + loaded = output(); + + constructor() { + afterNextRender(() => { + this.setupObserver(); + }); + } + + private setupObserver() { + this.observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + this.loadImage(); + this.observer?.disconnect(); + } + }); + }, + { rootMargin: '50px' } + ); + + this.observer.observe(this.el.nativeElement); + + // Set placeholder + if (this.el.nativeElement instanceof HTMLImageElement) { + this.el.nativeElement.src = this.placeholder(); + } + } + + private loadImage() { + const element = this.el.nativeElement; + + if (element instanceof HTMLImageElement) { + element.src = this.src(); + element.onload = () => this.loaded.emit(); + } else { + element.style.backgroundImage = `url(${this.src()})`; + this.loaded.emit(); + } + } + + ngOnDestroy() { + this.observer?.disconnect(); + } +} + +// Usage: Lazy loaded image +``` + +### Infinite Scroll + +```typescript +@Directive({ + selector: '[appInfiniteScroll]', +}) +export class InfiniteScroll implements OnDestroy { + private el = inject(ElementRef); + private observer: IntersectionObserver | null = null; + + threshold = input(0.1); + disabled = input(false); + + scrolled = output(); + + constructor() { + afterNextRender(() => { + this.setupObserver(); + }); + + effect(() => { + if (this.disabled()) { + this.observer?.disconnect(); + } else { + this.setupObserver(); + } + }); + } + + private setupObserver() { + this.observer?.disconnect(); + + this.observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && !this.disabled()) { + this.scrolled.emit(); + } + }, + { threshold: this.threshold() } + ); + + this.observer.observe(this.el.nativeElement); + } + + ngOnDestroy() { + this.observer?.disconnect(); + } +} + +// Usage: +//
+// @for (item of items(); track item.id) { +//
{{ item.name }}
+// } +//
+// Loading... +//
+//
+``` + +## Resize Observer + +```typescript +@Directive({ + selector: '[appResize]', +}) +export class Resize implements OnDestroy { + private el = inject(ElementRef); + private observer: ResizeObserver | null = null; + + width = signal(0); + height = signal(0); + + resized = output<{ width: number; height: number }>(); + + constructor() { + afterNextRender(() => { + this.observer = new ResizeObserver((entries) => { + const entry = entries[0]; + const { width, height } = entry.contentRect; + + this.width.set(width); + this.height.set(height); + this.resized.emit({ width, height }); + }); + + this.observer.observe(this.el.nativeElement); + }); + } + + ngOnDestroy() { + this.observer?.disconnect(); + } +} + +// Usage: +//
+// Size: {{ resize.width() }}x{{ resize.height() }} +//
+``` + +## Drag and Drop + +```typescript +@Directive({ + selector: '[appDraggable]', + host: { + 'draggable': 'true', + '[class.dragging]': 'isDragging()', + '(dragstart)': 'onDragStart($event)', + '(dragend)': 'onDragEnd($event)', + }, +}) +export class Draggable { + data = input(null, { alias: 'appDraggable' }); + effectAllowed = input('move'); + + isDragging = signal(false); + + dragStart = output(); + dragEnd = output(); + + onDragStart(event: DragEvent) { + this.isDragging.set(true); + + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = this.effectAllowed(); + event.dataTransfer.setData('application/json', JSON.stringify(this.data())); + } + + this.dragStart.emit(event); + } + + onDragEnd(event: DragEvent) { + this.isDragging.set(false); + this.dragEnd.emit(event); + } +} + +@Directive({ + selector: '[appDropZone]', + host: { + '[class.drag-over]': 'isDragOver()', + '(dragover)': 'onDragOver($event)', + '(dragleave)': 'onDragLeave($event)', + '(drop)': 'onDrop($event)', + }, +}) +export class DropZone { + isDragOver = signal(false); + + dropped = output(); + + onDragOver(event: DragEvent) { + event.preventDefault(); + this.isDragOver.set(true); + } + + onDragLeave(event: DragEvent) { + this.isDragOver.set(false); + } + + onDrop(event: DragEvent) { + event.preventDefault(); + this.isDragOver.set(false); + + const data = event.dataTransfer?.getData('application/json'); + if (data) { + this.dropped.emit(JSON.parse(data)); + } + } +} + +// Usage: +//
Drag me
+//
Drop here
+``` + +## Permission Directive + +```typescript +@Directive({ + selector: '[appHasPermission]', +}) +export class HasPermission { + private templateRef = inject(TemplateRef); + private viewContainer = inject(ViewContainerRef); + private authService = inject(Auth); + private hasView = false; + + permission = input.required({ alias: 'appHasPermission' }); + mode = input<'any' | 'all'>('any'); + + constructor() { + effect(() => { + const hasPermission = this.checkPermission(); + + if (hasPermission && !this.hasView) { + this.viewContainer.createEmbeddedView(this.templateRef); + this.hasView = true; + } else if (!hasPermission && this.hasView) { + this.viewContainer.clear(); + this.hasView = false; + } + }); + } + + private checkPermission(): boolean { + const required = this.permission(); + const permissions = Array.isArray(required) ? required : [required]; + const userPermissions = this.authService.permissions(); + + if (this.mode() === 'all') { + return permissions.every(p => userPermissions.includes(p)); + } + + return permissions.some(p => userPermissions.includes(p)); + } +} + +// Usage: +// +//
Edit & Delete
+``` + +## Export Directive Reference + +```typescript +@Directive({ + selector: '[appToggle]', + exportAs: 'appToggle', +}) +export class Toggle { + isOpen = signal(false); + + toggle() { + this.isOpen.update(v => !v); + } + + open() { + this.isOpen.set(true); + } + + close() { + this.isOpen.set(false); + } +} + +// Usage: +//
+// +// @if (toggle.isOpen()) { +//
Content
+// } +//
+``` diff --git a/.agents/skills/angular-forms/SKILL.md b/.agents/skills/angular-forms/SKILL.md new file mode 100644 index 0000000..f27e1d0 --- /dev/null +++ b/.agents/skills/angular-forms/SKILL.md @@ -0,0 +1,434 @@ +--- +name: angular-forms +description: Build signal-based forms in Angular v21+ using the new Signal Forms API. Use for form creation with automatic two-way binding, schema-based validation, field state management, and dynamic forms. Triggers on form implementation, adding validation, creating multi-step forms, or building forms with conditional fields. Signal Forms are experimental but recommended for new Angular projects. Don't use for template-driven forms without signals or third-party form libraries like Formly or ngx-formly. +--- + +# Angular Signal Forms + +Build type-safe, reactive forms using Angular's Signal Forms API. Signal Forms provide automatic two-way binding, schema-based validation, and reactive field state. + +**Note:** Signal Forms are experimental in Angular v21. For production apps requiring stability, see [references/form-patterns.md](references/form-patterns.md) for Reactive Forms patterns. + +## Basic Setup + +```typescript +import { Component, signal } from '@angular/core'; +import { form, FormField, required, email } from '@angular/forms/signals'; + +interface LoginData { + email: string; + password: string; +} + +@Component({ + selector: 'app-login', + imports: [FormField], + template: ` +
+ + @if (loginForm.email().touched() && loginForm.email().invalid()) { +

{{ loginForm.email().errors()[0].message }}

+ } + + + @if (loginForm.password().touched() && loginForm.password().invalid()) { +

{{ loginForm.password().errors()[0].message }}

+ } + + +
+ `, +}) +export class Login { + // Form model - a writable signal + loginModel = signal({ + email: '', + password: '', + }); + + // Create form with validation schema + loginForm = form(this.loginModel, (schemaPath) => { + required(schemaPath.email, { message: 'Email is required' }); + email(schemaPath.email, { message: 'Enter a valid email address' }); + required(schemaPath.password, { message: 'Password is required' }); + }); + + onSubmit(event: Event) { + event.preventDefault(); + if (this.loginForm().valid()) { + const credentials = this.loginModel(); + console.log('Submitting:', credentials); + } + } +} +``` + +## Form Models + +Form models are writable signals that serve as the single source of truth: + +```typescript +// Define interface for type safety +interface UserProfile { + name: string; + email: string; + age: number | null; + preferences: { + newsletter: boolean; + theme: 'light' | 'dark'; + }; +} + +// Create model signal with initial values +const userModel = signal({ + name: '', + email: '', + age: null, + preferences: { + newsletter: false, + theme: 'light', + }, +}); + +// Create form from model +const userForm = form(userModel); + +// Access nested fields via dot notation +userForm.name // FieldTree +userForm.preferences.theme // FieldTree<'light' | 'dark'> +``` + +### Reading Values + +```typescript +// Read entire model +const data = this.userModel(); + +// Read field value via field state +const name = this.userForm.name().value(); +const theme = this.userForm.preferences.theme().value(); +``` + +### Updating Values + +```typescript +// Replace entire model +this.userModel.set({ + name: 'Alice', + email: 'alice@example.com', + age: 30, + preferences: { newsletter: true, theme: 'dark' }, +}); + +// Update single field +this.userForm.name().value.set('Bob'); +this.userForm.age().value.update(age => (age ?? 0) + 1); +``` + +## Field State + +Each field provides reactive signals for validation, interaction, and availability: + +```typescript +const emailField = this.form.email(); + +// Validation state +emailField.valid() // true if passes all validation +emailField.invalid() // true if has validation errors +emailField.errors() // array of error objects +emailField.pending() // true if async validation in progress + +// Interaction state +emailField.touched() // true after focus + blur +emailField.dirty() // true after user modification + +// Availability state +emailField.disabled() // true if field is disabled +emailField.hidden() // true if field should be hidden +emailField.readonly() // true if field is readonly + +// Value +emailField.value() // current field value (signal) +``` + +### Form-Level State + +The form itself is also a field with aggregated state: + +```typescript +// Form is valid when all interactive fields are valid +this.form().valid() + +// Form is touched when any field is touched +this.form().touched() + +// Form is dirty when any field is modified +this.form().dirty() +``` + +## Validation + +### Built-in Validators + +```typescript +import { + form, required, email, min, max, + minLength, maxLength, pattern +} from '@angular/forms/signals'; + +const userForm = form(this.userModel, (schemaPath) => { + // Required field + required(schemaPath.name, { message: 'Name is required' }); + + // Email format + email(schemaPath.email, { message: 'Invalid email' }); + + // Numeric range + min(schemaPath.age, 18, { message: 'Must be 18+' }); + max(schemaPath.age, 120, { message: 'Invalid age' }); + + // String/array length + minLength(schemaPath.password, 8, { message: 'Min 8 characters' }); + maxLength(schemaPath.bio, 500, { message: 'Max 500 characters' }); + + // Regex pattern + pattern(schemaPath.phone, /^\d{3}-\d{3}-\d{4}$/, { + message: 'Format: 555-123-4567', + }); +}); +``` + +### Conditional Validation + +```typescript +const orderForm = form(this.orderModel, (schemaPath) => { + required(schemaPath.promoCode, { + message: 'Promo code required for discounts', + when: ({ valueOf }) => valueOf(schemaPath.applyDiscount), + }); +}); +``` + +### Custom Validators + +```typescript +import { validate } from '@angular/forms/signals'; + +const signupForm = form(this.signupModel, (schemaPath) => { + // Custom validation logic + validate(schemaPath.username, ({ value }) => { + if (value().includes(' ')) { + return { kind: 'noSpaces', message: 'Username cannot contain spaces' }; + } + return null; + }); +}); +``` + +### Cross-Field Validation + +```typescript +const passwordForm = form(this.passwordModel, (schemaPath) => { + required(schemaPath.password); + required(schemaPath.confirmPassword); + + // Compare fields + validate(schemaPath.confirmPassword, ({ value, valueOf }) => { + if (value() !== valueOf(schemaPath.password)) { + return { kind: 'mismatch', message: 'Passwords do not match' }; + } + return null; + }); +}); +``` + +### Async Validation + +```typescript +import { validateHttp } from '@angular/forms/signals'; + +const signupForm = form(this.signupModel, (schemaPath) => { + validateHttp(schemaPath.username, { + request: ({ value }) => `/api/check-username?u=${value()}`, + onSuccess: (response: { taken: boolean }) => { + if (response.taken) { + return { kind: 'taken', message: 'Username already taken' }; + } + return null; + }, + onError: () => ({ + kind: 'networkError', + message: 'Could not verify username', + }), + }); +}); +``` + +## Conditional Fields + +### Hidden Fields + +```typescript +import { hidden } from '@angular/forms/signals'; + +const profileForm = form(this.profileModel, (schemaPath) => { + hidden(schemaPath.publicUrl, ({ valueOf }) => !valueOf(schemaPath.isPublic)); +}); +``` + +```html +@if (!profileForm.publicUrl().hidden()) { + +} +``` + +### Disabled Fields + +```typescript +import { disabled } from '@angular/forms/signals'; + +const orderForm = form(this.orderModel, (schemaPath) => { + disabled(schemaPath.couponCode, ({ valueOf }) => valueOf(schemaPath.total) < 50); +}); +``` + +### Readonly Fields + +```typescript +import { readonly } from '@angular/forms/signals'; + +const accountForm = form(this.accountModel, (schemaPath) => { + readonly(schemaPath.username); // Always readonly +}); +``` + +## Form Submission + +```typescript +import { submit } from '@angular/forms/signals'; + +@Component({ + template: ` +
+ + + +
+ `, +}) +export class Login { + model = signal({ email: '', password: '' }); + form = form(this.model, (schemaPath) => { + required(schemaPath.email); + required(schemaPath.password); + }); + + onSubmit(event: Event) { + event.preventDefault(); + + // submit() marks all fields touched and runs callback if valid + submit(this.form, async () => { + await this.authService.login(this.model()); + }); + } +} +``` + +## Arrays and Dynamic Fields + +```typescript +interface Order { + items: Array<{ product: string; quantity: number }>; +} + +@Component({ + template: ` + @for (item of orderForm.items; track $index; let i = $index) { +
+ + + +
+ } + + `, +}) +export class Order { + orderModel = signal({ + items: [{ product: '', quantity: 1 }], + }); + + orderForm = form(this.orderModel, (schemaPath) => { + applyEach(schemaPath.items, (item) => { + required(item.product, { message: 'Product required' }); + min(item.quantity, 1, { message: 'Min quantity is 1' }); + }); + }); + + addItem() { + this.orderModel.update(m => ({ + ...m, + items: [...m.items, { product: '', quantity: 1 }], + })); + } + + removeItem(index: number) { + this.orderModel.update(m => ({ + ...m, + items: m.items.filter((_, i) => i !== index), + })); + } +} +``` + +## Displaying Errors + +```html + + +@if (form.email().touched() && form.email().invalid()) { +
    + @for (error of form.email().errors(); track error) { +
  • {{ error.message }}
  • + } +
+} + +@if (form.email().pending()) { + Validating... +} +``` + +## Styling Based on State + +```html + +``` + +## Reset Form + +```typescript +async onSubmit() { + if (!this.form().valid()) return; + + await this.api.submit(this.model()); + + // Clear interaction state + this.form().reset(); + + // Clear values + this.model.set({ email: '', password: '' }); +} +``` + +For Reactive Forms patterns (production-stable), see [references/form-patterns.md](references/form-patterns.md). diff --git a/.agents/skills/angular-forms/references/form-patterns.md b/.agents/skills/angular-forms/references/form-patterns.md new file mode 100644 index 0000000..522f9b8 --- /dev/null +++ b/.agents/skills/angular-forms/references/form-patterns.md @@ -0,0 +1,405 @@ +# Angular Form Patterns + +## Table of Contents +- [Reactive Forms (Production-Stable)](#reactive-forms-production-stable) +- [Typed Reactive Forms](#typed-reactive-forms) +- [FormBuilder Patterns](#formbuilder-patterns) +- [Dynamic Forms with FormArray](#dynamic-forms-with-formarray) +- [Custom Validators](#custom-validators) +- [Form State Management](#form-state-management) + +## Reactive Forms (Production-Stable) + +For production applications requiring stability guarantees, use Reactive Forms: + +```typescript +import { Component, inject } from '@angular/core'; +import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms'; + +@Component({ + selector: 'app-login', + imports: [ReactiveFormsModule], + template: ` +
+ + @if (form.controls.email.errors?.['required'] && form.controls.email.touched) { + Email is required + } + + + + +
+ `, +}) +export class Login { + private fb = inject(FormBuilder); + + form = this.fb.group({ + email: ['', [Validators.required, Validators.email]], + password: ['', [Validators.required, Validators.minLength(8)]], + }); + + onSubmit() { + if (this.form.valid) { + console.log(this.form.value); + } + } +} +``` + +## Typed Reactive Forms + +### Typed FormControl + +```typescript +import { FormControl } from '@angular/forms'; + +// Inferred type: FormControl +const name = new FormControl(''); + +// Non-nullable (no reset to null) +const email = new FormControl('', { nonNullable: true }); +// Type: FormControl + +// With validators +const username = new FormControl('', { + nonNullable: true, + validators: [Validators.required, Validators.minLength(3)], +}); +``` + +### Typed FormGroup + +```typescript +import { FormGroup, FormControl } from '@angular/forms'; + +interface UserForm { + name: FormControl; + email: FormControl; + age: FormControl; +} + +const form = new FormGroup({ + name: new FormControl('', { nonNullable: true }), + email: new FormControl('', { nonNullable: true }), + age: new FormControl(null), +}); + +// Typed value access +const name: string = form.controls.name.value; +``` + +### NonNullableFormBuilder + +```typescript +import { inject } from '@angular/core'; +import { NonNullableFormBuilder } from '@angular/forms'; + +@Component({...}) +export class Profile { + private fb = inject(NonNullableFormBuilder); + + form = this.fb.group({ + name: ['', Validators.required], // FormControl + email: ['', [Validators.required, Validators.email]], + preferences: this.fb.group({ + newsletter: [false], // FormControl + theme: ['light' as 'light' | 'dark'], // FormControl<'light' | 'dark'> + }), + }); +} +``` + +## FormBuilder Patterns + +### Nested FormGroups + +```typescript +@Component({ + imports: [ReactiveFormsModule], + template: ` +
+ + +
+ + + +
+ + +
+ `, +}) +export class Profile { + private fb = inject(NonNullableFormBuilder); + + form = this.fb.group({ + name: ['', Validators.required], + address: this.fb.group({ + street: [''], + city: ['', Validators.required], + zip: ['', [Validators.required, Validators.pattern(/^\d{5}$/)]], + }), + }); +} +``` + +## Dynamic Forms with FormArray + +```typescript +import { FormArray } from '@angular/forms'; + +@Component({ + imports: [ReactiveFormsModule], + template: ` +
+
+ @for (item of items.controls; track $index; let i = $index) { +
+ + + +
+ } +
+ +
+ `, +}) +export class Order { + private fb = inject(NonNullableFormBuilder); + + form = this.fb.group({ + items: this.fb.array([this.createItem()]), + }); + + get items() { + return this.form.controls.items; + } + + createItem() { + return this.fb.group({ + product: ['', Validators.required], + quantity: [1, [Validators.required, Validators.min(1)]], + }); + } + + addItem() { + this.items.push(this.createItem()); + } + + removeItem(index: number) { + this.items.removeAt(index); + } +} +``` + +## Custom Validators + +### Sync Validator + +```typescript +import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms'; + +export function forbiddenValue(forbidden: string): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + return control.value === forbidden + ? { forbiddenValue: { value: control.value } } + : null; + }; +} + +// Usage +name: ['', [Validators.required, forbiddenValue('admin')]], +``` + +### Cross-Field Validator + +```typescript +export function passwordMatch(): ValidatorFn { + return (group: AbstractControl): ValidationErrors | null => { + const password = group.get('password')?.value; + const confirm = group.get('confirmPassword')?.value; + return password === confirm ? null : { passwordMismatch: true }; + }; +} + +// Usage +form = this.fb.group({ + password: ['', [Validators.required, Validators.minLength(8)]], + confirmPassword: ['', Validators.required], +}, { validators: passwordMatch() }); +``` + +### Async Validator + +```typescript +import { AsyncValidatorFn } from '@angular/forms'; +import { map, catchError, of } from 'rxjs'; + +export function uniqueEmail(userService: User): AsyncValidatorFn { + return (control: AbstractControl) => { + return userService.checkEmail(control.value).pipe( + map(exists => exists ? { emailTaken: true } : null), + catchError(() => of(null)) + ); + }; +} + +// Usage +email: ['', + [Validators.required, Validators.email], // sync validators + [uniqueEmail(this.userService)] // async validators +], +``` + +## Form State Management + +### State Properties + +```typescript +// Check states +form.valid // All validations pass +form.invalid // Has validation errors +form.pending // Async validation in progress +form.dirty // Value changed by user +form.pristine // Value not changed +form.touched // Control has been focused +form.untouched // Control never focused + +// Update values +form.setValue({ name: 'John', email: 'john@example.com' }); // Must include all +form.patchValue({ name: 'John' }); // Partial update + +// Reset +form.reset(); +form.reset({ name: 'Default' }); + +// Disable/Enable +form.disable(); +form.enable(); +form.controls.email.disable(); + +// Mark states +form.markAllAsTouched(); // Show all errors +form.markAsPristine(); +form.markAsDirty(); +``` + +### Value Changes Observable + +```typescript +// Subscribe to value changes +form.valueChanges.subscribe(value => { + console.log('Form value:', value); +}); + +// Single control with debounce +form.controls.email.valueChanges.pipe( + debounceTime(300), + distinctUntilChanged() +).subscribe(email => { + this.validateEmail(email); +}); + +// Status changes +form.statusChanges.subscribe(status => { + console.log('Form status:', status); // VALID, INVALID, PENDING +}); +``` + +### Unified Events (Angular v18+) + +```typescript +import { + ValueChangeEvent, StatusChangeEvent, + PristineChangeEvent,TouchedChangeEvent, + FormSubmittedEvent, FormResetEvent +} from '@angular/forms'; + +form.events.subscribe(event => { + if (event instanceof ValueChangeEvent) { + console.log('Value changed:', event.value); + } + if (event instanceof StatusChangeEvent) { + console.log('Status changed:', event.status); + } + if (event instanceof PristineChangeEvent) { + console.log('Pristine changed:', event.pristine); + } + if (event instanceof TouchedChangeEvent) { + console.log('Touched changed:', event.touched); + } + if (event instanceof FormSubmittedEvent) { + console.log('Form submitted'); + } + if (event instanceof FormResetEvent) { + console.log('Form reset'); + } +}); +``` + +## Error Display Pattern + +```typescript +@Component({ + template: ` + + + @if (form.controls.email.invalid && form.controls.email.touched) { +
+ @if (form.controls.email.errors?.['required']) { + Email is required + } + @if (form.controls.email.errors?.['email']) { + Invalid email format + } +
+ } + `, +}) +export class Form { + // Helper for cleaner templates + hasError(controlName: string, errorKey: string): boolean { + const control = this.form.get(controlName); + return control?.hasError(errorKey) && control?.touched || false; + } +} +``` + +## Form Submission Pattern + +```typescript +@Component({ + template: ` +
+ + +
+ `, +}) +export class Form { + isSubmitting = false; + + async onSubmit() { + if (this.form.invalid) { + this.form.markAllAsTouched(); + return; + } + + this.isSubmitting = true; + try { + await this.api.submit(this.form.getRawValue()); + this.form.reset(); + } catch (error) { + // Handle error + } finally { + this.isSubmitting = false; + } + } +} +``` diff --git a/.agents/skills/angular-forms/references/formvalueControl-patterns.md b/.agents/skills/angular-forms/references/formvalueControl-patterns.md new file mode 100644 index 0000000..ef9f4af --- /dev/null +++ b/.agents/skills/angular-forms/references/formvalueControl-patterns.md @@ -0,0 +1,110 @@ +# Angular Signal Forms - ( FormValueControl ) + +## Table of Contents +- [Signal Form FormValueControl](#formValueControl) + +## Signal Forms FormValueControl + +``` typescript + +interface Rating { + rating : number +} + +import { form, FormField, FormValueControl, ValidationError, WithOptionalField } from '@angular/forms/signals'; +import { MatIconModule } from '@angular/material/icon'; +import { MatError } from '@angular/material/form-field'; + + +@Component({ + selector: 'app-rating', + imports : [MatIconModule,MatError], + template: ` +
+ @for (star of starArray(); track $index) { + + {{ getStarIcon(star) }} + + } + @if (errors().at(0)?.message) { + + {{ errors().at(0)?.message }} + + } +
+ `, + styles: ``, +}) +export class Rating implements FormValueControl { + // Required: The value of the control, exposed as a two-way binding. + readonly value = model(0); + // Optional: Bindings for other form control states. + readonly readonly = input(false); + readonly invalid = input(false); + readonly errors: InputSignal[]> = input< + readonly WithOptionalField[] + >([]); + + starArray: Signal = signal( + Array(5) + .fill(0) + .map((_, i) => i + 1), + ); + + getStarIcon(index: number): string { + const floorRating = Math.floor(this.value()); + if (index <= floorRating) { + return 'star'; // Full star + } else { + return 'star_border'; // Empty star + } + } + rate(index: number): void { + if (!this.readonly()) { + this.value.set(index); + } + } +} + + +import { FormField } from '@angular/forms/signals'; + +@Component({ + selector: 'app-signal-forms', + imports : [FormField, Rating], + template: ` +
+
+ + + + + {{ratingForm.rating().value()}} +
+
+ `, + styles: ``, +}) +export class SignalForms { + readonly ratingModel = signal({ + rating: 0, + }); + + readonly ratingForm = form(this.ratingModel) + + submit(event: Event): void { + event.preventDefault(); + console.log(this.ratingForm.rating().value()); + } +} + + + +``` + diff --git a/.agents/skills/angular-http/SKILL.md b/.agents/skills/angular-http/SKILL.md new file mode 100644 index 0000000..56fe269 --- /dev/null +++ b/.agents/skills/angular-http/SKILL.md @@ -0,0 +1,366 @@ +--- +name: angular-http +description: Implement HTTP data fetching in Angular v20+ using resource(), httpResource(), and HttpClient. Use for API calls, data loading with signals, request/response handling, and interceptors. Triggers on data fetching, API integration, loading states, error handling, or converting Observable-based HTTP to signal-based patterns. +--- + +# Angular HTTP & Data Fetching + +Fetch data in Angular using signal-based `resource()`, `httpResource()`, and the traditional `HttpClient`. + +## httpResource() - Signal-Based HTTP + +`httpResource()` wraps HttpClient with signal-based state management: + +```typescript +import { Component, signal } from '@angular/core'; +import { httpResource } from '@angular/common/http'; + +interface User { + id: number; + name: string; + email: string; +} + +@Component({ + selector: 'app-user-profile', + template: ` + @if (userResource.isLoading()) { +

Loading...

+ } @else if (userResource.error()) { +

Error: {{ userResource.error()?.message }}

+ + } @else if (userResource.hasValue()) { +

{{ userResource.value().name }}

+

{{ userResource.value().email }}

+ } + `, +}) +export class UserProfile { + userId = signal('123'); + + // Reactive HTTP resource - refetches when userId changes + userResource = httpResource(() => `/api/users/${this.userId()}`); +} +``` + +### httpResource Options + +```typescript +// Simple GET request +userResource = httpResource(() => `/api/users/${this.userId()}`); + +// With full request options +userResource = httpResource(() => ({ + url: `/api/users/${this.userId()}`, + method: 'GET', + headers: { 'Authorization': `Bearer ${this.token()}` }, + params: { include: 'profile' }, +})); + +// With default value +usersResource = httpResource(() => '/api/users', { + defaultValue: [], +}); + +// Skip request when params undefined +userResource = httpResource(() => { + const id = this.userId(); + return id ? `/api/users/${id}` : undefined; +}); +``` + +### Resource State + +```typescript +// Status signals +userResource.value() // Current value or undefined +userResource.hasValue() // Boolean - has resolved value +userResource.error() // Error or undefined +userResource.isLoading() // Boolean - currently loading +userResource.status() // 'idle' | 'loading' | 'reloading' | 'resolved' | 'error' | 'local' + +// Actions +userResource.reload() // Manually trigger reload +userResource.set(value) // Set local value +userResource.update(fn) // Update local value +``` + +## resource() - Generic Async Data + +For non-HTTP async operations or custom fetch logic: + +```typescript +import { resource, signal } from '@angular/core'; + +@Component({...}) +export class Search { + query = signal(''); + + searchResource = resource({ + // Reactive params - triggers reload when changed + params: () => ({ q: this.query() }), + + // Async loader function + loader: async ({ params, abortSignal }) => { + if (!params.q) return []; + + const response = await fetch(`/api/search?q=${params.q}`, { + signal: abortSignal, + }); + return response.json() as Promise; + }, + }); +} +``` + +### Resource with Default Value + +```typescript +todosResource = resource({ + defaultValue: [] as Todo[], + params: () => ({ filter: this.filter() }), + loader: async ({ params }) => { + const res = await fetch(`/api/todos?filter=${params.filter}`); + return res.json(); + }, +}); + +// value() returns Todo[] (never undefined) +``` + +### Conditional Loading + +```typescript +const userId = signal(null); + +userResource = resource({ + params: () => { + const id = userId(); + // Return undefined to skip loading + return id ? { id } : undefined; + }, + loader: async ({ params }) => { + return fetch(`/api/users/${params.id}`).then(r => r.json()); + }, +}); +// Status is 'idle' when params returns undefined +``` + +## HttpClient - Traditional Approach + +For complex scenarios or when you need Observable operators: + +```typescript +import { Component, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { toSignal } from '@angular/core/rxjs-interop'; + +@Component({...}) +export class Users { + private http = inject(HttpClient); + + // Convert Observable to Signal + users = toSignal( + this.http.get('/api/users'), + { initialValue: [] } + ); + + // Or use Observable directly + users$ = this.http.get('/api/users'); +} +``` + +### HTTP Methods + +```typescript +private http = inject(HttpClient); + +// GET +getUser(id: string) { + return this.http.get(`/api/users/${id}`); +} + +// POST +createUser(user: CreateUserDto) { + return this.http.post('/api/users', user); +} + +// PUT +updateUser(id: string, user: UpdateUserDto) { + return this.http.put(`/api/users/${id}`, user); +} + +// PATCH +patchUser(id: string, changes: Partial) { + return this.http.patch(`/api/users/${id}`, changes); +} + +// DELETE +deleteUser(id: string) { + return this.http.delete(`/api/users/${id}`); +} +``` + +### Request Options + +```typescript +this.http.get('/api/users', { + headers: { + 'Authorization': 'Bearer token', + 'Content-Type': 'application/json', + }, + params: { + page: '1', + limit: '10', + sort: 'name', + }, + observe: 'response', // Get full HttpResponse + responseType: 'json', +}); +``` + +## Interceptors + +### Functional Interceptor (Recommended) + +```typescript +// auth.interceptor.ts +import { HttpInterceptorFn } from '@angular/common/http'; +import { inject } from '@angular/core'; + +export const authInterceptor: HttpInterceptorFn = (req, next) => { + const authService = inject(Auth); + const token = authService.token(); + + if (token) { + req = req.clone({ + setHeaders: { Authorization: `Bearer ${token}` }, + }); + } + + return next(req); +}; + +// error.interceptor.ts +export const errorInterceptor: HttpInterceptorFn = (req, next) => { + return next(req).pipe( + catchError((error: HttpErrorResponse) => { + if (error.status === 401) { + inject(Router).navigate(['/login']); + } + return throwError(() => error); + }) + ); +}; + +// logging.interceptor.ts +export const loggingInterceptor: HttpInterceptorFn = (req, next) => { + const started = Date.now(); + return next(req).pipe( + tap({ + next: () => console.log(`${req.method} ${req.url} - ${Date.now() - started}ms`), + error: (err) => console.error(`${req.method} ${req.url} failed`, err), + }) + ); +}; +``` + +### Register Interceptors + +```typescript +// app.config.ts +import { provideHttpClient, withInterceptors } from '@angular/common/http'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideHttpClient( + withInterceptors([ + authInterceptor, + errorInterceptor, + loggingInterceptor, + ]) + ), + ], +}; +``` + +## Error Handling + +### With httpResource + +```typescript +@Component({ + template: ` + @if (userResource.error(); as error) { +
+

{{ getErrorMessage(error) }}

+ +
+ } + `, +}) +export class UserCmpt { + userResource = httpResource(() => `/api/users/${this.userId()}`); + + getErrorMessage(error: unknown): string { + if (error instanceof HttpErrorResponse) { + return error.error?.message || `Error ${error.status}: ${error.statusText}`; + } + return 'An unexpected error occurred'; + } +} +``` + +### With HttpClient + +```typescript +import { catchError, retry } from 'rxjs'; + +getUser(id: string) { + return this.http.get(`/api/users/${id}`).pipe( + retry(2), // Retry up to 2 times + catchError((error: HttpErrorResponse) => { + console.error('Error fetching user:', error); + return throwError(() => new Error('Failed to load user')); + }) + ); +} +``` + +## Loading States Pattern + +```typescript +@Component({ + template: ` + @switch (dataResource.status()) { + @case ('idle') { +

Enter a search term

+ } + @case ('loading') { + + } + @case ('reloading') { + + + } + @case ('resolved') { + + } + @case ('error') { + + } + } + `, +}) +export class Data { + query = signal(''); + dataResource = httpResource(() => + this.query() ? `/api/search?q=${this.query()}` : undefined + ); +} +``` + +For advanced patterns, see [references/http-patterns.md](references/http-patterns.md). diff --git a/.agents/skills/angular-http/references/http-patterns.md b/.agents/skills/angular-http/references/http-patterns.md new file mode 100644 index 0000000..1791060 --- /dev/null +++ b/.agents/skills/angular-http/references/http-patterns.md @@ -0,0 +1,448 @@ +# Angular HTTP Patterns + +## Table of Contents +- [Service Layer Pattern](#service-layer-pattern) +- [Caching Strategies](#caching-strategies) +- [Pagination](#pagination) +- [File Upload](#file-upload) +- [Request Cancellation](#request-cancellation) +- [Testing HTTP](#testing-http) + +## Service Layer Pattern + +Encapsulate HTTP logic in services: + +```typescript +import { Injectable, inject, signal, computed } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { httpResource } from '@angular/common/http'; + +export interface User { + id: string; + name: string; + email: string; +} + +@Injectable({ providedIn: 'root' }) +export class User { + private http = inject(HttpClient); + private baseUrl = '/api/users'; + + // Current user ID for reactive fetching + private currentUserId = signal(null); + + // Reactive resource that updates when currentUserId changes + currentUser = httpResource(() => { + const id = this.currentUserId(); + return id ? `${this.baseUrl}/${id}` : undefined; + }); + + // Set current user to fetch + selectUser(id: string) { + this.currentUserId.set(id); + } + + // CRUD operations + getAll() { + return this.http.get(this.baseUrl); + } + + getById(id: string) { + return this.http.get(`${this.baseUrl}/${id}`); + } + + create(user: Omit) { + return this.http.post(this.baseUrl, user); + } + + update(id: string, user: Partial) { + return this.http.patch(`${this.baseUrl}/${id}`, user); + } + + delete(id: string) { + return this.http.delete(`${this.baseUrl}/${id}`); + } +} +``` + +## Caching Strategies + +### Simple In-Memory Cache + +```typescript +@Injectable({ providedIn: 'root' }) +export class CachedUser { + private http = inject(HttpClient); + private cache = new Map(); + private cacheDuration = 5 * 60 * 1000; // 5 minutes + + getUser(id: string): Observable { + const cached = this.cache.get(id); + + if (cached && Date.now() - cached.timestamp < this.cacheDuration) { + return of(cached.data); + } + + return this.http.get(`/api/users/${id}`).pipe( + tap(user => { + this.cache.set(id, { data: user, timestamp: Date.now() }); + }) + ); + } + + invalidateCache(id?: string) { + if (id) { + this.cache.delete(id); + } else { + this.cache.clear(); + } + } +} +``` + +### Signal-Based Cache + +```typescript +@Injectable({ providedIn: 'root' }) +export class UserCache { + private http = inject(HttpClient); + + // Cache as signal + private usersCache = signal>(new Map()); + + // Computed for easy access + users = computed(() => Array.from(this.usersCache().values())); + + getUser(id: string): User | undefined { + return this.usersCache().get(id); + } + + async fetchUser(id: string): Promise { + const cached = this.getUser(id); + if (cached) return cached; + + const user = await firstValueFrom( + this.http.get(`/api/users/${id}`) + ); + + this.usersCache.update(cache => { + const newCache = new Map(cache); + newCache.set(id, user); + return newCache; + }); + + return user; + } +} +``` + +## Pagination + +### Paginated Resource + +```typescript +interface PaginatedResponse { + data: T[]; + total: number; + page: number; + pageSize: number; + totalPages: number; +} + +@Component({ + template: ` + @if (usersResource.isLoading()) { + + } @else if (usersResource.hasValue()) { +
    + @for (user of usersResource.value().data; track user.id) { +
  • {{ user.name }}
  • + } +
+ + + } + `, +}) +export class UsersList { + page = signal(1); + pageSize = signal(10); + + usersResource = httpResource>(() => ({ + url: '/api/users', + params: { + page: this.page().toString(), + pageSize: this.pageSize().toString(), + }, + })); + + nextPage() { + this.page.update(p => p + 1); + } + + prevPage() { + this.page.update(p => Math.max(1, p - 1)); + } +} +``` + +### Infinite Scroll + +```typescript +@Component({ + template: ` +
    + @for (user of allUsers(); track user.id) { +
  • {{ user.name }}
  • + } +
+ + @if (isLoading()) { + + } + + @if (hasMore()) { + + } + `, +}) +export class InfiniteUsers { + private http = inject(HttpClient); + + private page = signal(1); + private users = signal([]); + private totalPages = signal(1); + + allUsers = this.users.asReadonly(); + isLoading = signal(false); + hasMore = computed(() => this.page() < this.totalPages()); + + constructor() { + this.loadPage(1); + } + + loadMore() { + this.loadPage(this.page() + 1); + } + + private async loadPage(page: number) { + this.isLoading.set(true); + + try { + const response = await firstValueFrom( + this.http.get>('/api/users', { + params: { page: page.toString(), pageSize: '20' }, + }) + ); + + this.users.update(users => [...users, ...response.data]); + this.page.set(page); + this.totalPages.set(response.totalPages); + } finally { + this.isLoading.set(false); + } + } +} +``` + +## File Upload + +### Single File Upload + +```typescript +@Component({ + template: ` + + + @if (uploadProgress() !== null) { + + } + `, +}) +export class FileUpload { + private http = inject(HttpClient); + + uploadProgress = signal(null); + + onFileSelected(event: Event) { + const file = (event.target as HTMLInputElement).files?.[0]; + if (!file) return; + + const formData = new FormData(); + formData.append('file', file); + + this.http.post('/api/upload', formData, { + reportProgress: true, + observe: 'events', + }).subscribe(event => { + if (event.type === HttpEventType.UploadProgress && event.total) { + this.uploadProgress.set(Math.round(100 * event.loaded / event.total)); + } else if (event.type === HttpEventType.Response) { + this.uploadProgress.set(null); + console.log('Upload complete:', event.body); + } + }); + } +} +``` + +### Multiple Files + +```typescript +uploadFiles(files: FileList) { + const formData = new FormData(); + + for (let i = 0; i < files.length; i++) { + formData.append('files', files[i]); + } + + return this.http.post<{ urls: string[] }>('/api/upload-multiple', formData); +} +``` + +## Request Cancellation + +### With resource() + +```typescript +// resource() automatically handles cancellation via abortSignal +searchResource = resource({ + params: () => ({ q: this.query() }), + loader: async ({ params, abortSignal }) => { + const response = await fetch(`/api/search?q=${params.q}`, { + signal: abortSignal, // Cancels if params change + }); + return response.json(); + }, +}); +``` + +### With HttpClient + +```typescript +@Component({...}) +export class Search implements OnDestroy { + private http = inject(HttpClient); + private destroyRef = inject(DestroyRef); + + query = signal(''); + results = signal([]); + + private searchSubscription?: Subscription; + + search() { + // Cancel previous request + this.searchSubscription?.unsubscribe(); + + this.searchSubscription = this.http + .get(`/api/search?q=${this.query()}`) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(results => this.results.set(results)); + } +} +``` + +### Debounced Search + +```typescript +@Component({...}) +export class SearchDebounced { + query = signal(''); + + private http = inject(HttpClient); + + results = toSignal( + toObservable(this.query).pipe( + debounceTime(300), + distinctUntilChanged(), + filter(q => q.length >= 2), + switchMap(q => this.http.get(`/api/search?q=${q}`)), + catchError(() => of([])) + ), + { initialValue: [] } + ); +} +``` + +## Testing HTTP + +### Testing httpResource + +```typescript +describe('UserCmpt', () => { + let component: UserCmpt; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [UserCmpt], + providers: [provideHttpClientTesting()], + }); + + component = TestBed.createComponent(UserCmpt).componentInstance; + httpMock = TestBed.inject(HttpTestingController); + }); + + it('should load user', () => { + component.userId.set('123'); + + const req = httpMock.expectOne('/api/users/123'); + req.flush({ id: '123', name: 'Test User' }); + + expect(component.userResource.value()?.name).toBe('Test User'); + }); + + afterEach(() => { + httpMock.verify(); + }); +}); +``` + +### Testing Services + +```typescript +describe('User', () => { + let service: User; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + User, + provideHttpClient(), + provideHttpClientTesting(), + ], + }); + + service = TestBed.inject(User); + httpMock = TestBed.inject(HttpTestingController); + }); + + it('should create user', () => { + const newUser = { name: 'Test', email: 'test@example.com' }; + + service.create(newUser).subscribe(user => { + expect(user.id).toBeDefined(); + expect(user.name).toBe('Test'); + }); + + const req = httpMock.expectOne('/api/users'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual(newUser); + + req.flush({ id: '1', ...newUser }); + }); +}); +``` diff --git a/.agents/skills/angular-routing/SKILL.md b/.agents/skills/angular-routing/SKILL.md new file mode 100644 index 0000000..5cfefe2 --- /dev/null +++ b/.agents/skills/angular-routing/SKILL.md @@ -0,0 +1,395 @@ +--- +name: angular-routing +description: Implement routing in Angular v20+ applications with lazy loading, functional guards, resolvers, and route parameters. Use for navigation setup, protected routes, route-based data loading, and nested routing. Triggers on route configuration, adding authentication guards, implementing lazy loading, or reading route parameters with signals. +--- + +# Angular Routing + +Configure routing in Angular v20+ with lazy loading, functional guards, and signal-based route parameters. + +## Basic Setup + +```typescript +// app.routes.ts +import { Routes } from '@angular/router'; + +export const routes: Routes = [ + { path: '', redirectTo: '/home', pathMatch: 'full' }, + { path: 'home', component: Home }, + { path: 'about', component: About }, + { path: '**', component: NotFound }, +]; + +// app.config.ts +import { ApplicationConfig } from '@angular/core'; +import { provideRouter } from '@angular/router'; +import { routes } from './app.routes'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideRouter(routes), + ], +}; + +// app.component.ts +import { Component } from '@angular/core'; +import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router'; + +@Component({ + selector: 'app-root', + imports: [RouterOutlet, RouterLink, RouterLinkActive], + template: ` + + + `, +}) +export class App {} +``` + +## Lazy Loading + +Load feature modules on demand: + +```typescript +// app.routes.ts +export const routes: Routes = [ + { path: '', redirectTo: '/home', pathMatch: 'full' }, + { path: 'home', component: Home }, + + // Lazy load entire feature + { + path: 'admin', + loadChildren: () => import('./admin/admin.routes').then(m => m.adminRoutes), + }, + + // Lazy load single component + { + path: 'settings', + loadComponent: () => import('./settings/settings.component').then(m => m.Settings), + }, +]; + +// admin/admin.routes.ts +export const adminRoutes: Routes = [ + { path: '', component: AdminDashboard }, + { path: 'users', component: AdminUsers }, + { path: 'settings', component: AdminSettings }, +]; +``` + +## Route Parameters + +### With Signal Inputs (Recommended) + +```typescript +// Route config +{ path: 'users/:id', component: UserDetail } + +// Component - use input() for route params +import { Component, input, computed } from '@angular/core'; + +@Component({ + selector: 'app-user-detail', + template: ` +

User {{ id() }}

+ `, +}) +export class UserDetail { + // Route param as signal input + id = input.required(); + + // Computed based on route param + userId = computed(() => parseInt(this.id(), 10)); +} +``` + +Enable with `withComponentInputBinding()`: + +```typescript +// app.config.ts +import { provideRouter, withComponentInputBinding } from '@angular/router'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideRouter(routes, withComponentInputBinding()), + ], +}; +``` + +### Query Parameters + +```typescript +// Route: /search?q=angular&page=1 + +@Component({...}) +export class Search { + // Query params as inputs + q = input(''); + page = input('1'); + + currentPage = computed(() => parseInt(this.page(), 10)); +} +``` + +### With ActivatedRoute (Alternative) + +```typescript +import { Component, inject } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { map } from 'rxjs'; + +@Component({...}) +export class UserDetail { + private route = inject(ActivatedRoute); + + // Convert route params to signal + id = toSignal( + this.route.paramMap.pipe(map(params => params.get('id'))), + { initialValue: null } + ); + + // Query params + query = toSignal( + this.route.queryParamMap.pipe(map(params => params.get('q'))), + { initialValue: '' } + ); +} +``` + +## Functional Guards + +### Auth Guard + +```typescript +// guards/auth.guard.ts +import { inject } from '@angular/core'; +import { CanActivateFn, Router } from '@angular/router'; + +export const authGuard: CanActivateFn = (route, state) => { + const authService = inject(Auth); + const router = inject(Router); + + if (authService.isAuthenticated()) { + return true; + } + + // Redirect to login with return URL + return router.createUrlTree(['/login'], { + queryParams: { returnUrl: state.url }, + }); +}; + +// Usage in routes +{ + path: 'dashboard', + component: Dashboard, + canActivate: [authGuard], +} +``` + +### Role Guard + +```typescript +export const roleGuard = (allowedRoles: string[]): CanActivateFn => { + return (route, state) => { + const authService = inject(Auth); + const router = inject(Router); + + const userRole = authService.currentUser()?.role; + + if (userRole && allowedRoles.includes(userRole)) { + return true; + } + + return router.createUrlTree(['/unauthorized']); + }; +}; + +// Usage +{ + path: 'admin', + component: Admin, + canActivate: [authGuard, roleGuard(['admin', 'superadmin'])], +} +``` + +### Can Deactivate Guard + +```typescript +export interface CanDeactivate { + canDeactivate: () => boolean | Promise; +} + +export const unsavedChangesGuard: CanDeactivateFn = (component) => { + if (component.canDeactivate()) { + return true; + } + + return confirm('You have unsaved changes. Leave anyway?'); +}; + +// Component implementation +@Component({...}) +export class Edit implements CanDeactivate { + form = inject(FormBuilder).group({...}); + + canDeactivate(): boolean { + return !this.form.dirty; + } +} + +// Route +{ + path: 'edit/:id', + component: Edit, + canDeactivate: [unsavedChangesGuard], +} +``` + +## Resolvers + +Pre-fetch data before route activation: + +```typescript +// resolvers/user.resolver.ts +import { inject } from '@angular/core'; +import { ResolveFn } from '@angular/router'; + +export const userResolver: ResolveFn = (route) => { + const userService = inject(User); + const id = route.paramMap.get('id')!; + return userService.getById(id); +}; + +// Route config +{ + path: 'users/:id', + component: UserDetail, + resolve: { user: userResolver }, +} + +// Component - access resolved data via input +@Component({...}) +export class UserDetail { + user = input.required(); +} +``` + +## Nested Routes + +```typescript +// Parent route with children +export const routes: Routes = [ + { + path: 'products', + component: ProductsLayout, + children: [ + { path: '', component: ProductList }, + { path: ':id', component: ProductDetail }, + { path: ':id/edit', component: ProductEdit }, + ], + }, +]; + +// ProductsLayout +@Component({ + imports: [RouterOutlet], + template: ` +

Products

+ + `, +}) +export class ProductsLayout {} +``` + +## Programmatic Navigation + +```typescript +import { Component, inject } from '@angular/core'; +import { Router } from '@angular/router'; + +@Component({...}) +export class Product { + private router = inject(Router); + + // Navigate to route + goToProducts() { + this.router.navigate(['/products']); + } + + // Navigate with params + goToProduct(id: string) { + this.router.navigate(['/products', id]); + } + + // Navigate with query params + search(query: string) { + this.router.navigate(['/search'], { + queryParams: { q: query, page: 1 }, + }); + } + + // Navigate relative to current route + goToEdit() { + this.router.navigate(['edit'], { relativeTo: this.route }); + } + + // Replace current history entry + replaceUrl() { + this.router.navigate(['/new-page'], { replaceUrl: true }); + } +} +``` + +## Route Data + +```typescript +// Static route data +{ + path: 'admin', + component: Admin, + data: { + title: 'Admin Dashboard', + roles: ['admin'], + }, +} + +// Access in component +@Component({...}) +export class AdminCmpt { + title = input(); // From route data + roles = input(); // From route data +} + +// Or via ActivatedRoute +private route = inject(ActivatedRoute); +data = toSignal(this.route.data); +``` + +## Router Events + +```typescript +import { Router, NavigationStart, NavigationEnd } from '@angular/router'; +import { filter } from 'rxjs'; + +@Component({...}) +export class AppMain { + private router = inject(Router); + + isNavigating = signal(false); + + constructor() { + this.router.events.pipe( + filter(e => e instanceof NavigationStart || e instanceof NavigationEnd) + ).subscribe(event => { + this.isNavigating.set(event instanceof NavigationStart); + }); + } +} +``` + +For advanced patterns, see [references/routing-patterns.md](references/routing-patterns.md). diff --git a/.agents/skills/angular-routing/references/routing-patterns.md b/.agents/skills/angular-routing/references/routing-patterns.md new file mode 100644 index 0000000..7ddd6ee --- /dev/null +++ b/.agents/skills/angular-routing/references/routing-patterns.md @@ -0,0 +1,472 @@ +# Angular Routing Patterns + +## Table of Contents +- [Route Configuration Options](#route-configuration-options) +- [Authentication Flow](#authentication-flow) +- [Breadcrumbs](#breadcrumbs) +- [Tab Navigation](#tab-navigation) +- [Modal Routes](#modal-routes) +- [Preloading Strategies](#preloading-strategies) + +## Route Configuration Options + +### Full Route Options + +```typescript +{ + path: 'users/:id', + component: UserCmpt, + + // Lazy loading alternatives + loadComponent: () => import('./user.component').then(m => m.UserCmpt), + loadChildren: () => import('./user.routes').then(m => m.userRoutes), + + // Guards + canActivate: [authGuard], + canActivateChild: [authGuard], + canDeactivate: [unsavedChangesGuard], + canMatch: [featureFlagGuard], + + // Data + resolve: { user: userResolver }, + data: { title: 'User Profile', animation: 'userPage' }, + + // Children + children: [...], + + // Outlet + outlet: 'sidebar', + + // Path matching + pathMatch: 'full', // or 'prefix' + + // Title + title: 'User Profile', + // Or dynamic title + title: userTitleResolver, +} +``` + +### Dynamic Title Resolver + +```typescript +export const userTitleResolver: ResolveFn = (route) => { + const userService = inject(User); + const id = route.paramMap.get('id')!; + return userService.getById(id).pipe( + map(user => `${user.name} - Profile`) + ); +}; +``` + +## Authentication Flow + +### Complete Auth Setup + +```typescript +// auth.service.ts +@Injectable({ providedIn: 'root' }) +export class Auth { + private _user = signal(null); + private _token = signal(null); + + readonly user = this._user.asReadonly(); + readonly isAuthenticated = computed(() => this._user() !== null); + + private router = inject(Router); + private http = inject(HttpClient); + + async login(credentials: Credentials): Promise { + try { + const response = await firstValueFrom( + this.http.post('/api/login', credentials) + ); + + this._token.set(response.token); + this._user.set(response.user); + localStorage.setItem('token', response.token); + + return true; + } catch { + return false; + } + } + + logout(): void { + this._user.set(null); + this._token.set(null); + localStorage.removeItem('token'); + this.router.navigate(['/login']); + } + + async checkAuth(): Promise { + const token = localStorage.getItem('token'); + if (!token) return false; + + try { + const user = await firstValueFrom( + this.http.get('/api/me') + ); + this._user.set(user); + this._token.set(token); + return true; + } catch { + localStorage.removeItem('token'); + return false; + } + } +} + +// auth.guard.ts +export const authGuard: CanActivateFn = async (route, state) => { + const authService = inject(Auth); + const router = inject(Router); + + // Check if already authenticated + if (authService.isAuthenticated()) { + return true; + } + + // Try to restore session + const isValid = await authService.checkAuth(); + if (isValid) { + return true; + } + + // Redirect to login + return router.createUrlTree(['/login'], { + queryParams: { returnUrl: state.url }, + }); +}; + +// login.component.ts +@Component({ + template: ` +
+ + + +
+ `, +}) +export class Login { + private authService = inject(Auth); + private router = inject(Router); + private route = inject(ActivatedRoute); + + email = ''; + password = ''; + + async login() { + const success = await this.authService.login({ + email: this.email, + password: this.password, + }); + + if (success) { + const returnUrl = this.route.snapshot.queryParams['returnUrl'] || '/'; + this.router.navigateByUrl(returnUrl); + } + } +} +``` + +## Breadcrumbs + +```typescript +// breadcrumb.service.ts +@Injectable({ providedIn: 'root' }) +export class Breadcrumb { + private router = inject(Router); + private route = inject(ActivatedRoute); + + breadcrumbs = toSignal( + this.router.events.pipe( + filter(event => event instanceof NavigationEnd), + map(() => this.buildBreadcrumbs(this.route.root)) + ), + { initialValue: [] } + ); + + private buildBreadcrumbs( + route: ActivatedRoute, + url: string = '', + breadcrumbs: Breadcrumb[] = [] + ): Breadcrumb[] { + const children = route.children; + + if (children.length === 0) { + return breadcrumbs; + } + + for (const child of children) { + const routeUrl = child.snapshot.url + .map(segment => segment.path) + .join('/'); + + if (routeUrl) { + url += `/${routeUrl}`; + } + + const label = child.snapshot.data['breadcrumb']; + if (label) { + breadcrumbs.push({ label, url }); + } + + return this.buildBreadcrumbs(child, url, breadcrumbs); + } + + return breadcrumbs; + } +} + +// Route config with breadcrumb data +export const routes: Routes = [ + { + path: 'products', + data: { breadcrumb: 'Products' }, + children: [ + { path: '', component: ProductList }, + { + path: ':id', + data: { breadcrumb: 'Product Details' }, + component: ProductDetail, + }, + ], + }, +]; + +// breadcrumb.component.ts +@Component({ + selector: 'app-breadcrumb', + template: ` + + `, +}) +export class BreadcrumbCmpt { + breadcrumbService = inject(Breadcrumb); +} +``` + +## Tab Navigation + +```typescript +// tabs-layout.component.ts +@Component({ + imports: [RouterOutlet, RouterLink, RouterLinkActive], + template: ` +
+ @for (tab of tabs; track tab.path) { + + {{ tab.label }} + + } +
+
+ +
+ `, +}) +export class TabsLayout { + tabs = [ + { path: './', label: 'Overview', exact: true }, + { path: 'details', label: 'Details', exact: false }, + { path: 'settings', label: 'Settings', exact: false }, + ]; +} + +// Routes +{ + path: 'account', + component: TabsLayout, + children: [ + { path: '', component: AccountOverview }, + { path: 'details', component: AccountDetails }, + { path: 'settings', component: AccountSettings }, + ], +} +``` + +## Modal Routes + +Using auxiliary outlets for modals: + +```typescript +// Routes +export const routes: Routes = [ + { path: 'products', component: ProductList }, + { path: 'product-modal/:id', component: ProductModal, outlet: 'modal' }, +]; + +// App template +@Component({ + template: ` + + + `, +}) +export class App {} + +// Open modal +this.router.navigate([{ outlets: { modal: ['product-modal', productId] } }]); + +// Close modal +this.router.navigate([{ outlets: { modal: null } }]); + +// Link to open modal + + View Details + +``` + +## Preloading Strategies + +### Built-in Strategies + +```typescript +import { + provideRouter, + withPreloading, + PreloadAllModules, + NoPreloading +} from '@angular/router'; + +// Preload all lazy modules +provideRouter(routes, withPreloading(PreloadAllModules)) + +// No preloading (default) +provideRouter(routes, withPreloading(NoPreloading)) +``` + +### Custom Preloading Strategy + +```typescript +// selective-preload.strategy.ts +@Injectable({ providedIn: 'root' }) +export class SelectivePreloadStrategy implements PreloadingStrategy { + preload(route: Route, load: () => Observable): Observable { + // Only preload routes marked with data.preload = true + if (route.data?.['preload']) { + return load(); + } + return of(null); + } +} + +// Routes +{ + path: 'dashboard', + loadComponent: () => import('./dashboard.component'), + data: { preload: true }, // Will be preloaded +} + +// Config +provideRouter(routes, withPreloading(SelectivePreloadStrategy)) +``` + +### Network-Aware Preloading + +```typescript +@Injectable({ providedIn: 'root' }) +export class NetworkAwarePreloadStrategy implements PreloadingStrategy { + preload(route: Route, load: () => Observable): Observable { + // Check network conditions + const connection = (navigator as any).connection; + + if (connection) { + // Don't preload on slow connections + if (connection.saveData || connection.effectiveType === '2g') { + return of(null); + } + } + + // Preload if marked + if (route.data?.['preload']) { + return load(); + } + + return of(null); + } +} +``` + +## Route Animations + +```typescript +// app.routes.ts +export const routes: Routes = [ + { path: 'home', component: Home, data: { animation: 'HomePage' } }, + { path: 'about', component: About, data: { animation: 'AboutPage' } }, +]; + +// app.component.ts +@Component({ + imports: [RouterOutlet], + template: ` +
+ +
+ `, + animations: [ + trigger('routeAnimations', [ + transition('HomePage <=> AboutPage', [ + style({ position: 'relative' }), + query(':enter, :leave', [ + style({ + position: 'absolute', + top: 0, + left: 0, + width: '100%', + }), + ]), + query(':enter', [style({ left: '-100%' })]), + query(':leave', animateChild()), + group([ + query(':leave', [animate('300ms ease-out', style({ left: '100%' }))]), + query(':enter', [animate('300ms ease-out', style({ left: '0%' }))]), + ]), + ]), + ]), + ], +}) +export class AppMain { + getRouteAnimationData() { + return this.route.firstChild?.snapshot.data['animation']; + } +} +``` + +## Scroll Position Restoration + +```typescript +// app.config.ts +import { + provideRouter, + withInMemoryScrolling, + withRouterConfig +} from '@angular/router'; + +provideRouter( + routes, + withInMemoryScrolling({ + scrollPositionRestoration: 'enabled', // or 'top' + anchorScrolling: 'enabled', + }), + withRouterConfig({ + onSameUrlNavigation: 'reload', + }) +) +``` diff --git a/.agents/skills/angular-signals/SKILL.md b/.agents/skills/angular-signals/SKILL.md new file mode 100644 index 0000000..fd5f6f4 --- /dev/null +++ b/.agents/skills/angular-signals/SKILL.md @@ -0,0 +1,302 @@ +--- +name: angular-signals +description: Implement signal-based reactive state management in Angular v20+. Use for creating reactive state with signal(), derived state with computed(), dependent state with linkedSignal(), and side effects with effect(). Triggers on state management questions, converting from BehaviorSubject/Observable patterns to signals, or implementing reactive data flows. +--- + +# Angular Signals + +Signals are Angular's reactive primitive for state management. They provide synchronous, fine-grained reactivity. + +## Core Signal APIs + +### signal() - Writable State + +```typescript +import { signal } from '@angular/core'; + +// Create writable signal +const count = signal(0); + +// Read value +console.log(count()); // 0 + +// Set new value +count.set(5); + +// Update based on current value +count.update(c => c + 1); + +// With explicit type +const user = signal(null); +user.set({ id: 1, name: 'Alice' }); +``` + +### computed() - Derived State + +```typescript +import { signal, computed } from '@angular/core'; + +const firstName = signal('John'); +const lastName = signal('Doe'); + +// Derived signal - automatically updates when dependencies change +const fullName = computed(() => `${firstName()} ${lastName()}`); + +console.log(fullName()); // "John Doe" +firstName.set('Jane'); +console.log(fullName()); // "Jane Doe" + +// Computed with complex logic +const items = signal([]); +const filter = signal(''); + +const filteredItems = computed(() => { + const query = filter().toLowerCase(); + return items().filter(item => + item.name.toLowerCase().includes(query) + ); +}); + +const totalPrice = computed(() => + filteredItems().reduce((sum, item) => sum + item.price, 0) +); +``` + +### linkedSignal() - Dependent State with Reset + +```typescript +import { signal, linkedSignal } from '@angular/core'; + +const options = signal(['A', 'B', 'C']); + +// Resets to first option when options change +const selected = linkedSignal(() => options()[0]); + +console.log(selected()); // "A" +selected.set('B'); // User selects B +console.log(selected()); // "B" +options.set(['X', 'Y']); // Options change +console.log(selected()); // "X" - auto-reset to first + +// With previous value access +const items = signal([]); + +const selectedItem = linkedSignal({ + source: () => items(), + computation: (newItems, previous) => { + // Try to preserve selection if item still exists + const prevItem = previous?.value; + if (prevItem && newItems.some(i => i.id === prevItem.id)) { + return prevItem; + } + return newItems[0] ?? null; + }, +}); +``` + +### effect() - Side Effects + +```typescript +import { signal, effect, inject, DestroyRef } from '@angular/core'; + +@Component({...}) +export class Search { + query = signal(''); + + constructor() { + // Effect runs when query changes + effect(() => { + console.log('Search query:', this.query()); + }); + + // Effect with cleanup + effect((onCleanup) => { + const timer = setInterval(() => { + console.log('Current query:', this.query()); + }, 1000); + + onCleanup(() => clearInterval(timer)); + }); + } +} +``` + +**Effect rules:** +- Run in injection context (constructor or with `runInInjectionContext`) +- Automatically cleaned up when component destroys + +## Component State Pattern + +```typescript +@Component({ + selector: 'app-todo-list', + template: ` + + + +
    + @for (todo of filteredTodos(); track todo.id) { +
  • + {{ todo.text }} + +
  • + } +
+ +

{{ remaining() }} remaining

+ `, +}) +export class TodoList { + // State + todos = signal([]); + newTodo = signal(''); + filter = signal<'all' | 'active' | 'done'>('all'); + + // Derived state + canAdd = computed(() => this.newTodo().trim().length > 0); + + filteredTodos = computed(() => { + const todos = this.todos(); + switch (this.filter()) { + case 'active': return todos.filter(t => !t.done); + case 'done': return todos.filter(t => t.done); + default: return todos; + } + }); + + remaining = computed(() => + this.todos().filter(t => !t.done).length + ); + + // Actions + addTodo() { + const text = this.newTodo().trim(); + if (text) { + this.todos.update(todos => [ + ...todos, + { id: crypto.randomUUID(), text, done: false } + ]); + this.newTodo.set(''); + } + } + + toggleTodo(id: string) { + this.todos.update(todos => + todos.map(t => t.id === id ? { ...t, done: !t.done } : t) + ); + } +} +``` + +## RxJS Interop + +### toSignal() - Observable to Signal + +```typescript +import { toSignal } from '@angular/core/rxjs-interop'; +import { interval } from 'rxjs'; + +@Component({...}) +export class Timer { + private http = inject(HttpClient); + + // From observable - requires initial value or allowUndefined + counter = toSignal(interval(1000), { initialValue: 0 }); + + // From HTTP - undefined until loaded + users = toSignal(this.http.get('/api/users')); + + // With requireSync for synchronous observables (BehaviorSubject) + private user$ = new BehaviorSubject(null); + currentUser = toSignal(this.user$, { requireSync: true }); +} +``` + +### toObservable() - Signal to Observable + +```typescript +import { toObservable } from '@angular/core/rxjs-interop'; +import { switchMap, debounceTime } from 'rxjs'; + +@Component({...}) +export class Search { + query = signal(''); + + private http = inject(HttpClient); + + // Convert signal to observable for RxJS operators + results = toSignal( + toObservable(this.query).pipe( + debounceTime(300), + switchMap(q => this.http.get(`/api/search?q=${q}`)) + ), + { initialValue: [] } + ); +} +``` + +## Signal Equality + +```typescript +// Custom equality function +const user = signal( + { id: 1, name: 'Alice' }, + { equal: (a, b) => a.id === b.id } +); + +// Only triggers updates when ID changes +user.set({ id: 1, name: 'Alice Updated' }); // No update +user.set({ id: 2, name: 'Bob' }); // Triggers update +``` + +## Untracked Reads + +```typescript +import { untracked } from '@angular/core'; + +const a = signal(1); +const b = signal(2); + +// Only depends on 'a', not 'b' +const result = computed(() => { + const aVal = a(); + const bVal = untracked(() => b()); + return aVal + bVal; +}); +``` + +## Service State Pattern + +```typescript +@Injectable({ providedIn: 'root' }) +export class Auth { + // Private writable state + private _user = signal(null); + private _loading = signal(false); + + // Public read-only signals + readonly user = this._user.asReadonly(); + readonly loading = this._loading.asReadonly(); + readonly isAuthenticated = computed(() => this._user() !== null); + + private http = inject(HttpClient); + + async login(credentials: Credentials): Promise { + this._loading.set(true); + try { + const user = await firstValueFrom( + this.http.post('/api/login', credentials) + ); + this._user.set(user); + } finally { + this._loading.set(false); + } + } + + logout(): void { + this._user.set(null); + } +} +``` + +For advanced patterns including resource(), see [references/signal-patterns.md](references/signal-patterns.md). diff --git a/.agents/skills/angular-signals/references/signal-patterns.md b/.agents/skills/angular-signals/references/signal-patterns.md new file mode 100644 index 0000000..ec6f3fc --- /dev/null +++ b/.agents/skills/angular-signals/references/signal-patterns.md @@ -0,0 +1,404 @@ +# Angular Signal Patterns + +## Table of Contents +- [Resource API](#resource-api) +- [Signal Store Pattern](#signal-store-pattern) +- [Form State with Signals](#form-state-with-signals) +- [Async Operations](#async-operations) +- [Testing Signals](#testing-signals) + +## Resource API + +The `resource()` API handles async data fetching with signals: + +```typescript +import { resource, signal, computed } from '@angular/core'; + +@Component({...}) +export class UserProfile { + userId = signal(''); + + // Resource fetches data when params change + userResource = resource({ + params: () => ({ id: this.userId() }), + loader: async ({ params, abortSignal }) => { + const response = await fetch(`/api/users/${params.id}`, { + signal: abortSignal, + }); + return response.json() as Promise; + }, + }); + + // Access resource state + user = computed(() => this.userResource.value()); + isLoading = computed(() => this.userResource.isLoading()); + error = computed(() => this.userResource.error()); +} +``` + +### Resource Status + +```typescript +const userResource = resource({...}); + +// Status signals +userResource.value(); // Current value or undefined +userResource.hasValue(); // Boolean - has resolved value +userResource.error(); // Error or undefined +userResource.isLoading(); // Boolean - currently loading +userResource.status(); // 'idle' | 'loading' | 'reloading' | 'resolved' | 'error' | 'local' + +// Manual reload +userResource.reload(); + +// Local updates +userResource.set(newValue); +userResource.update(current => ({ ...current, name: 'Updated' })); +``` + +### Resource with Default Value + +```typescript +const todosResource = resource({ + defaultValue: [] as Todo[], + params: () => ({ filter: this.filter() }), + loader: async ({ params }) => { + const response = await fetch(`/api/todos?filter=${params.filter}`); + return response.json(); + }, +}); + +// value() returns Todo[] (never undefined due to defaultValue) +``` + +### Conditional Loading + +```typescript +const userId = signal(null); + +const userResource = resource({ + params: () => { + const id = userId(); + // Return undefined to skip loading + return id ? { id } : undefined; + }, + loader: async ({ params }) => { + return fetch(`/api/users/${params.id}`).then(r => r.json()); + }, +}); +// Status is 'idle' when params returns undefined +``` + +## Signal Store Pattern + +For complex state, create a dedicated store: + +```typescript +interface ProductState { + products: Product[]; + selectedId: string | null; + filter: string; + loading: boolean; + error: string | null; +} + +@Injectable({ providedIn: 'root' }) +export class ProductSt { + // Private state + private state = signal({ + products: [], + selectedId: null, + filter: '', + loading: false, + error: null, + }); + + // Selectors (computed signals) + readonly products = computed(() => this.state().products); + readonly selectedId = computed(() => this.state().selectedId); + readonly filter = computed(() => this.state().filter); + readonly loading = computed(() => this.state().loading); + readonly error = computed(() => this.state().error); + + readonly filteredProducts = computed(() => { + const { products, filter } = this.state(); + if (!filter) return products; + return products.filter(p => + p.name.toLowerCase().includes(filter.toLowerCase()) + ); + }); + + readonly selectedProduct = computed(() => { + const { products, selectedId } = this.state(); + return products.find(p => p.id === selectedId) ?? null; + }); + + private http = inject(HttpClient); + + // Actions + setFilter(filter: string): void { + this.state.update(s => ({ ...s, filter })); + } + + selectProduct(id: string | null): void { + this.state.update(s => ({ ...s, selectedId: id })); + } + + async loadProducts(): Promise { + this.state.update(s => ({ ...s, loading: true, error: null })); + + try { + const products = await firstValueFrom( + this.http.get('/api/products') + ); + this.state.update(s => ({ ...s, products, loading: false })); + } catch (err) { + this.state.update(s => ({ + ...s, + loading: false, + error: 'Failed to load products' + })); + } + } + + async addProduct(product: Omit): Promise { + const newProduct = await firstValueFrom( + this.http.post('/api/products', product) + ); + this.state.update(s => ({ + ...s, + products: [...s.products, newProduct], + })); + } +} +``` + +## Form State with Signals + +```typescript +interface FormState { + value: T; + touched: boolean; + dirty: boolean; + valid: boolean; + errors: string[]; +} + +function createFormField( + initialValue: T, + validators: ((value: T) => string | null)[] = [] +) { + const value = signal(initialValue); + const touched = signal(false); + const dirty = signal(false); + + const errors = computed(() => { + return validators + .map(v => v(value())) + .filter((e): e is string => e !== null); + }); + + const valid = computed(() => errors().length === 0); + + return { + value, + touched: touched.asReadonly(), + dirty: dirty.asReadonly(), + errors, + valid, + + setValue(newValue: T) { + value.set(newValue); + dirty.set(true); + }, + + markTouched() { + touched.set(true); + }, + + reset() { + value.set(initialValue); + touched.set(false); + dirty.set(false); + }, + }; +} + +// Usage +@Component({...}) +export class Signup { + email = createFormField('', [ + v => !v ? 'Email is required' : null, + v => !v.includes('@') ? 'Invalid email' : null, + ]); + + password = createFormField('', [ + v => !v ? 'Password is required' : null, + v => v.length < 8 ? 'Password must be at least 8 characters' : null, + ]); + + formValid = computed(() => + this.email.valid() && this.password.valid() + ); +} +``` + +## Async Operations + +### Debounced Search + +```typescript +@Component({...}) +export class Search { + query = signal(''); + + private http = inject(HttpClient); + + // Debounced search using toObservable + results = toSignal( + toObservable(this.query).pipe( + debounceTime(300), + distinctUntilChanged(), + filter(q => q.length >= 2), + switchMap(q => this.http.get(`/api/search?q=${q}`)), + catchError(() => of([])) + ), + { initialValue: [] } + ); + + // Loading state + private searching = signal(false); + readonly isSearching = this.searching.asReadonly(); + + constructor() { + // Track loading state + effect(() => { + const q = this.query(); + if (q.length >= 2) { + this.searching.set(true); + } + }); + + effect(() => { + this.results(); // Subscribe to results + this.searching.set(false); + }); + } +} +``` + +### Optimistic Updates + +```typescript +@Injectable({ providedIn: 'root' }) +export class Todo { + private todos = signal([]); + readonly items = this.todos.asReadonly(); + + private http = inject(HttpClient); + + async toggleTodo(id: string): Promise { + // Optimistic update + const previousTodos = this.todos(); + this.todos.update(todos => + todos.map(t => t.id === id ? { ...t, done: !t.done } : t) + ); + + try { + await firstValueFrom( + this.http.patch(`/api/todos/${id}/toggle`, {}) + ); + } catch { + // Rollback on error + this.todos.set(previousTodos); + } + } +} +``` + +## Testing Signals + +```typescript +describe('Counter', () => { + it('should increment count', () => { + const component = new Counter(); + + expect(component.count()).toBe(0); + + component.increment(); + expect(component.count()).toBe(1); + + component.increment(); + expect(component.count()).toBe(2); + }); + + it('should compute doubled value', () => { + const component = new Counter(); + + expect(component.doubled()).toBe(0); + + component.count.set(5); + expect(component.doubled()).toBe(10); + }); +}); + +describe('ProductSt', () => { + let store: ProductSt; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + ProductSt, + provideHttpClient(), + provideHttpClientTesting(), + ], + }); + + store = TestBed.inject(ProductSt); + httpMock = TestBed.inject(HttpTestingController); + }); + + it('should filter products', () => { + // Set initial state + store['state'].set({ + products: [ + { id: '1', name: 'Apple' }, + { id: '2', name: 'Banana' }, + ], + selectedId: null, + filter: '', + loading: false, + error: null, + }); + + expect(store.filteredProducts().length).toBe(2); + + store.setFilter('app'); + expect(store.filteredProducts().length).toBe(1); + expect(store.filteredProducts()[0].name).toBe('Apple'); + }); +}); +``` + +## Signal Debugging + +```typescript +// Debug effect to log signal changes +effect(() => { + console.log('State changed:', { + count: this.count(), + items: this.items(), + filter: this.filter(), + }); +}); + +// Conditional debugging +const DEBUG = signal(false); + +effect(() => { + if (untracked(() => DEBUG())) { + console.log('Debug:', this.state()); + } +}); +``` diff --git a/.agents/skills/angular-ssr/SKILL.md b/.agents/skills/angular-ssr/SKILL.md new file mode 100644 index 0000000..c0f1800 --- /dev/null +++ b/.agents/skills/angular-ssr/SKILL.md @@ -0,0 +1,435 @@ +--- +name: angular-ssr +description: Implement server-side rendering and hydration in Angular v20+ using @angular/ssr. Use for SSR setup, hydration strategies, prerendering static pages, and handling browser-only APIs. Triggers on SSR configuration, fixing hydration mismatches, prerendering routes, or making code SSR-compatible. +--- + +# Angular SSR + +Implement server-side rendering, hydration, and prerendering in Angular v20+. + +## Setup + +### Add SSR to Existing Project + +```bash +ng add @angular/ssr +``` + +This adds: +- `@angular/ssr` package +- `server.ts` - Express server +- `src/main.server.ts` - Server bootstrap +- `src/app/app.config.server.ts` - Server providers +- Updates `angular.json` with SSR configuration + +### Project Structure + +``` +src/ +├── app/ +│ ├── app.config.ts # Browser config +│ ├── app.config.server.ts # Server config +│ └── app.routes.ts +├── main.ts # Browser bootstrap +├── main.server.ts # Server bootstrap +server.ts # Express server +``` + +## Configuration + +### app.config.server.ts + +```typescript +import { ApplicationConfig, mergeApplicationConfig } from '@angular/core'; +import { provideServerRendering } from '@angular/platform-server'; +import { provideServerRoutesConfig } from '@angular/ssr'; +import { appConfig } from './app.config'; +import { serverRoutes } from './app.routes.server'; + +const serverConfig: ApplicationConfig = { + providers: [ + provideServerRendering(), + provideServerRoutesConfig(serverRoutes), + ], +}; + +export const config = mergeApplicationConfig(appConfig, serverConfig); +``` + +### Server Routes Configuration + +```typescript +// app.routes.server.ts +import { RenderMode, ServerRoute } from '@angular/ssr'; + +export const serverRoutes: ServerRoute[] = [ + { + path: '', + renderMode: RenderMode.Prerender, // Static at build time + }, + { + path: 'products', + renderMode: RenderMode.Prerender, + }, + { + path: 'products/:id', + renderMode: RenderMode.Server, // Dynamic SSR + }, + { + path: 'dashboard', + renderMode: RenderMode.Client, // Client-only (SPA) + }, + { + path: '**', + renderMode: RenderMode.Server, + }, +]; +``` + +### Render Modes + +| Mode | Description | Use Case | +|------|-------------|----------| +| `RenderMode.Prerender` | Static HTML at build time | Marketing pages, blogs | +| `RenderMode.Server` | Dynamic SSR per request | User-specific content | +| `RenderMode.Client` | Client-side only (SPA) | Authenticated dashboards | + +## Hydration + +### Default Hydration + +Hydration is enabled by default with `provideClientHydration()`: + +```typescript +// app.config.ts +import { provideClientHydration } from '@angular/platform-browser'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideClientHydration(), + // ... + ], +}; +``` + +### Incremental Hydration + +Defer hydration of specific components: + +```typescript +@Component({ + template: ` + + @defer (hydrate on viewport) { + + } @placeholder { +
Loading comments...
+ } + + + @defer (hydrate on interaction) { + + } + + + @defer (hydrate on idle) { + + } + + + @defer (hydrate never) { + + } + `, +}) +export class Post { + postId = input.required(); + chartData = input.required(); +} +``` + +### Hydration Triggers + +| Trigger | Description | +|---------|-------------| +| `hydrate on viewport` | When element enters viewport | +| `hydrate on interaction` | On click, focus, or input | +| `hydrate on idle` | When browser is idle | +| `hydrate on immediate` | Immediately after load | +| `hydrate on timer(ms)` | After specified delay | +| `hydrate when condition` | When expression is true | +| `hydrate never` | Never hydrate (static) | + +### Event Replay + +Capture user events before hydration completes: + +```typescript +import { provideClientHydration, withEventReplay } from '@angular/platform-browser'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideClientHydration(withEventReplay()), + ], +}; +``` + +## Browser-Only Code + +### Platform Detection + +```typescript +import { PLATFORM_ID, inject } from '@angular/core'; +import { isPlatformBrowser, isPlatformServer } from '@angular/common'; + +@Component({...}) +export class My { + private platformId = inject(PLATFORM_ID); + + ngOnInit() { + if (isPlatformBrowser(this.platformId)) { + // Browser-only code + window.addEventListener('scroll', this.onScroll); + } + } +} +``` + +### afterNextRender / afterRender + +Run code only in browser after rendering: + +```typescript +import { afterNextRender, afterRender } from '@angular/core'; + +@Component({...}) +export class Chart { + constructor() { + // Runs once after first render (browser only) + afterNextRender(() => { + this.initChart(); + }); + + // Runs after every render (browser only) + afterRender(() => { + this.updateChart(); + }); + } + + private initChart() { + // Safe to use DOM APIs here + const canvas = document.getElementById('chart'); + new Chart(canvas, this.config); + } +} +``` + +### Inject Browser APIs Safely + +```typescript +// tokens.ts +import { InjectionToken, PLATFORM_ID, inject } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; + +export const WINDOW = new InjectionToken('Window', { + providedIn: 'root', + factory: () => { + const platformId = inject(PLATFORM_ID); + return isPlatformBrowser(platformId) ? window : null; + }, +}); + +export const LOCAL_STORAGE = new InjectionToken('LocalStorage', { + providedIn: 'root', + factory: () => { + const platformId = inject(PLATFORM_ID); + return isPlatformBrowser(platformId) ? localStorage : null; + }, +}); + +// Usage +@Injectable({ providedIn: 'root' }) +export class Storage { + private storage = inject(LOCAL_STORAGE); + + get(key: string): string | null { + return this.storage?.getItem(key) ?? null; + } + + set(key: string, value: string): void { + this.storage?.setItem(key, value); + } +} +``` + +## Prerendering + +### Static Routes + +```typescript +// app.routes.server.ts +export const serverRoutes: ServerRoute[] = [ + { path: '', renderMode: RenderMode.Prerender }, + { path: 'about', renderMode: RenderMode.Prerender }, + { path: 'contact', renderMode: RenderMode.Prerender }, + { path: 'blog', renderMode: RenderMode.Prerender }, +]; +``` + +### Dynamic Routes with getPrerenderParams + +```typescript +// app.routes.server.ts +import { RenderMode, ServerRoute, PrerenderFallback } from '@angular/ssr'; + +export const serverRoutes: ServerRoute[] = [ + { + path: 'products/:id', + renderMode: RenderMode.Prerender, + async getPrerenderParams() { + // Fetch product IDs to prerender + const response = await fetch('https://api.example.com/products'); + const products = await response.json(); + return products.map((p: Product) => ({ id: p.id })); + }, + fallback: PrerenderFallback.Server, // SSR for non-prerendered + }, + { + path: 'blog/:slug', + renderMode: RenderMode.Prerender, + async getPrerenderParams() { + const posts = await fetchBlogPosts(); + return posts.map(post => ({ slug: post.slug })); + }, + fallback: PrerenderFallback.Client, // SPA for non-prerendered + }, +]; +``` + +### Prerender Fallback Options + +| Fallback | Description | +|----------|-------------| +| `PrerenderFallback.Server` | SSR for non-prerendered routes | +| `PrerenderFallback.Client` | Client-side rendering | +| `PrerenderFallback.None` | 404 for non-prerendered routes | + +## HTTP Caching + +### TransferState + +Automatically transfer HTTP responses from server to client: + +```typescript +import { provideClientHydration, withHttpTransferCacheOptions } from '@angular/platform-browser'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideClientHydration( + withHttpTransferCacheOptions({ + includePostRequests: true, + includeRequestsWithAuthHeaders: false, + filter: (req) => !req.url.includes('/api/realtime'), + }) + ), + ], +}; +``` + +### Manual TransferState + +```typescript +import { TransferState, makeStateKey } from '@angular/core'; + +const PRODUCTS_KEY = makeStateKey('products'); + +@Injectable({ providedIn: 'root' }) +export class Product { + private http = inject(HttpClient); + private transferState = inject(TransferState); + private platformId = inject(PLATFORM_ID); + + getProducts(): Observable { + // Check if data was transferred from server + if (this.transferState.hasKey(PRODUCTS_KEY)) { + const products = this.transferState.get(PRODUCTS_KEY, []); + this.transferState.remove(PRODUCTS_KEY); + return of(products); + } + + return this.http.get('/api/products').pipe( + tap(products => { + // Store for transfer on server + if (isPlatformServer(this.platformId)) { + this.transferState.set(PRODUCTS_KEY, products); + } + }) + ); + } +} +``` + +## Build and Deploy + +### Build Commands + +```bash +# Build with SSR +ng build + +# Output structure +dist/ +├── my-app/ +│ ├── browser/ # Client assets +│ └── server/ # Server bundle +``` + +### Run SSR Server + +```bash +# Development +npm run serve:ssr:my-app + +# Production +node dist/my-app/server/server.mjs +``` + +### Deploy to Node.js Host + +```javascript +// server.ts (generated) +import { APP_BASE_HREF } from '@angular/common'; +import { CommonEngine } from '@angular/ssr/node'; +import express from 'express'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import bootstrap from './src/main.server'; + +const serverDistFolder = dirname(fileURLToPath(import.meta.url)); +const browserDistFolder = resolve(serverDistFolder, '../browser'); +const indexHtml = join(serverDistFolder, 'index.server.html'); + +const app = express(); +const commonEngine = new CommonEngine(); + +app.get('*', express.static(browserDistFolder, { maxAge: '1y', index: false })); + +app.get('*', (req, res, next) => { + commonEngine + .render({ + bootstrap, + documentFilePath: indexHtml, + url: req.originalUrl, + publicPath: browserDistFolder, + providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }], + }) + .then((html) => res.send(html)) + .catch((err) => next(err)); +}); + +app.listen(4000, () => { + console.log('Server listening on http://localhost:4000'); +}); +``` + +For advanced patterns, see [references/ssr-patterns.md](references/ssr-patterns.md). diff --git a/.agents/skills/angular-ssr/references/ssr-patterns.md b/.agents/skills/angular-ssr/references/ssr-patterns.md new file mode 100644 index 0000000..49ff709 --- /dev/null +++ b/.agents/skills/angular-ssr/references/ssr-patterns.md @@ -0,0 +1,497 @@ +# Angular SSR Patterns + +## Table of Contents +- [Hydration Debugging](#hydration-debugging) +- [SEO Optimization](#seo-optimization) +- [Authentication with SSR](#authentication-with-ssr) +- [Caching Strategies](#caching-strategies) +- [Error Handling](#error-handling) +- [Performance Optimization](#performance-optimization) + +## Hydration Debugging + +### Common Hydration Mismatches + +```typescript +// Problem: Different content on server vs client +@Component({ + template: `

Current time: {{ currentTime }}

`, +}) +export class Time { + // BAD: Different value on server and client + currentTime = new Date().toLocaleTimeString(); +} + +// Solution: Use afterNextRender or skip SSR +@Component({ + template: `

Current time: {{ currentTime() }}

`, +}) +export class Time { + currentTime = signal(''); + + constructor() { + afterNextRender(() => { + this.currentTime.set(new Date().toLocaleTimeString()); + }); + } +} +``` + +### Skip Hydration for Dynamic Content + +```typescript +@Component({ + template: ` + +
+ +
+ `, +}) +export class Page {} +``` + +### Debug Hydration Issues + +```typescript +// Enable hydration debugging in development +import { provideClientHydration, withNoDomReuse } from '@angular/platform-browser'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideClientHydration( + // Disable DOM reuse to see hydration errors clearly + ...(isDevMode() ? [withNoDomReuse()] : []) + ), + ], +}; +``` + +## SEO Optimization + +### Meta Tags Service + +```typescript +import { Injectable, inject } from '@angular/core'; +import { Meta, Title } from '@angular/platform-browser'; +import { DOCUMENT } from '@angular/common'; + +@Injectable({ providedIn: 'root' }) +export class Seo { + private meta = inject(Meta); + private title = inject(Title); + private document = inject(DOCUMENT); + + updateMetaTags(config: { + title: string; + description: string; + image?: string; + url?: string; + type?: string; + }) { + // Basic meta + this.title.setTitle(config.title); + this.meta.updateTag({ name: 'description', content: config.description }); + + // Open Graph + this.meta.updateTag({ property: 'og:title', content: config.title }); + this.meta.updateTag({ property: 'og:description', content: config.description }); + this.meta.updateTag({ property: 'og:type', content: config.type || 'website' }); + + if (config.image) { + this.meta.updateTag({ property: 'og:image', content: config.image }); + } + + if (config.url) { + this.meta.updateTag({ property: 'og:url', content: config.url }); + this.updateCanonicalUrl(config.url); + } + + // Twitter Card + this.meta.updateTag({ name: 'twitter:card', content: 'summary_large_image' }); + this.meta.updateTag({ name: 'twitter:title', content: config.title }); + this.meta.updateTag({ name: 'twitter:description', content: config.description }); + + if (config.image) { + this.meta.updateTag({ name: 'twitter:image', content: config.image }); + } + } + + private updateCanonicalUrl(url: string) { + let link: HTMLLinkElement | null = this.document.querySelector('link[rel="canonical"]'); + + if (!link) { + link = this.document.createElement('link'); + link.setAttribute('rel', 'canonical'); + this.document.head.appendChild(link); + } + + link.setAttribute('href', url); + } + + setJsonLd(data: object) { + let script: HTMLScriptElement | null = this.document.querySelector('script[type="application/ld+json"]'); + + if (!script) { + script = this.document.createElement('script'); + script.type = 'application/ld+json'; + this.document.head.appendChild(script); + } + + script.textContent = JSON.stringify(data); + } +} + +// Usage in component +@Component({...}) +export class Product { + private seo = inject(Seo); + product = input.required(); + + constructor() { + effect(() => { + const product = this.product(); + this.seo.updateMetaTags({ + title: `${product.name} | My Store`, + description: product.description, + image: product.imageUrl, + url: `https://mystore.com/products/${product.id}`, + type: 'product', + }); + + this.seo.setJsonLd({ + '@context': 'https://schema.org', + '@type': 'Product', + name: product.name, + description: product.description, + image: product.imageUrl, + offers: { + '@type': 'Offer', + price: product.price, + priceCurrency: 'USD', + }, + }); + }); + } +} +``` + +### Route-Based SEO with Resolvers + +```typescript +// seo.resolver.ts +export const seoResolver: ResolveFn = async (route) => { + const productId = route.paramMap.get('id')!; + const productService = inject(Product); + const product = await productService.getById(productId); + + return { + title: `${product.name} | My Store`, + description: product.description, + image: product.imageUrl, + }; +}; + +// Routes +{ + path: 'products/:id', + component: Product, + resolve: { seo: seoResolver }, +} + +// Component +@Component({...}) +export class Product { + private seo = inject(Seo); + seoData = input.required(); // From resolver + + constructor() { + effect(() => { + this.seo.updateMetaTags(this.seoData()); + }); + } +} +``` + +## Authentication with SSR + +### Cookie-Based Auth + +```typescript +// Server-side cookie reading +import { REQUEST } from '@angular/ssr/tokens'; + +@Injectable({ providedIn: 'root' }) +export class Auth { + private request = inject(REQUEST, { optional: true }); + private platformId = inject(PLATFORM_ID); + + getToken(): string | null { + if (isPlatformServer(this.platformId) && this.request) { + // Read from request cookies on server + const cookies = this.request.headers.cookie || ''; + const match = cookies.match(/auth_token=([^;]+)/); + return match ? match[1] : null; + } + + if (isPlatformBrowser(this.platformId)) { + // Read from document cookies on client + const match = document.cookie.match(/auth_token=([^;]+)/); + return match ? match[1] : null; + } + + return null; + } +} +``` + +### Skip SSR for Authenticated Routes + +```typescript +// app.routes.server.ts +export const serverRoutes: ServerRoute[] = [ + // Public routes - prerender + { path: '', renderMode: RenderMode.Prerender }, + { path: 'products', renderMode: RenderMode.Prerender }, + + // Authenticated routes - client only + { path: 'dashboard', renderMode: RenderMode.Client }, + { path: 'profile', renderMode: RenderMode.Client }, + { path: 'settings', renderMode: RenderMode.Client }, +]; +``` + +## Caching Strategies + +### HTTP Cache Headers + +```typescript +// server.ts +import { REQUEST, RESPONSE_INIT } from '@angular/ssr/tokens'; + +// In route configuration or component +@Component({...}) +export class ProductList { + private responseInit = inject(RESPONSE_INIT, { optional: true }); + + constructor() { + // Set cache headers for SSR response + if (this.responseInit) { + this.responseInit.headers = { + ...this.responseInit.headers, + 'Cache-Control': 'public, max-age=3600, s-maxage=86400', + }; + } + } +} +``` + +### CDN Caching with Vary Headers + +```typescript +// server.ts - Express middleware +app.use((req, res, next) => { + // Vary by cookie for authenticated content + res.setHeader('Vary', 'Cookie'); + next(); +}); +``` + +### Stale-While-Revalidate + +```typescript +// Set SWR headers for dynamic content +this.responseInit.headers = { + 'Cache-Control': 'public, max-age=60, stale-while-revalidate=3600', +}; +``` + +## Error Handling + +### SSR Error Boundaries + +```typescript +// error-handler.ts +import { ErrorHandler, Injectable, inject } from '@angular/core'; +import { PLATFORM_ID } from '@angular/core'; +import { isPlatformServer } from '@angular/common'; + +@Injectable() +export class SsrError implements ErrorHandler { + private platformId = inject(PLATFORM_ID); + + handleError(error: Error) { + if (isPlatformServer(this.platformId)) { + // Log server errors + console.error('SSR Error:', error); + // Could send to monitoring service + } else { + // Client-side error handling + console.error('Client Error:', error); + } + } +} + +// Provide in app.config.ts +{ provide: ErrorHandler, useClass: SsrError } +``` + +### Graceful Degradation + +```typescript +@Component({ + template: ` + @if (dataError()) { + + + } @else { + + } + `, +}) +export class PageCmpt { + private dataService = inject(Data); + + data = signal(null); + dataError = signal(false); + + constructor() { + this.loadData(); + } + + private async loadData() { + try { + const data = await this.dataService.getData(); + this.data.set(data); + } catch { + this.dataError.set(true); + } + } +} +``` + +## Performance Optimization + +### Lazy Hydration Strategy + +```typescript +@Component({ + template: ` + +
+ +
+ + +
+ @defer (hydrate on viewport) { + + } +
+ + + @defer (hydrate on idle) { + + } + + + @defer (hydrate on interaction) { + + } + + + @defer (hydrate never) { + + } + `, +}) +export class ProductPage {} +``` + +### Preload Critical Data + +```typescript +// app.routes.server.ts +export const serverRoutes: ServerRoute[] = [ + { + path: 'products/:id', + renderMode: RenderMode.Server, + async getPrerenderParams() { + // Prerender top 100 products + const topProducts = await fetchTopProducts(100); + return topProducts.map(p => ({ id: p.id })); + }, + }, +]; +``` + +### Streaming SSR (Experimental) + +```typescript +// Enable streaming for faster TTFB +import { provideServerRendering } from '@angular/platform-server'; + +const serverConfig: ApplicationConfig = { + providers: [ + provideServerRendering(), + // Streaming is automatic with @defer blocks + ], +}; +``` + +## Testing SSR + +### Test Server Rendering + +```typescript +import { renderApplication } from '@angular/platform-server'; +import { App } from './app.component'; +import { config } from './app.config.server'; + +describe('SSR', () => { + it('should render home page', async () => { + const html = await renderApplication(App, { + appId: 'my-app', + providers: config.providers, + url: '/', + }); + + expect(html).toContain('

Welcome

'); + expect(html).toContain(''); + }); + + it('should render product page with data', async () => { + const html = await renderApplication(App, { + appId: 'my-app', + providers: config.providers, + url: '/products/123', + }); + + expect(html).toContain('Product Name'); + expect(html).not.toContain('Loading...'); + }); +}); +``` + +### Test Hydration + +```typescript +import { TestBed } from '@angular/core/testing'; +import { provideClientHydration } from '@angular/platform-browser'; + +describe('Hydration', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideClientHydration()], + }); + }); + + it('should hydrate without errors', () => { + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + // No hydration mismatch errors should be thrown + expect(fixture.componentInstance).toBeTruthy(); + }); +}); +``` diff --git a/.agents/skills/angular-testing/SKILL.md b/.agents/skills/angular-testing/SKILL.md new file mode 100644 index 0000000..db59b2c --- /dev/null +++ b/.agents/skills/angular-testing/SKILL.md @@ -0,0 +1,457 @@ +--- +name: angular-testing +description: Write unit and integration tests for Angular v20+ applications using Vitest or Jasmine with TestBed and modern testing patterns. Use for testing components with signals, OnPush change detection, services with inject(), and HTTP interactions. Triggers on test creation, testing signal-based components, mocking dependencies, or setting up test infrastructure. Don't use for E2E testing with Cypress or Playwright, or for testing non-Angular JavaScript/TypeScript code. +--- + +# Angular Testing + +Test Angular v20+ applications with Vitest (recommended) or Jasmine, focusing on signal-based components and modern patterns. + +## Vitest Setup (Angular v20+) + +Angular v20+ has native Vitest support through the `@angular/build` package. + +```bash +npm install -D vitest jsdom +``` + +Configure in angular.json: + +```json +{ + "projects": { + "your-app": { + "architect": { + "test": { + "builder": "@angular/build:unit-test", + "options": { + "tsConfig": "tsconfig.spec.json", + "buildTarget": "your-app:build" + } + } + } + } + } +} +``` + +Run tests: + +```bash +ng test # Run tests +ng test --watch # Watch mode +ng test --code-coverage # With coverage +``` + +For Vitest migration from Jasmine and advanced configuration, see [references/vitest-migration.md](references/vitest-migration.md). + +## Basic Component Test + +```typescript +import { describe, it, expect, beforeEach } from 'vitest'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Counter } from './counter.component'; + +describe('Counter', () => { + let component: Counter; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [Counter], // Standalone component + }).compileComponents(); + + fixture = TestBed.createComponent(Counter); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should increment count', () => { + expect(component.count()).toBe(0); + component.increment(); + expect(component.count()).toBe(1); + }); + + it('should display count in template', () => { + component.count.set(5); + fixture.detectChanges(); + + const element = fixture.nativeElement.querySelector('.count'); + expect(element.textContent).toContain('5'); + }); +}); +``` + +## Testing Signals + +### Direct Signal Testing + +```typescript +import { signal, computed } from '@angular/core'; + +describe('Signal logic', () => { + it('should update computed when signal changes', () => { + const count = signal(0); + const doubled = computed(() => count() * 2); + + expect(doubled()).toBe(0); + + count.set(5); + expect(doubled()).toBe(10); + + count.update(c => c + 1); + expect(doubled()).toBe(12); + }); +}); +``` + +### Testing Component Signals + +```typescript +@Component({ + selector: 'app-todo-list', + template: ` +
    + @for (todo of filteredTodos(); track todo.id) { +
  • {{ todo.text }}
  • + } +
+

{{ remaining() }} remaining

+ `, +}) +export class TodoList { + todos = signal([]); + filter = signal<'all' | 'active' | 'done'>('all'); + + filteredTodos = computed(() => { + const todos = this.todos(); + switch (this.filter()) { + case 'active': return todos.filter(t => !t.done); + case 'done': return todos.filter(t => t.done); + default: return todos; + } + }); + + remaining = computed(() => this.todos().filter(t => !t.done).length); +} + +describe('TodoList', () => { + let component: TodoList; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TodoList], + }).compileComponents(); + + fixture = TestBed.createComponent(TodoList); + component = fixture.componentInstance; + }); + + it('should filter active todos', () => { + component.todos.set([ + { id: '1', text: 'Task 1', done: false }, + { id: '2', text: 'Task 2', done: true }, + { id: '3', text: 'Task 3', done: false }, + ]); + + component.filter.set('active'); + + expect(component.filteredTodos().length).toBe(2); + expect(component.remaining()).toBe(2); + }); +}); +``` + +## Testing OnPush Components + +OnPush components require explicit change detection: + +```typescript +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + template: `{{ data().name }}`, +}) +export class OnPushCmpt { + data = input.required<{ name: string }>(); +} + +describe('OnPushCmpt', () => { + it('should update when input signal changes', () => { + const fixture = TestBed.createComponent(OnPushCmpt); + + // Set input using setInput (for signal inputs) + fixture.componentRef.setInput('data', { name: 'Initial' }); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toContain('Initial'); + + // Update input + fixture.componentRef.setInput('data', { name: 'Updated' }); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toContain('Updated'); + }); +}); +``` + +## Testing Services + +### Basic Service Test + +```typescript +@Injectable({ providedIn: 'root' }) +export class CounterService { + private _count = signal(0); + readonly count = this._count.asReadonly(); + + increment() { this._count.update(c => c + 1); } + reset() { this._count.set(0); } +} + +describe('CounterService', () => { + let service: CounterService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(CounterService); + }); + + it('should increment count', () => { + expect(service.count()).toBe(0); + service.increment(); + expect(service.count()).toBe(1); + }); +}); +``` + +### Service with HTTP + +```typescript +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideHttpClient } from '@angular/common/http'; + +describe('UserService', () => { + let service: UserService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + ], + }); + + service = TestBed.inject(UserService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); // Verify no outstanding requests + }); + + it('should fetch user by id', () => { + const mockUser = { id: '1', name: 'Test User' }; + + service.getUser('1').subscribe(user => { + expect(user).toEqual(mockUser); + }); + + const req = httpMock.expectOne('/api/users/1'); + expect(req.request.method).toBe('GET'); + req.flush(mockUser); + }); +}); +``` + +## Mocking Dependencies + +### Using Vitest Mocks + +```typescript +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +describe('UserProfile', () => { + const mockUserService = { + getUser: vi.fn(), + updateUser: vi.fn(), + user: signal(null), + }; + + beforeEach(async () => { + vi.clearAllMocks(); + mockUserService.getUser.mockReturnValue(of({ id: '1', name: 'Test' })); + + await TestBed.configureTestingModule({ + imports: [UserProfile], + providers: [ + { provide: UserService, useValue: mockUserService }, + ], + }).compileComponents(); + }); + + it('should call getUser on init', () => { + const fixture = TestBed.createComponent(UserProfile); + fixture.detectChanges(); + + expect(mockUserService.getUser).toHaveBeenCalledWith('1'); + }); +}); +``` + +### Mock Signal-Based Service + +```typescript +const mockAuth = { + user: signal(null), + isAuthenticated: computed(() => mockAuth.user() !== null), + login: vi.fn(), + logout: vi.fn(), +}; + +beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ProtectedPage], + providers: [ + { provide: AuthService, useValue: mockAuth }, + ], + }).compileComponents(); +}); + +it('should show content when authenticated', () => { + mockAuth.user.set({ id: '1', name: 'Test User' }); + + const fixture = TestBed.createComponent(ProtectedPage); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('.protected-content')).toBeTruthy(); +}); +``` + +## Testing Inputs and Outputs + +```typescript +@Component({ + selector: 'app-item', + template: `
{{ item().name }}
`, +}) +export class ItemCmpt { + item = input.required(); + selected = output(); + + select() { + this.selected.emit(this.item()); + } +} + +describe('ItemCmpt', () => { + it('should emit selected event on click', () => { + const fixture = TestBed.createComponent(ItemCmpt); + const item: Item = { id: '1', name: 'Test Item' }; + + fixture.componentRef.setInput('item', item); + fixture.detectChanges(); + + let emittedItem: Item | undefined; + fixture.componentInstance.selected.subscribe(i => emittedItem = i); + + fixture.nativeElement.querySelector('div').click(); + + expect(emittedItem).toEqual(item); + }); +}); +``` + +## Testing Async Operations + +### Using fakeAsync + +```typescript +import { fakeAsync, tick, flush } from '@angular/core/testing'; + +it('should debounce search', fakeAsync(() => { + const fixture = TestBed.createComponent(SearchCmpt); + fixture.detectChanges(); + + fixture.componentInstance.query.set('test'); + + tick(300); // Advance time for debounce + fixture.detectChanges(); + + expect(fixture.componentInstance.results().length).toBeGreaterThan(0); + + flush(); // Flush remaining timers +})); +``` + +### Using waitForAsync + +```typescript +import { waitForAsync } from '@angular/core/testing'; + +it('should load data', waitForAsync(() => { + const fixture = TestBed.createComponent(DataCmpt); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + fixture.detectChanges(); + expect(fixture.componentInstance.data()).toBeDefined(); + }); +})); +``` + +## Testing HTTP Resources + +```typescript +@Component({ + template: ` + @if (userResource.isLoading()) { +

Loading...

+ } @else if (userResource.hasValue()) { +

{{ userResource.value().name }}

+ } + `, +}) +export class UserCmpt { + userId = signal('1'); + userResource = httpResource(() => `/api/users/${this.userId()}`); +} + +describe('UserCmpt', () => { + let httpMock: HttpTestingController; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [UserCmpt], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + ], + }).compileComponents(); + + httpMock = TestBed.inject(HttpTestingController); + }); + + it('should display user name after loading', () => { + const fixture = TestBed.createComponent(UserCmpt); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toContain('Loading'); + + const req = httpMock.expectOne('/api/users/1'); + req.flush({ id: '1', name: 'John Doe' }); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toContain('John Doe'); + }); +}); +``` + +For advanced testing patterns including component harnesses, router testing, form testing, and directive testing, see [references/testing-patterns.md](references/testing-patterns.md). + +For Vitest migration from Jasmine, see [references/vitest-migration.md](references/vitest-migration.md). diff --git a/.agents/skills/angular-testing/references/testing-patterns.md b/.agents/skills/angular-testing/references/testing-patterns.md new file mode 100644 index 0000000..4dc4291 --- /dev/null +++ b/.agents/skills/angular-testing/references/testing-patterns.md @@ -0,0 +1,707 @@ +# Angular Testing Patterns + +## Table of Contents +- [Vitest Advanced Patterns](#vitest-advanced-patterns) +- [Component Harnesses](#component-harnesses) +- [Testing Router](#testing-router) +- [Testing Forms](#testing-forms) +- [Testing Directives](#testing-directives) +- [Testing Pipes](#testing-pipes) +- [E2E Testing Setup](#e2e-testing-setup) + +## Vitest Advanced Patterns + +### Snapshot Testing + +```typescript +import { describe, it, expect } from 'vitest'; + +describe('UserCard', () => { + it('should match snapshot', () => { + const fixture = TestBed.createComponent(UserCard); + fixture.componentRef.setInput('user', { id: '1', name: 'John', email: 'john@example.com' }); + fixture.detectChanges(); + + expect(fixture.nativeElement.innerHTML).toMatchSnapshot(); + }); +}); +``` + +### Parameterized Tests + +```typescript +import { describe, it, expect } from 'vitest'; + +describe('Validator', () => { + it.each([ + { input: '', expected: false }, + { input: 'test', expected: false }, + { input: 'test@example.com', expected: true }, + { input: 'invalid@', expected: false }, + ])('should validate email "$input" as $expected', ({ input, expected }) => { + expect(isValidEmail(input)).toBe(expected); + }); +}); +``` + +### Testing with Fake Timers + +```typescript +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +describe('Debounced Search', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should debounce search input', async () => { + const fixture = TestBed.createComponent(Search); + fixture.detectChanges(); + + fixture.componentInstance.query.set('test'); + + // Search not called yet + expect(fixture.componentInstance.results()).toEqual([]); + + // Advance timers + vi.advanceTimersByTime(300); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(fixture.componentInstance.results().length).toBeGreaterThan(0); + }); +}); +``` + +### Module Mocking + +```typescript +import { describe, it, expect, vi } from 'vitest'; + +// Mock entire module +vi.mock('./analytics.service', () => ({ + Analytics: class { + track = vi.fn(); + identify = vi.fn(); + }, +})); + +describe('with mocked analytics', () => { + it('should track events', () => { + const fixture = TestBed.createComponent(Dashboard); + const analytics = TestBed.inject(Analytics); + + fixture.detectChanges(); + + expect(analytics.track).toHaveBeenCalledWith('dashboard_viewed'); + }); +}); +``` + +### Testing Async/Await + +```typescript +import { describe, it, expect, vi } from 'vitest'; + +describe('User', () => { + it('should load user data', async () => { + const mockUser = { id: '1', name: 'Test' }; + const httpMock = TestBed.inject(HttpTestingController); + const service = TestBed.inject(User); + + const userPromise = service.loadUser('1'); + + httpMock.expectOne('/api/users/1').flush(mockUser); + + const user = await userPromise; + expect(user).toEqual(mockUser); + }); +}); +``` + +### Coverage Configuration + +```typescript +// vite.config.ts +export default defineConfig({ + test: { + coverage: { + provider: 'v8', + reporter: ['text', 'html', 'lcov'], + exclude: [ + 'node_modules/', + 'src/test-setup.ts', + '**/*.spec.ts', + '**/*.d.ts', + ], + thresholds: { + statements: 80, + branches: 80, + functions: 80, + lines: 80, + }, + }, + }, +}); +``` + +### Vitest UI Mode + +```bash +# Run with UI +npx vitest --ui + +# Open UI at specific port +npx vitest --ui --port 51204 +``` + +### Concurrent Tests + +```typescript +import { describe, it, expect } from 'vitest'; + +// Run tests in this describe block concurrently +describe.concurrent('API calls', () => { + it('should fetch users', async () => { + // ... + }); + + it('should fetch products', async () => { + // ... + }); + + it('should fetch orders', async () => { + // ... + }); +}); +``` + +### Test Fixtures + +```typescript +import { describe, it, expect, beforeEach } from 'vitest'; + +// Shared test fixtures +const createTestUser = (overrides = {}) => ({ + id: '1', + name: 'Test User', + email: 'test@example.com', + ...overrides, +}); + +const createTestProduct = (overrides = {}) => ({ + id: '1', + name: 'Test Product', + price: 99.99, + ...overrides, +}); + +describe('Order', () => { + it('should calculate total', () => { + const fixture = TestBed.createComponent(Order); + fixture.componentRef.setInput('user', createTestUser()); + fixture.componentRef.setInput('products', [ + createTestProduct({ price: 10 }), + createTestProduct({ id: '2', price: 20 }), + ]); + fixture.detectChanges(); + + expect(fixture.componentInstance.total()).toBe(30); + }); +}); +``` + +## Component Harnesses + +Use Angular CDK component harnesses for more maintainable tests: + +### Creating a Harness + +```typescript +import { ComponentHarness, HarnessPredicate } from '@angular/cdk/testing'; + +export class CounterHarn extends ComponentHarness { + static hostSelector = 'app-counter'; + + // Locators + private getIncrementButton = this.locatorFor('button.increment'); + private getDecrementButton = this.locatorFor('button.decrement'); + private getCountDisplay = this.locatorFor('.count'); + + // Actions + async increment(): Promise { + const button = await this.getIncrementButton(); + await button.click(); + } + + async decrement(): Promise { + const button = await this.getDecrementButton(); + await button.click(); + } + + // Queries + async getCount(): Promise { + const display = await this.getCountDisplay(); + const text = await display.text(); + return parseInt(text, 10); + } + + // Filter factory + static with(options: { count?: number } = {}): HarnessPredicate { + return new HarnessPredicate(CounterHarn, options) + .addOption('count', options.count, async (harness, count) => { + return (await harness.getCount()) === count; + }); + } +} +``` + +### Using Harnesses in Tests + +```typescript +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; + +describe('Counter with Harness', () => { + let loader: HarnessLoader; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [Counter], + }).compileComponents(); + + const fixture = TestBed.createComponent(Counter); + loader = TestbedHarnessEnvironment.loader(fixture); + }); + + it('should increment count', async () => { + const counter = await loader.getHarness(CounterHarn); + + expect(await counter.getCount()).toBe(0); + + await counter.increment(); + expect(await counter.getCount()).toBe(1); + + await counter.increment(); + expect(await counter.getCount()).toBe(2); + }); + + it('should find counter with specific count', async () => { + const counter = await loader.getHarness(CounterHarn); + await counter.increment(); + await counter.increment(); + + // Find counter with count of 2 + const counterWith2 = await loader.getHarness(CounterHarn.with({ count: 2 })); + expect(counterWith2).toBeTruthy(); + }); +}); +``` + +## Testing Router + +### RouterTestingHarness + +```typescript +import { RouterTestingHarness } from '@angular/router/testing'; +import { provideRouter } from '@angular/router'; + +describe('Router Navigation', () => { + let harness: RouterTestingHarness; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + providers: [ + provideRouter([ + { path: '', component: Home }, + { path: 'users/:id', component: UserCmpt }, + ]), + ], + }).compileComponents(); + + harness = await RouterTestingHarness.create(); + }); + + it('should navigate to user page', async () => { + const component = await harness.navigateByUrl('/users/123', UserCmpt); + + expect(component.id()).toBe('123'); + }); + + it('should display user name', async () => { + await harness.navigateByUrl('/users/123'); + + expect(harness.routeNativeElement?.textContent).toContain('User 123'); + }); +}); +``` + +### Testing Guards + +```typescript +describe('AuthGuard', () => { + let authService: jasmine.SpyObj; + + beforeEach(() => { + authService = jasmine.createSpyObj('Auth', ['isAuthenticated']); + + TestBed.configureTestingModule({ + providers: [ + { provide: Auth, useValue: authService }, + provideRouter([ + { path: 'login', component: Login }, + { + path: 'dashboard', + component: Dashboard, + canActivate: [authGuard], + }, + ]), + ], + }); + }); + + it('should allow access when authenticated', async () => { + authService.isAuthenticated.and.returnValue(true); + + const harness = await RouterTestingHarness.create(); + await harness.navigateByUrl('/dashboard'); + + expect(harness.routeNativeElement?.textContent).toContain('Dashboard'); + }); + + it('should redirect to login when not authenticated', async () => { + authService.isAuthenticated.and.returnValue(false); + + const harness = await RouterTestingHarness.create(); + await harness.navigateByUrl('/dashboard'); + + expect(TestBed.inject(Router).url).toBe('/login'); + }); +}); +``` + +## Testing Forms + +### Testing Signal Forms + +```typescript +import { form, FormField, required, email } from '@angular/forms/signals'; + +@Component({ + imports: [FormField], + template: ` +
+ + + +
+ `, +}) +export class Login { + model = signal({ email: '', password: '' }); + loginForm = form(this.model, (schemaPath) => { + required(schemaPath.email); + email(schemaPath.email); + required(schemaPath.password); + }); + + submitted = signal(false); + + onSubmit(event: Event) { + event.preventDefault(); + if (this.loginForm().valid()) { + this.submitted.set(true); + } + } +} + +describe('Login', () => { + let fixture: ComponentFixture; + let component: Login; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [Login], + }).compileComponents(); + + fixture = TestBed.createComponent(Login); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should be invalid when empty', () => { + expect(component.loginForm().invalid()).toBeTrue(); + }); + + it('should be valid with correct data', () => { + component.model.set({ + email: 'test@example.com', + password: 'password123', + }); + + expect(component.loginForm().valid()).toBeTrue(); + }); + + it('should show email error for invalid email', () => { + component.loginForm.email().value.set('invalid'); + fixture.detectChanges(); + + expect(component.loginForm.email().invalid()).toBeTrue(); + expect(component.loginForm.email().errors().some(e => e.kind === 'email')).toBeTrue(); + }); + + it('should disable submit button when invalid', () => { + const button = fixture.nativeElement.querySelector('button'); + expect(button.disabled).toBeTrue(); + }); +}); +``` + +### Testing Reactive Forms + +```typescript +describe('ReactiveForm', () => { + it('should validate form', () => { + const fixture = TestBed.createComponent(ProfileForm); + const component = fixture.componentInstance; + + expect(component.form.valid).toBeFalse(); + + component.form.patchValue({ + name: 'John', + email: 'john@example.com', + }); + + expect(component.form.valid).toBeTrue(); + }); + + it('should show validation errors', () => { + const fixture = TestBed.createComponent(ProfileForm); + fixture.detectChanges(); + + const emailControl = fixture.componentInstance.form.controls.email; + emailControl.setValue('invalid'); + emailControl.markAsTouched(); + fixture.detectChanges(); + + const errorElement = fixture.nativeElement.querySelector('.error'); + expect(errorElement.textContent).toContain('Invalid email'); + }); +}); +``` + +## Testing Directives + +### Attribute Directive + +```typescript +@Directive({ + selector: '[appHighlight]', + host: { + '[style.backgroundColor]': 'color()', + }, +}) +export class Highlight { + color = input('yellow', { alias: 'appHighlight' }); +} + +describe('Highlight', () => { + @Component({ + imports: [Highlight], + template: `

Test

`, + }) + class Test {} + + it('should apply background color', () => { + const fixture = TestBed.createComponent(Test); + fixture.detectChanges(); + + const p = fixture.nativeElement.querySelector('p'); + expect(p.style.backgroundColor).toBe('lightblue'); + }); +}); +``` + +### Structural Directive + +```typescript +@Directive({ + selector: '[appIf]', +}) +export class If { + private templateRef = inject(TemplateRef); + private viewContainer = inject(ViewContainerRef); + + condition = input.required({ alias: 'appIf' }); + + constructor() { + effect(() => { + if (this.condition()) { + this.viewContainer.createEmbeddedView(this.templateRef); + } else { + this.viewContainer.clear(); + } + }); + } +} + +describe('If', () => { + @Component({ + imports: [If], + template: `

Visible

`, + }) + class TestCmpt { + show = signal(false); + } + + it('should show content when condition is true', () => { + const fixture = TestBed.createComponent(Test); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('p')).toBeNull(); + + fixture.componentInstance.show.set(true); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('p')).toBeTruthy(); + }); +}); +``` + +## Testing Pipes + +```typescript +@Pipe({ name: 'truncate' }) +export class Truncate implements PipeTransform { + transform(value: string, length: number = 50): string { + if (value.length <= length) return value; + return value.substring(0, length) + '...'; + } +} + +describe('Truncate', () => { + let pipe: Truncate; + + beforeEach(() => { + pipe = new Truncate(); + }); + + it('should not truncate short strings', () => { + expect(pipe.transform('Hello', 10)).toBe('Hello'); + }); + + it('should truncate long strings', () => { + expect(pipe.transform('Hello World', 5)).toBe('Hello...'); + }); + + it('should use default length', () => { + const longString = 'a'.repeat(60); + const result = pipe.transform(longString); + expect(result.length).toBe(53); // 50 + '...' + }); +}); +``` + +## E2E Testing Setup + +### Playwright Configuration + +```typescript +// playwright.config.ts +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + use: { + baseURL: 'http://localhost:4200', + trace: 'on-first-retry', + }, + webServer: { + command: 'npm run start', + url: 'http://localhost:4200', + reuseExistingServer: !process.env.CI, + }, +}); +``` + +### E2E Test Example + +```typescript +// e2e/login.spec.ts +import { test, expect } from '@playwright/test'; + +test.describe('Login', () => { + test('should login successfully', async ({ page }) => { + await page.goto('/login'); + + await page.fill('input[name="email"]', 'test@example.com'); + await page.fill('input[name="password"]', 'password123'); + await page.click('button[type="submit"]'); + + await expect(page).toHaveURL('/dashboard'); + await expect(page.locator('h1')).toContainText('Welcome'); + }); + + test('should show error for invalid credentials', async ({ page }) => { + await page.goto('/login'); + + await page.fill('input[name="email"]', 'wrong@example.com'); + await page.fill('input[name="password"]', 'wrongpassword'); + await page.click('button[type="submit"]'); + + await expect(page.locator('.error')).toBeVisible(); + await expect(page.locator('.error')).toContainText('Invalid credentials'); + }); +}); +``` + +## Test Utilities + +### Custom Test Helpers + +```typescript +// test-utils.ts +export function setSignalInput( + fixture: ComponentFixture, + inputName: string, + value: T +): void { + fixture.componentRef.setInput(inputName, value); + fixture.detectChanges(); +} + +export async function waitForSignal( + signal: () => T, + predicate: (value: T) => boolean, + timeout = 5000 +): Promise { + const start = Date.now(); + while (Date.now() - start < timeout) { + const value = signal(); + if (predicate(value)) return value; + await new Promise(resolve => setTimeout(resolve, 10)); + } + throw new Error('Timeout waiting for signal'); +} + +// Usage +it('should load data', async () => { + const fixture = TestBed.createComponent(Data); + fixture.detectChanges(); + + await waitForSignal( + () => fixture.componentInstance.data(), + data => data !== undefined + ); + + expect(fixture.componentInstance.data()).toBeDefined(); +}); +``` diff --git a/.agents/skills/angular-testing/references/vitest-migration.md b/.agents/skills/angular-testing/references/vitest-migration.md new file mode 100644 index 0000000..b56a1c1 --- /dev/null +++ b/.agents/skills/angular-testing/references/vitest-migration.md @@ -0,0 +1,164 @@ +# Vitest Setup and Migration Guide + +## Vitest vs Jasmine Comparison + +| Feature | Vitest | Jasmine/Karma | +|---------|--------|---------------| +| Speed | Faster (native ESM) | Slower | +| Watch mode | Instant feedback | Slower rebuilds | +| Mocking | `vi.fn()`, `vi.mock()` | `jasmine.createSpy()` | +| Assertions | `expect()` (Chai-style) | `expect()` (Jasmine) | +| UI | Built-in UI mode | Karma browser | +| Config | `angular.json` | `karma.conf.js` | + +## Migration from Jasmine to Vitest + +### Spy Migration + +```typescript +// Jasmine +const spy = jasmine.createSpy('callback'); +spy.and.returnValue('value'); +expect(spy).toHaveBeenCalledWith('arg'); + +// Vitest +const spy = vi.fn(); +spy.mockReturnValue('value'); +expect(spy).toHaveBeenCalledWith('arg'); +``` + +### SpyOn Migration + +```typescript +// Jasmine +spyOn(service, 'method').and.returnValue(of(data)); + +// Vitest +vi.spyOn(service, 'method').mockReturnValue(of(data)); +``` + +### createSpyObj Migration + +```typescript +// Jasmine +const mockService = jasmine.createSpyObj('UserService', ['getUser', 'updateUser']); +mockService.getUser.and.returnValue(of({ id: '1', name: 'Test' })); + +// Vitest +const mockService = { + getUser: vi.fn(), + updateUser: vi.fn(), +}; +mockService.getUser.mockReturnValue(of({ id: '1', name: 'Test' })); +``` + +### Async Testing Migration + +```typescript +// Jasmine - using done callback +it('should load data', (done) => { + service.loadData().subscribe(data => { + expect(data).toBeDefined(); + done(); + }); +}); + +// Vitest - using async/await +it('should load data', async () => { + const data = await firstValueFrom(service.loadData()); + expect(data).toBeDefined(); +}); +``` + +### Clock/Timer Migration + +```typescript +// Jasmine +jasmine.clock().install(); +jasmine.clock().tick(1000); +jasmine.clock().uninstall(); + +// Vitest +vi.useFakeTimers(); +vi.advanceTimersByTime(1000); +vi.useRealTimers(); +``` + +## Vitest Configuration Details + +### Full angular.json Configuration + +```json +{ + "projects": { + "your-app": { + "architect": { + "test": { + "builder": "@angular/build:unit-test", + "options": { + "tsConfig": "tsconfig.spec.json", + "buildTarget": "your-app:build" + } + } + } + } + } +} +``` + +### tsconfig.spec.json + +```json +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["vitest/globals"] + }, + "include": ["src/**/*.spec.ts"] +} +``` + +### Optional vite.config.ts + +For advanced configuration, create a `vite.config.ts`: + +```typescript +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'jsdom', + include: ['src/**/*.spec.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'html', 'lcov'], + exclude: [ + 'node_modules/', + 'src/test-setup.ts', + '**/*.spec.ts', + '**/*.d.ts', + ], + }, + }, +}); +``` + +## Running Vitest + +```bash +# Run tests +ng test + +# Watch mode +ng test --watch + +# Coverage +ng test --code-coverage + +# Run specific file pattern +ng test --include='**/user*.spec.ts' + +# CI mode (single run) +ng test --watch=false +``` diff --git a/.agents/skills/angular-tooling/SKILL.md b/.agents/skills/angular-tooling/SKILL.md new file mode 100644 index 0000000..33e7dd5 --- /dev/null +++ b/.agents/skills/angular-tooling/SKILL.md @@ -0,0 +1,352 @@ +--- +name: angular-tooling +description: Use Angular CLI and development tools effectively in Angular v20+ projects. Use for project setup, code generation, building, testing, and configuration. Triggers on creating new projects, generating components/services/modules, configuring builds, running tests, or optimizing production builds. Don't use for Nx workspace commands, custom Webpack configurations, or non-Angular CLI build systems like Vite standalone or esbuild direct usage. +--- + +# Angular Tooling + +Use Angular CLI and development tools for efficient Angular v20+ development. + +## Project Setup + +### Create New Project + +```bash +# Create new standalone project (default in v20+) +ng new my-app + +# With specific options +ng new my-app --style=scss --routing --ssr=false + +# Skip tests +ng new my-app --skip-tests + +# Minimal setup +ng new my-app --minimal --inline-style --inline-template +``` + +### Project Structure + +``` +my-app/ +├── src/ +│ ├── app/ +│ │ ├── app.component.ts +│ │ ├── app.config.ts +│ │ └── app.routes.ts +│ ├── index.html +│ ├── main.ts +│ └── styles.scss +├── public/ # Static assets +├── angular.json # CLI configuration +├── package.json +├── tsconfig.json +└── tsconfig.app.json +``` + +## Code Generation + +### Components + +```bash +# Generate component +ng generate component features/user-profile +ng g c features/user-profile # Short form + +# With options +ng g c shared/button --inline-template --inline-style +ng g c features/dashboard --skip-tests +ng g c features/settings --change-detection=OnPush + +# Flat (no folder) +ng g c shared/icon --flat + +# Dry run (preview) +ng g c features/checkout --dry-run +``` + +### Services + +```bash +# Generate service (providedIn: 'root' by default) +ng g service services/auth +ng g s services/user + +# Skip tests +ng g s services/api --skip-tests +``` + +### Other Schematics + +```bash +# Directive +ng g directive directives/highlight +ng g d directives/tooltip + +# Pipe +ng g pipe pipes/truncate +ng g p pipes/date-format + +# Guard (functional by default) +ng g guard guards/auth + +# Interceptor (functional by default) +ng g interceptor interceptors/auth + +# Interface +ng g interface models/user + +# Enum +ng g enum models/status + +# Class +ng g class models/product +``` + +### Generate with Path Alias + +```bash +# Components in feature folders +ng g c @features/products/product-list +ng g c @shared/ui/button +``` + +## Development Server + +```bash +# Start dev server +ng serve +ng s # Short form + +# With options +ng serve --port 4201 +ng serve --open # Open browser +ng serve --host 0.0.0.0 # Expose to network + +# Production mode locally +ng serve --configuration=production + +# With SSL +ng serve --ssl --ssl-key ./ssl/key.pem --ssl-cert ./ssl/cert.pem +``` + +## Building + +### Development Build + +```bash +ng build +``` + +### Production Build + +```bash +ng build --configuration=production +ng build -c production # Short form + +# With specific options +ng build -c production --source-map=false +ng build -c production --named-chunks +``` + +### Build Output + +``` +dist/my-app/ +├── browser/ +│ ├── index.html +│ ├── main-[hash].js +│ ├── polyfills-[hash].js +│ └── styles-[hash].css +└── server/ # If SSR enabled + └── main.js +``` + +## Testing + +### Unit Tests + +```bash +# Run tests +ng test +ng t # Short form + +# Single run (CI) +ng test --watch=false --browsers=ChromeHeadless + +# With coverage +ng test --code-coverage + +# Specific file +ng test --include=**/user.service.spec.ts +``` + +### E2E Tests + +```bash +# Run e2e (if configured) +ng e2e +``` + +## Linting + +```bash +# Run linter +ng lint + +# Fix auto-fixable issues +ng lint --fix +``` + +## Configuration + +### angular.json Key Sections + +```json +{ + "projects": { + "my-app": { + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:application", + "options": { + "outputPath": "dist/my-app", + "index": "src/index.html", + "browser": "src/main.ts", + "polyfills": ["zone.js"], + "tsConfig": "tsconfig.app.json", + "assets": ["{ \"glob\": \"**/*\", \"input\": \"public\" }"], + "styles": ["src/styles.scss"], + "scripts": [] + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kB", + "maximumError": "1MB" + } + ], + "outputHashing": "all" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true + } + } + } + } + } + } +} +``` + +### Environment Configuration + +```typescript +// src/environments/environment.ts +export const environment = { + production: false, + apiUrl: 'http://localhost:3000/api', +}; + +// src/environments/environment.prod.ts +export const environment = { + production: true, + apiUrl: 'https://api.example.com', +}; +``` + +Configure in angular.json: + +```json +{ + "configurations": { + "production": { + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.prod.ts" + } + ] + } + } +} +``` + +## Adding Libraries + +### Angular Libraries + +```bash +# Add Angular Material +ng add @angular/material + +# Add Angular PWA +ng add @angular/pwa + +# Add Angular SSR +ng add @angular/ssr + +# Add Angular Localize +ng add @angular/localize +``` + +### Third-Party Libraries + +```bash +# Install and configure +npm install @ngrx/signals + +# Some libraries have schematics +ng add @ngrx/store +``` + +## Update Angular + +```bash +# Check for updates +ng update + +# Update Angular core and CLI +ng update @angular/core @angular/cli + +# Update all packages +ng update --all + +# Force update (skip peer dependency checks) +ng update @angular/core @angular/cli --force +``` + +## Performance Analysis + +```bash +# Build with stats +ng build -c production --stats-json + +# Analyze bundle (install esbuild-visualizer) +npx esbuild-visualizer --metadata dist/my-app/browser/stats.json --open +``` + +## Caching + +```bash +# Enable persistent build cache (default in v20+) +# Configured in angular.json: +{ + "cli": { + "cache": { + "enabled": true, + "path": ".angular/cache", + "environment": "all" + } + } +} + +# Clear cache +rm -rf .angular/cache +``` + +For advanced configuration, see [references/tooling-patterns.md](references/tooling-patterns.md). diff --git a/.agents/skills/angular-tooling/references/tooling-patterns.md b/.agents/skills/angular-tooling/references/tooling-patterns.md new file mode 100644 index 0000000..b514366 --- /dev/null +++ b/.agents/skills/angular-tooling/references/tooling-patterns.md @@ -0,0 +1,448 @@ +# Angular Tooling Patterns + +## Table of Contents +- [Custom Schematics](#custom-schematics) +- [Build Optimization](#build-optimization) +- [Multi-Project Workspace](#multi-project-workspace) +- [CI/CD Configuration](#cicd-configuration) +- [Path Aliases](#path-aliases) +- [Proxy Configuration](#proxy-configuration) + +## Custom Schematics + +### Generate Schematic Collection + +```bash +# Install schematics CLI +npm install -g @angular-devkit/schematics-cli + +# Create schematic collection +schematics blank --name=my-schematics +``` + +### Simple Component Schematic + +```typescript +// src/my-component/index.ts +import { Rule, SchematicContext, Tree, apply, url, template, move, mergeWith } from '@angular-devkit/schematics'; +import { strings } from '@angular-devkit/core'; + +export function myComponent(options: { name: string; path: string }): Rule { + return (tree: Tree, context: SchematicContext) => { + const templateSource = apply(url('./files'), [ + template({ + ...options, + ...strings, + }), + move(options.path), + ]); + + return mergeWith(templateSource)(tree, context); + }; +} +``` + +### Use Custom Schematics + +```bash +# Link locally +npm link ./my-schematics + +# Use +ng generate my-schematics:my-component --name=test --path=src/app +``` + +## Build Optimization + +### Budget Configuration + +```json +{ + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kB", + "maximumError": "1MB" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "4kB", + "maximumError": "8kB" + }, + { + "type": "anyScript", + "maximumWarning": "100kB", + "maximumError": "200kB" + } + ] +} +``` + +### Differential Loading + +Automatic in v20+ - builds for modern browsers by default. + +```json +// .browserslistrc +last 2 Chrome versions +last 2 Firefox versions +last 2 Safari versions +last 2 Edge versions +``` + +### Code Splitting + +```typescript +// Lazy load routes for automatic code splitting +export const routes: Routes = [ + { + path: 'admin', + loadChildren: () => import('./admin/admin.routes').then(m => m.adminRoutes), + }, + { + path: 'reports', + loadComponent: () => import('./reports/reports.component').then(m => m.Reports), + }, +]; +``` + +### Tree Shaking + +Ensure proper imports for tree shaking: + +```typescript +// Good - tree shakeable +import { map, filter } from 'rxjs'; + +// Avoid - imports entire library +import * as rxjs from 'rxjs'; +``` + +### Preload Strategy + +```typescript +// app.config.ts +import { provideRouter, withPreloading, PreloadAllModules } from '@angular/router'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideRouter(routes, withPreloading(PreloadAllModules)), + ], +}; +``` + +## Multi-Project Workspace + +### Create Workspace + +```bash +# Create empty workspace +ng new my-workspace --create-application=false + +cd my-workspace + +# Add applications +ng generate application main-app +ng generate application admin-app + +# Add library +ng generate library shared-ui +ng generate library data-access +``` + +### Workspace Structure + +``` +my-workspace/ +├── projects/ +│ ├── main-app/ +│ │ └── src/ +│ ├── admin-app/ +│ │ └── src/ +│ ├── shared-ui/ +│ │ └── src/ +│ └── data-access/ +│ └── src/ +├── angular.json +└── package.json +``` + +### Build Specific Project + +```bash +ng build main-app +ng build shared-ui +ng serve admin-app +``` + +### Library Configuration + +```json +// projects/shared-ui/ng-package.json +{ + "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", + "dest": "../../dist/shared-ui", + "lib": { + "entryFile": "src/public-api.ts" + } +} +``` + +### Using Library in App + +```typescript +// After building library: ng build shared-ui +import { Button } from 'shared-ui'; + +@Component({ + imports: [Button], + template: `Click`, +}) +export class App {} +``` + +## CI/CD Configuration + +### GitHub Actions + +```yaml +# .github/workflows/ci.yml +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Test + run: npm run test -- --watch=false --browsers=ChromeHeadless --code-coverage + + - name: Build + run: npm run build -- -c production + + - name: Upload coverage + uses: codecov/codecov-action@v3 + with: + files: ./coverage/lcov.info +``` + +### GitLab CI + +```yaml +# .gitlab-ci.yml +image: node:20 + +cache: + paths: + - node_modules/ + - .angular/cache/ + +stages: + - install + - test + - build + +install: + stage: install + script: + - npm ci + +test: + stage: test + script: + - npm run lint + - npm run test -- --watch=false --browsers=ChromeHeadless + +build: + stage: build + script: + - npm run build -- -c production + artifacts: + paths: + - dist/ +``` + +## Path Aliases + +### Configure tsconfig.json + +```json +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@app/*": ["src/app/*"], + "@env/*": ["src/environments/*"], + "@shared/*": ["src/app/shared/*"], + "@features/*": ["src/app/features/*"], + "@core/*": ["src/app/core/*"] + } + } +} +``` + +### Usage + +```typescript +// Instead of relative imports +import { User } from '../../../core/services/user.service'; + +// Use path alias +import { User } from '@core/services/user.service'; +``` + +## Proxy Configuration + +### Development Proxy + +```json +// proxy.conf.json +{ + "/api": { + "target": "http://localhost:3000", + "secure": false, + "changeOrigin": true + }, + "/auth": { + "target": "http://localhost:4000", + "secure": false, + "pathRewrite": { + "^/auth": "" + } + } +} +``` + +### Configure in angular.json + +```json +{ + "serve": { + "options": { + "proxyConfig": "proxy.conf.json" + } + } +} +``` + +### Or via CLI + +```bash +ng serve --proxy-config proxy.conf.json +``` + +## Custom Builders + +### Using esbuild (Default in v20+) + +```json +{ + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:application", + "options": { + "browser": "src/main.ts" + } + } + } +} +``` + +### SSR Configuration + +```bash +# Add SSR +ng add @angular/ssr +``` + +```json +{ + "architect": { + "build": { + "options": { + "server": "src/main.server.ts", + "prerender": true, + "ssr": { + "entry": "server.ts" + } + } + } + } +} +``` + +## Debugging + +### Source Maps + +```json +{ + "configurations": { + "development": { + "sourceMap": true + }, + "production": { + "sourceMap": { + "scripts": true, + "styles": false, + "hidden": true, + "vendor": false + } + } + } +} +``` + +### Verbose Logging + +```bash +ng build --verbose +ng serve --verbose +``` + +### Debug Tests + +```bash +# Run tests with debugging +ng test --browsers=Chrome + +# In Chrome DevTools, open Sources tab and set breakpoints +``` + +## Package Scripts + +```json +{ + "scripts": { + "start": "ng serve", + "build": "ng build", + "build:prod": "ng build -c production", + "test": "ng test", + "test:ci": "ng test --watch=false --browsers=ChromeHeadless --code-coverage", + "lint": "ng lint", + "lint:fix": "ng lint --fix", + "analyze": "ng build -c production --stats-json && npx esbuild-visualizer --metadata dist/my-app/browser/stats.json --open", + "update": "ng update" + } +} +``` diff --git a/.agents/skills/caveman/SKILL.md b/.agents/skills/caveman/SKILL.md new file mode 100644 index 0000000..85770a3 --- /dev/null +++ b/.agents/skills/caveman/SKILL.md @@ -0,0 +1,49 @@ +--- +name: caveman +description: > + Ultra-compressed communication mode. Cuts token usage ~75% by dropping + filler, articles, and pleasantries while keeping full technical accuracy. + Use when user says "caveman mode", "talk like caveman", "use caveman", + "less tokens", "be brief", or invokes /caveman. +--- + +Respond terse like smart caveman. All technical substance stay. Only fluff die. + +## Persistence + +ACTIVE EVERY RESPONSE once triggered. No revert after many turns. No filler drift. Still active if unsure. Off only when user says "stop caveman" or "normal mode". + +## Rules + +Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/of course/happy to), hedging. Fragments OK. Short synonyms (big not extensive, fix not "implement a solution for"). Abbreviate common terms (DB/auth/config/req/res/fn/impl). Strip conjunctions. Use arrows for causality (X -> Y). One word when one word enough. + +Technical terms stay exact. Code blocks unchanged. Errors quoted exact. + +Pattern: `[thing] [action] [reason]. [next step].` + +Not: "Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by..." +Yes: "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:" + +### Examples + +**"Why React component re-render?"** + +> Inline obj prop -> new ref -> re-render. `useMemo`. + +**"Explain database connection pooling."** + +> Pool = reuse DB conn. Skip handshake -> fast under load. + +## Auto-Clarity Exception + +Drop caveman temporarily for: security warnings, irreversible action confirmations, multi-step sequences where fragment order risks misread, user asks to clarify or repeats question. Resume caveman after clear part done. + +Example -- destructive op: + +> **Warning:** This will permanently delete all rows in the `users` table and cannot be undone. +> +> ```sql +> DROP TABLE users; +> ``` +> +> Caveman resume. Verify backup exist first. diff --git a/.agents/skills/code-simplifier/SKILL.md b/.agents/skills/code-simplifier/SKILL.md new file mode 100644 index 0000000..4c2aff8 --- /dev/null +++ b/.agents/skills/code-simplifier/SKILL.md @@ -0,0 +1,67 @@ +--- +name: code-simplifier +description: > + Simplifies and refines code for clarity, consistency, and maintainability + while preserving all functionality. Focuses on recently modified code unless + instructed otherwise. Trigger on requests like "simplify this code", + "clean up", "refactor for clarity", "make this more readable", or after + writing significant code that could benefit from refinement. +model: opus +--- + +You are an expert code simplification specialist focused on enhancing code clarity, consistency, and maintainability while preserving exact functionality. Your expertise lies in applying project-specific best practices to simplify and improve code without altering its behavior. You prioritize readable, explicit code over overly compact solutions. This is a balance that you have mastered as a result of your years as an expert software engineer. + +You will analyze recently modified code and apply refinements that: + +## 1. Preserve Functionality + +Never change what the code does - only how it does it. All original features, outputs, and behaviors must remain intact. + +## 2. Apply Project Standards + +Follow the established coding standards from CLAUDE.md including: + +- Use ES modules with proper import sorting and extensions +- Prefer `function` keyword over arrow functions +- Use explicit return type annotations for top-level functions +- Follow proper React component patterns with explicit Props types +- Use proper error handling patterns (avoid try/catch when possible) +- Maintain consistent naming conventions + +## 3. Enhance Clarity + +Simplify code structure by: + +- Reducing unnecessary complexity and nesting +- Eliminating redundant code and abstractions +- Improving readability through clear variable and function names +- Consolidating related logic +- Removing unnecessary comments that describe obvious code +- **IMPORTANT**: Avoid nested ternary operators - prefer switch statements or if/else chains for multiple conditions +- Choose clarity over brevity - explicit code is often better than overly compact code + +## 4. Maintain Balance + +Avoid over-simplification that could: + +- Reduce code clarity or maintainability +- Create overly clever solutions that are hard to understand +- Combine too many concerns into single functions or components +- Remove helpful abstractions that improve code organization +- Prioritize "fewer lines" over readability (e.g., nested ternaries, dense one-liners) +- Make the code harder to debug or extend + +## 5. Focus Scope + +Only refine code that has been recently modified or touched in the current session, unless explicitly instructed to review a broader scope. + +## Refinement Process + +1. Identify the recently modified code sections +2. Analyze for opportunities to improve elegance and consistency +3. Apply project-specific best practices and coding standards +4. Ensure all functionality remains unchanged +5. Verify the refined code is simpler and more maintainable +6. Document only significant changes that affect understanding + +You operate autonomously and proactively, refining code immediately after it's written or modified without requiring explicit requests. Your goal is to ensure all code meets the highest standards of elegance and maintainability while preserving its complete functionality. diff --git a/.agents/skills/dagger-like-website/SKILL.md b/.agents/skills/dagger-like-website/SKILL.md new file mode 100644 index 0000000..9bdef94 --- /dev/null +++ b/.agents/skills/dagger-like-website/SKILL.md @@ -0,0 +1,111 @@ +--- +name: dagger-like-website +description: > + Build dagger.io-inspired project landing pages and documentation sites using + pure CSS (CSS Layers, OKLCH colors, BEM naming). Use when creating marketing + websites, project homepages, docs sites, or landing pages that follow the + dagger.io aesthetic: warm cream canvas, navy ink, generous whitespace, rounded + white cards. Triggers: "landing page", "project site", "docs site", + "dagger style", "marketing page", "product page", "harbor satellite site", + or building any static site with this visual language. Works with Hugo or + standalone HTML. Do NOT use for dashboard/app UI. +--- + +# Dagger-like Website + +Build project landing pages and documentation sites following the dagger.io visual language: warm cream backgrounds, navy-tinted neutrals, generous whitespace, rounded card layouts. + +## Workflow + +1. **Determine page type**: Landing page, docs page, or blog layout +2. **Choose base**: Hugo integration or standalone HTML +3. **Build sections** using the component patterns below +4. **Apply design tokens** from references/design-tokens.md +5. **Add responsive styles** at standard breakpoints + +## Design Identity + +Warm, open, technical-but-approachable. Cream paper, navy ink, generous air. + +- Cream canvas (`oklch(96.5% 0.015 80)`) -- never pure white backgrounds +- Navy ink for text (`oklch(15% 0.04 280)`) -- never pure black +- Sections breathe with 100-160px vertical padding +- White cards on cream: `border-radius: 16px`, no visible borders +- Accents (teal, orange, yellow) used sparingly in visuals, not text +- Headings are large but light-weight (400), not bold + +## CSS Stack + +Pure CSS, no preprocessors, no build step for styles. + +```css +@layer reset, base, components, utilities; +``` + +- **OKLCH color space** with two-tier tokens (raw values + semantic names) +- **BEM naming** (`.block__element--modifier`) for all components +- **CSS custom properties** for all shared values +- **Native CSS nesting** for pseudo-classes, media queries, child selectors only + +### Critical Rule + +**No `&__element` BEM nesting.** Native CSS `&` is NOT string concatenation. `.navbar { &__inner {} }` produces `.navbar .navbar__inner`, not `.navbar__inner`. Keep BEM selectors flat. + +Valid nesting: `&:hover`, `&::before`, `& svg`, `@media` blocks. + +## Landing Page Sections + +A typical page flows top-to-bottom: + +1. **Navbar** -- Fixed top, logo left, links center, CTA right (navy button) +2. **Hero** -- Two-column: title + subtitle + CTA left, visual right +3. **Logo ticker** -- Horizontal muted logo row (or problem statement) +4. **Feature cards** -- Stacked white cards, text left + colored visual right +5. **Community** -- Centered header + 3-column card grid +6. **Newsletter** -- Email input + submit button +7. **Footer** -- Dark navy, logo + link columns + social icons + +Each section component is documented with HTML structure and key CSS in references/components.md. + +## Documentation Layout + +- Sidebar navigation (left, 16rem) + content area (right, fluid) +- Prose styling for markdown: `--text-base`, `--leading-relaxed`, max-width 48rem +- Code blocks: dark navy background +- Callout boxes: teal border-left for info, orange for warnings + +See references/docs-layout.md for full docs patterns. + +## Responsive Breakpoints + +``` +1024px -- tablet: 2-col to 1-col, reduce padding +768px -- mobile: hide nav links, stack everything +480px -- small mobile: reduce font sizes, tighter spacing +``` + +Responsive styles go OUTSIDE layers (for cascade override). + +## Hugo Integration + +When using Hugo, CSS files go in `themes//assets/css/dagger/`. Files are concatenated via `resources.Concat` (Hugo does NOT resolve `@import`). + +To add a new component: +1. Create `assets/css/dagger/components/my-component.css` +2. Wrap in `@layer components { }` +3. Register in `layouts/partials/dagger/css.html` + +Two base templates: +- `index-baseof.html` -- Homepage only (inline CSS) +- `_default/baseof.html` -- All other pages (loads dagger CSS partial) + +## CSS Framework Compatibility + +This skill aligns with the container-registry/css-framework spec (same layers, tokens, BEM). The framework adds dashboard components (sidebar, table, modal) not used in landing pages but available for docs pages needing richer UI. + +## Reference Files + +- **Design tokens**: See references/design-tokens.md for full color, spacing, typography, and layout token values +- **Component patterns**: See references/components.md for HTML structure and CSS for each landing page section +- **Docs layout**: See references/docs-layout.md for documentation page patterns +- **Template**: The assets/ directory contains a complete dagger-clone HTML/CSS prototype that can be used as a starting point diff --git a/.agents/skills/dagger-like-website/assets/index.html b/.agents/skills/dagger-like-website/assets/index.html new file mode 100644 index 0000000..82bbbdd --- /dev/null +++ b/.agents/skills/dagger-like-website/assets/index.html @@ -0,0 +1,292 @@ + + + + + + Dagger.io + + + + + + + + +
+ +
+
+
+

A better way to ship

+
+

Build, test and deploy any codebase, repeatably and at scale.

+

Runs locally, in your CI server, or directly in the cloud.

+
+
+ Get Started +

brew install dagger/tap/dagger

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

Programmable

+
+

Orchestrating end-to-end tests requires a lot of automation. We believe shell scripts and proprietary YAML are no longer an acceptable developer experience for this automation.

+

Dagger provides a complete platform for modern test orchestration: a runtime; system API; SDKs for 8 languages; interactive REPL; and more.

+
+
+
+
+ + +
+
+

Local-first

+
+

With Dagger, local execution is not an afterthought but a core feature.

+

Once Dagger is configured to orchestrate your tests, it will reliably do so on any supported system: your laptop, AI sandbox, CI server, or dedicated cloud infrastructure. The only dependency is a recent Linux kernel.

+
+
+
+
+ + +
+
+

Repeatable

+
+

Dagger is designed from the ground up for repeatability: tests run in containers; your orchestration logic runs in sandboxed functions; host dependencies are explicit and strictly typed; intermediate artifacts and environments are built just-in-time; everything is cached by default with fine-grained cache control.

+

Whether it's a test result or an intermediate artifact, Dagger gives you an output you can trust.

+
+ Read Docs +
+
+
+ + +
+
+

Observable

+
+

Built-in tracing, logs, and metrics that show exactly what's happening at every step. Debug complex workflows immediately instead of guessing what went wrong from a wall of text logs.

+
+ Learn More +
+
+
+ +
+
+ + +
+ +
+ + + +
+ + + + + + diff --git a/.agents/skills/dagger-like-website/assets/styles/base.css b/.agents/skills/dagger-like-website/assets/styles/base.css new file mode 100644 index 0000000..e4aa84d --- /dev/null +++ b/.agents/skills/dagger-like-website/assets/styles/base.css @@ -0,0 +1,45 @@ +/* Base element styles */ +@layer base { + body { + font-family: var(--font-sans); + font-weight: var(--weight-medium); + font-size: var(--text-lg); + line-height: var(--leading-normal); + color: var(--color-ink); + background-color: var(--color-canvas); + } + + h1 { + font-size: var(--text-4xl); + font-weight: var(--weight-semibold); + line-height: var(--leading-tight); + letter-spacing: -0.01em; + color: var(--color-ink); + } + + h2 { + font-size: var(--text-3xl); + font-weight: var(--weight-semibold); + line-height: var(--leading-snug); + color: var(--color-ink); + } + + h4 { + font-size: var(--text-2xl); + font-weight: var(--weight-semibold); + line-height: var(--leading-snug); + color: var(--color-ink); + } + + p { + line-height: var(--leading-normal); + } + + a { + transition: opacity 0.2s ease; + + &:hover { + opacity: 0.7; + } + } +} diff --git a/.agents/skills/dagger-like-website/assets/styles/components/community.css b/.agents/skills/dagger-like-website/assets/styles/components/community.css new file mode 100644 index 0000000..cc047e0 --- /dev/null +++ b/.agents/skills/dagger-like-website/assets/styles/components/community.css @@ -0,0 +1,143 @@ +/* Community section component */ +@layer components { + .community { + padding: var(--space-32) var(--page-padding); + text-align: center; + + &__inner { + max-width: var(--page-max-width); + margin: 0 auto; + } + + &__label { + display: inline-flex; + align-items: center; + gap: var(--space-2); + font-size: var(--text-base); + font-weight: var(--weight-medium); + color: var(--color-ink); + margin-bottom: var(--space-4); + + &::before { + content: ""; + width: 10px; + height: 10px; + border-radius: var(--radius-full); + background: var(--color-teal); + } + } + + &__title { + margin-bottom: var(--space-4); + } + + &__subtitle { + font-size: var(--text-lg); + color: var(--color-ink); + margin-bottom: var(--space-16); + } + + &__cards { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: var(--space-6); + margin-bottom: var(--space-16); + } + + &__card { + background: var(--color-surface); + border-radius: var(--radius-lg); + padding: var(--space-10); + text-align: left; + display: flex; + flex-direction: column; + gap: var(--space-4); + transition: transform 0.2s ease, box-shadow 0.2s ease; + + &:hover { + opacity: 1; + transform: translateY(-2px); + box-shadow: var(--shadow-md); + } + } + + &__card-icon { + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + color: var(--color-ink); + + & svg { + width: 32px; + height: 32px; + } + } + + &__card-title { + font-size: var(--text-2xl); + font-weight: var(--weight-semibold); + } + + &__card-desc { + font-size: var(--text-base); + color: var(--color-ink-muted); + line-height: var(--leading-relaxed); + } + } + + .newsletter { + max-width: var(--page-max-width); + margin: 0 auto; + padding: var(--space-12) 0; + + &__title { + font-size: var(--text-2xl); + font-weight: var(--weight-semibold); + text-align: center; + margin-bottom: var(--space-6); + } + + &__form { + display: flex; + align-items: center; + justify-content: center; + gap: var(--space-3); + max-width: 480px; + margin: 0 auto; + } + + &__input { + flex: 1; + padding: var(--space-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + font-size: var(--text-base); + background: transparent; + + &::placeholder { + color: var(--color-ink-muted); + } + + &:focus { + outline: 2px solid var(--color-navy); + outline-offset: -1px; + } + } + + &__submit { + padding: 8px 20px; + background: var(--color-navy); + color: var(--color-surface); + border-radius: var(--radius-md); + font-size: var(--text-sm); + font-weight: var(--weight-medium); + transition: background 0.2s ease; + + &:hover { + background: oklch(from var(--color-navy) calc(l + 0.08) c h); + } + } + } +} diff --git a/.agents/skills/dagger-like-website/assets/styles/components/features.css b/.agents/skills/dagger-like-website/assets/styles/components/features.css new file mode 100644 index 0000000..96b199e --- /dev/null +++ b/.agents/skills/dagger-like-website/assets/styles/components/features.css @@ -0,0 +1,90 @@ +/* Feature sections component */ +@layer components { + .features { + padding: 0 var(--page-padding); + + &__list { + display: flex; + flex-direction: column; + gap: 64px; + max-width: var(--page-max-width); + margin: 0 auto; + } + } + + .feature-card { + display: flex; + align-items: stretch; + gap: 80px; + background: var(--color-surface); + border-radius: var(--radius-lg); + padding: 48px 80px; + min-height: 450px; + + &__content { + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + gap: var(--space-6); + } + + &__title { + margin-bottom: var(--space-2); + } + + &__text { + display: flex; + flex-direction: column; + gap: var(--space-4); + + & p { + font-size: var(--text-lg); + line-height: var(--leading-normal); + color: var(--color-ink); + } + } + + &__link { + display: inline-flex; + align-items: center; + gap: var(--space-2); + font-size: var(--text-xs); + font-weight: var(--weight-medium); + color: var(--color-ink); + padding: 8px 20px; + background: var(--color-navy); + color: var(--color-surface); + border-radius: var(--radius-md); + align-self: flex-start; + margin-top: var(--space-4); + transition: background 0.2s ease; + + &:hover { + opacity: 1; + background: oklch(from var(--color-navy) calc(l + 0.08) c h); + } + } + + &__visual { + flex: 0 0 380px; + border-radius: var(--radius-lg); + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + + &--teal { + background: var(--color-teal); + } + + &--orange { + background: var(--color-orange); + } + + &--yellow { + background: var(--color-yellow); + } + } + } +} diff --git a/.agents/skills/dagger-like-website/assets/styles/components/footer.css b/.agents/skills/dagger-like-website/assets/styles/components/footer.css new file mode 100644 index 0000000..ca7c4a7 --- /dev/null +++ b/.agents/skills/dagger-like-website/assets/styles/components/footer.css @@ -0,0 +1,118 @@ +/* Footer component */ +@layer components { + .footer { + background: var(--color-navy-deep); + color: var(--color-surface); + padding: 100px 100px 40px; + + &__inner { + max-width: var(--page-max-width); + margin: 0 auto; + } + + &__top { + display: flex; + justify-content: space-between; + gap: var(--space-16); + margin-bottom: var(--space-16); + } + + &__logo { + flex-shrink: 0; + + & svg { + height: 32px; + width: auto; + } + } + + &__columns { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: var(--space-12); + flex: 1; + } + + &__column-title { + font-size: var(--text-sm); + font-weight: var(--weight-semibold); + color: oklch(100% 0 0 / 0.5); + margin-bottom: var(--space-4); + text-transform: none; + } + + &__column-links { + display: flex; + flex-direction: column; + gap: var(--space-2); + } + + &__column-link { + font-size: var(--text-sm); + font-weight: var(--weight-regular); + color: oklch(100% 0 0 / 0.8); + transition: color 0.2s ease; + + &:hover { + opacity: 1; + color: var(--color-surface); + } + } + + &__divider { + height: 1px; + background: oklch(100% 0 0 / 0.1); + margin-bottom: var(--space-8); + } + + &__bottom { + display: flex; + justify-content: space-between; + align-items: center; + } + + &__bottom-left { + display: flex; + align-items: center; + gap: var(--space-6); + } + + &__copyright { + font-size: var(--text-sm); + color: oklch(100% 0 0 / 0.5); + } + + &__legal-link { + font-size: var(--text-sm); + color: oklch(100% 0 0 / 0.5); + + &:hover { + opacity: 1; + color: oklch(100% 0 0 / 0.8); + } + } + + &__social { + display: flex; + align-items: center; + gap: var(--space-4); + } + + &__social-link { + color: oklch(100% 0 0 / 0.5); + display: flex; + align-items: center; + transition: color 0.2s ease; + + &:hover { + opacity: 1; + color: var(--color-surface); + } + + & svg { + width: 20px; + height: 20px; + } + } + } +} diff --git a/.agents/skills/dagger-like-website/assets/styles/components/hero.css b/.agents/skills/dagger-like-website/assets/styles/components/hero.css new file mode 100644 index 0000000..774c4b9 --- /dev/null +++ b/.agents/skills/dagger-like-website/assets/styles/components/hero.css @@ -0,0 +1,79 @@ +/* Hero section component */ +@layer components { + .hero { + padding: calc(var(--navbar-height) + var(--space-16)) var(--page-padding) var(--space-20); + min-height: 100vh; + display: flex; + align-items: flex-start; + + &__inner { + display: flex; + align-items: flex-start; + justify-content: space-between; + width: 100%; + max-width: var(--page-max-width); + margin: 0 auto; + gap: var(--space-16); + } + + &__content { + flex: 1; + max-width: 600px; + padding-top: var(--space-16); + } + + &__title { + margin-bottom: var(--space-6); + } + + &__description { + display: flex; + flex-direction: column; + gap: var(--space-2); + margin-bottom: var(--space-8); + color: var(--color-ink); + } + + &__actions { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--space-3); + } + + &__cta { + display: inline-flex; + align-items: center; + padding: 8px 20px; + background: var(--color-navy); + color: var(--color-surface); + border-radius: var(--radius-md); + font-size: var(--text-sm); + font-weight: var(--weight-medium); + transition: background 0.2s ease; + + &:hover { + opacity: 1; + background: oklch(from var(--color-navy) calc(l + 0.08) c h); + } + } + + &__brew { + font-family: var(--font-mono); + font-size: var(--text-xl); + color: var(--color-ink); + padding: var(--space-2) var(--space-4); + background: oklch(var(--lch-cream) / 0.6); + border-radius: var(--radius-sm); + } + + &__visual { + flex: 0 0 auto; + width: 420px; + height: 500px; + background: var(--color-border); + border-radius: var(--radius-lg); + overflow: hidden; + } + } +} diff --git a/.agents/skills/dagger-like-website/assets/styles/components/navbar.css b/.agents/skills/dagger-like-website/assets/styles/components/navbar.css new file mode 100644 index 0000000..1d1b8e3 --- /dev/null +++ b/.agents/skills/dagger-like-website/assets/styles/components/navbar.css @@ -0,0 +1,102 @@ +/* Navbar component */ +@layer components { + .navbar { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: var(--z-navbar); + display: flex; + align-items: center; + justify-content: center; + height: var(--navbar-height); + padding: 0 var(--page-padding); + + &__inner { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + max-width: var(--page-max-width); + } + + &__logo { + display: flex; + align-items: center; + + & svg { + height: 26px; + width: auto; + } + } + + &__links { + display: flex; + align-items: center; + gap: var(--space-8); + } + + &__link { + font-size: var(--text-base); + font-weight: var(--weight-medium); + color: var(--color-ink); + } + + &__actions { + display: flex; + align-items: center; + gap: var(--space-4); + } + + &__github { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: var(--text-sm); + color: var(--color-ink); + padding: 4px 10px; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-surface); + + & svg { + width: 16px; + height: 16px; + } + } + + &__github-count { + border-left: 1px solid var(--color-border); + padding-left: var(--space-2); + margin-left: var(--space-1); + } + + &__discord { + display: flex; + align-items: center; + color: var(--color-ink); + + & svg { + width: 24px; + height: 24px; + } + } + + &__cta { + display: inline-flex; + align-items: center; + padding: 8px 20px; + background: var(--color-navy); + color: var(--color-surface); + border-radius: var(--radius-md); + font-size: var(--text-sm); + font-weight: var(--weight-medium); + transition: background 0.2s ease; + + &:hover { + opacity: 1; + background: oklch(from var(--color-navy) calc(l + 0.08) c h); + } + } + } +} diff --git a/.agents/skills/dagger-like-website/assets/styles/main.css b/.agents/skills/dagger-like-website/assets/styles/main.css new file mode 100644 index 0000000..213431a --- /dev/null +++ b/.agents/skills/dagger-like-website/assets/styles/main.css @@ -0,0 +1,713 @@ +/* Dagger.io Clone - Complete CSS */ +/* Architecture: CSS Layers, OKLCH Colors, BEM Naming, Native Nesting */ + +@layer reset, base, components, utilities; + +/* ============================================ + Design Tokens + ============================================ */ +:root { + /* Tier 1: Raw OKLCH values */ + --lch-cream: 96.5% 0.015 80; + --lch-navy: 18% 0.04 280; + --lch-navy-deep: 16% 0.04 280; + --lch-white: 100% 0 0; + --lch-teal: 70% 0.1 190; + --lch-orange: 65% 0.18 50; + --lch-yellow: 82% 0.17 90; + --lch-gray-light: 92% 0.01 280; + --lch-ink: 15% 0.04 280; + --lch-ink-muted: 40% 0.02 280; + + /* Tier 2: Semantic colors */ + --color-canvas: oklch(var(--lch-cream)); + --color-surface: oklch(var(--lch-white)); + --color-ink: oklch(var(--lch-ink)); + --color-ink-muted: oklch(var(--lch-ink-muted)); + --color-navy: oklch(var(--lch-navy)); + --color-navy-deep: oklch(var(--lch-navy-deep)); + --color-teal: oklch(var(--lch-teal)); + --color-orange: oklch(var(--lch-orange)); + --color-yellow: oklch(var(--lch-yellow)); + --color-border: oklch(var(--lch-gray-light)); + + /* Spacing */ + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-3: 0.75rem; + --space-4: 1rem; + --space-5: 1.25rem; + --space-6: 1.5rem; + --space-8: 2rem; + --space-10: 2.5rem; + --space-12: 3rem; + --space-16: 4rem; + --space-20: 5rem; + --space-24: 6rem; + --space-32: 8rem; + + /* Typography */ + --font-sans: "General Sans", system-ui, -apple-system, sans-serif; + --font-mono: "Source Code Pro", ui-monospace, monospace; + --text-xs: 0.75rem; + --text-sm: 0.875rem; + --text-base: 1rem; + --text-lg: 1.125rem; + --text-xl: 1.25rem; + --text-2xl: 1.5rem; + --text-3xl: 2.5rem; + --text-4xl: 3.5rem; + --weight-regular: 400; + --weight-medium: 500; + --weight-semibold: 600; + --leading-tight: 1.1; + --leading-snug: 1.3; + --leading-normal: 1.4; + --leading-relaxed: 1.6; + + /* Borders & Radius */ + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 16px; + --radius-full: 9999px; + + /* Shadows */ + --shadow-md: 0 4px 12px oklch(0% 0 0 / 0.08); + + /* Z-index */ + --z-navbar: 10; + + /* Layout */ + --page-max-width: 1200px; + --page-padding: 60px; + --navbar-height: 69px; +} + +/* ============================================ + Reset Layer + ============================================ */ +@layer reset { + *, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; + } + + html { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + + body { min-height: 100dvh; line-height: 1.5; } + + img, svg { display: block; max-width: 100%; height: auto; } + + a { color: inherit; text-decoration: none; } + + button, input { font: inherit; color: inherit; background: none; border: none; cursor: pointer; } + + ul, ol { list-style: none; } + + h1, h2, h3, h4, h5, h6 { font-size: inherit; font-weight: inherit; } +} + +/* ============================================ + Base Layer + ============================================ */ +@layer base { + body { + font-family: var(--font-sans); + font-weight: var(--weight-regular); + font-size: var(--text-lg); + line-height: var(--leading-normal); + color: var(--color-ink); + background-color: var(--color-canvas); + } + + h1 { + font-size: var(--text-4xl); + font-weight: var(--weight-regular); + line-height: var(--leading-tight); + letter-spacing: -0.01em; + } + + h2 { + font-size: var(--text-3xl); + font-weight: var(--weight-regular); + line-height: 1.2; + } + + h4 { + font-size: var(--text-2xl); + font-weight: var(--weight-regular); + line-height: 1.2; + } + + a { transition: opacity 0.2s ease; } + a:hover { opacity: 0.7; } +} + +/* ============================================ + Components Layer + ============================================ */ +@layer components { + + /* --- Navbar --- */ + .navbar { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: var(--z-navbar); + display: flex; + align-items: center; + justify-content: center; + height: var(--navbar-height); + padding: 0 var(--page-padding); + padding-top: 24px; + } + + .navbar__inner { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + max-width: var(--page-max-width); + } + + .navbar__logo { + display: flex; + align-items: center; + } + + .navbar__logo svg { + height: 26px; + width: 107px; + } + + .navbar__links { + display: flex; + align-items: center; + gap: var(--space-8); + } + + .navbar__link { + font-size: var(--text-base); + font-weight: var(--weight-regular); + color: var(--color-ink); + } + + .navbar__actions { + display: flex; + align-items: center; + gap: var(--space-4); + } + + .navbar__github { + display: inline-flex; + align-items: center; + gap: var(--space-2); + font-size: var(--text-sm); + color: var(--color-ink); + padding: 4px 10px; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-surface); + line-height: 1; + } + + .navbar__github svg { + width: 16px; + height: 16px; + flex-shrink: 0; + } + + .navbar__github-count { + border-left: 1px solid var(--color-border); + padding-left: var(--space-2); + margin-left: var(--space-1); + } + + .navbar__discord { + display: inline-flex; + align-items: center; + color: var(--color-ink); + } + + .navbar__discord svg { + width: 24px; + height: 24px; + } + + .navbar__cta { + display: inline-flex; + align-items: center; + padding: 8px 20px; + background: var(--color-navy); + color: var(--color-surface); + border-radius: var(--radius-md); + font-size: var(--text-xs); + font-weight: var(--weight-regular); + } + + .navbar__cta:hover { + opacity: 1; + background: oklch(25% 0.04 280); + } + + /* --- Hero --- */ + .hero { + padding: 0 var(--page-padding) 80px; + display: flex; + justify-content: center; + align-items: center; + padding-top: 152px; + } + + .hero__inner { + display: flex; + align-items: center; + width: 100%; + max-width: calc(var(--page-max-width) - 2 * var(--page-padding)); + gap: 20px; + } + + .hero__content { + flex: 1; + display: flex; + flex-direction: column; + gap: 24px; + } + + .hero__title { + /* inherits h1 styles */ + } + + .hero__description { + display: flex; + flex-direction: column; + gap: var(--space-2); + font-weight: var(--weight-regular); + } + + .hero__actions { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--space-2); + } + + .hero__cta { + display: inline-flex; + align-items: center; + padding: 8px 20px; + background: var(--color-navy); + color: var(--color-surface); + border-radius: var(--radius-md); + font-size: var(--text-lg); + font-weight: var(--weight-regular); + } + + .hero__cta:hover { + opacity: 1; + background: oklch(25% 0.04 280); + } + + .hero__brew { + font-family: var(--font-mono); + font-size: 20px; + font-weight: var(--weight-medium); + color: var(--color-ink); + } + + .hero__visual { + flex: 0 0 auto; + width: 420px; + height: 471px; + background: var(--color-border); + border-radius: var(--radius-lg); + overflow: hidden; + } + + /* --- Logo Ticker --- */ + .logo-ticker { + padding: 0 var(--page-padding); + height: 120px; + display: flex; + align-items: center; + justify-content: center; + gap: 64px; + overflow: hidden; + } + + .logo-ticker__item { + width: 120px; + height: 40px; + background: oklch(90% 0.01 280 / 0.3); + border-radius: var(--radius-sm); + } + + /* --- Features --- */ + .features { + padding: 140px var(--page-padding) 0; + } + + .features__list { + display: flex; + flex-direction: column; + gap: 100px; + max-width: calc(var(--page-max-width) - 2 * var(--page-padding)); + margin: 0 auto; + } + + .feature-card { + display: flex; + align-items: stretch; + gap: 80px; + background: var(--color-surface); + border-radius: var(--radius-lg); + padding: 48px 80px; + min-height: 450px; + } + + .feature-card__content { + flex: 0 0 auto; + width: 368px; + display: flex; + flex-direction: column; + justify-content: center; + gap: 28px; + } + + .feature-card__text { + display: flex; + flex-direction: column; + gap: var(--space-4); + } + + .feature-card__text p { + font-size: var(--text-base); + font-weight: var(--weight-regular); + line-height: 1.5; + } + + .feature-card__link { + display: inline-flex; + align-items: center; + gap: var(--space-2); + font-size: var(--text-xs); + font-weight: var(--weight-regular); + padding: 8px 20px; + background: transparent; + color: var(--color-ink); + border: 1px solid var(--color-ink); + border-radius: var(--radius-md); + align-self: flex-start; + margin-top: var(--space-4); + } + + .feature-card__link:hover { + opacity: 1; + background: oklch(0% 0 0 / 0.05); + } + + .feature-card__visual { + flex: 1 0 0; + border-radius: var(--radius-lg); + min-height: 350px; + } + + .feature-card__visual--teal { background: var(--color-teal); } + .feature-card__visual--orange { background: var(--color-orange); } + .feature-card__visual--yellow { background: var(--color-yellow); } + + /* --- Community --- */ + .community { + padding: 160px var(--page-padding) 0; + text-align: center; + } + + .community__inner { + max-width: calc(var(--page-max-width) - 2 * var(--page-padding)); + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 64px; + } + + .community__label { + display: inline-flex; + align-items: center; + gap: var(--space-2); + font-size: var(--text-base); + font-weight: var(--weight-regular); + } + + .community__label::before { + content: ""; + width: 10px; + height: 10px; + border-radius: var(--radius-full); + background: var(--color-teal); + } + + .community__header { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + } + + .community__title { + /* no extra margin, handled by header gap */ + } + + .community__subtitle { + font-size: var(--text-lg); + } + + .community__cards { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; + } + + .community__card { + background: var(--color-surface); + border-radius: var(--radius-lg); + padding: 40px; + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + gap: 24px; + transition: transform 0.2s ease, box-shadow 0.2s ease; + } + + .community__card:hover { + opacity: 1; + transform: translateY(-2px); + box-shadow: var(--shadow-md); + } + + .community__card-icon { + width: 64px; + height: 64px; + display: flex; + align-items: center; + justify-content: center; + color: #D0E0F9; + } + + .community__card-icon svg { + width: 64px; + height: 64px; + flex-shrink: 0; + } + + .community__card-title { + font-size: var(--text-2xl); + font-weight: var(--weight-regular); + } + + .community__card-desc { + font-size: var(--text-base); + color: var(--color-ink); + line-height: 1.5; + } + + /* --- Newsletter --- */ + .newsletter { + max-width: var(--page-max-width); + margin: 0 auto; + padding: 64px var(--page-padding); + display: flex; + flex-direction: column; + align-items: center; + gap: 24px; + } + + .newsletter__title { + text-align: center; + } + + .newsletter__form { + display: flex; + align-items: center; + gap: 8px; + } + + .newsletter__input-wrap { + width: 371px; + height: 48px; + background: var(--color-surface); + border-radius: 10px; + overflow: hidden; + } + + .newsletter__input { + width: 100%; + height: 100%; + padding: 0 16px; + border: none; + font-size: var(--text-lg); + background: transparent; + cursor: text; + } + + .newsletter__input::placeholder { + color: var(--color-ink-muted); + } + + .newsletter__input:focus { + outline: none; + } + + .newsletter__submit { + padding: 8px 20px; + height: 48px; + background: var(--color-navy); + color: var(--color-surface); + border-radius: var(--radius-md); + font-size: var(--text-xs); + font-weight: var(--weight-regular); + white-space: nowrap; + flex-shrink: 0; + } + + .newsletter__submit:hover { + background: oklch(25% 0.04 280); + } + + /* --- Footer --- */ + .footer { + background: rgb(26, 24, 51); + color: var(--color-surface); + padding: 100px 100px 40px; + } + + .footer__inner { + max-width: var(--page-max-width); + margin: 0 auto; + } + + .footer__top { + display: flex; + justify-content: space-between; + gap: var(--space-16); + margin-bottom: var(--space-16); + } + + .footer__logo { + flex-shrink: 0; + } + + .footer__logo svg { + height: 56px; + width: 169px; + } + + .footer__columns { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: var(--space-12); + flex: 1; + } + + .footer__column-title { + font-size: 20px; + font-weight: var(--weight-regular); + color: var(--color-surface); + margin-bottom: 22px; + } + + .footer__column-links { + display: flex; + flex-direction: column; + gap: 20px; + } + + .footer__column-link { + font-size: var(--text-base); + font-weight: var(--weight-regular); + color: var(--color-surface); + } + + .footer__column-link:hover { + opacity: 1; + color: var(--color-surface); + } + + .footer__divider { + height: 1px; + background: oklch(100% 0 0 / 0.1); + margin-bottom: var(--space-8); + } + + .footer__bottom { + display: flex; + justify-content: space-between; + align-items: center; + } + + .footer__bottom-left { + display: flex; + align-items: center; + gap: var(--space-6); + } + + .footer__copyright { + font-size: var(--text-base); + color: var(--color-surface); + } + + .footer__legal-link { + font-size: var(--text-base); + color: oklch(100% 0 0 / 0.75); + } + + .footer__legal-link:hover { + opacity: 1; + color: oklch(100% 0 0 / 0.8); + } + + .footer__social { + display: flex; + align-items: center; + gap: var(--space-4); + } + + .footer__social-link { + color: var(--color-surface); + display: inline-flex; + align-items: center; + } + + .footer__social-link:hover { + opacity: 1; + color: var(--color-surface); + } + + .footer__social-link svg { + width: 20px; + height: 20px; + } + +} /* end components layer */ + +/* ============================================ + Utilities Layer + ============================================ */ +@layer utilities { + .visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } +} diff --git a/.agents/skills/dagger-like-website/assets/styles/reset.css b/.agents/skills/dagger-like-website/assets/styles/reset.css new file mode 100644 index 0000000..e9bd994 --- /dev/null +++ b/.agents/skills/dagger-like-website/assets/styles/reset.css @@ -0,0 +1,64 @@ +/* CSS Reset */ +@layer reset { + *, + *::before, + *::after { + box-sizing: border-box; + margin: 0; + padding: 0; + } + + html { + -webkit-text-size-adjust: 100%; + text-size-adjust: 100%; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + + body { + min-height: 100dvh; + line-height: 1.5; + } + + img, + svg { + display: block; + max-width: 100%; + height: auto; + } + + a { + color: inherit; + text-decoration: none; + } + + button { + font: inherit; + color: inherit; + background: none; + border: none; + cursor: pointer; + } + + input { + font: inherit; + color: inherit; + background: none; + border: none; + } + + ul, + ol { + list-style: none; + } + + h1, + h2, + h3, + h4, + h5, + h6 { + font-size: inherit; + font-weight: inherit; + } +} diff --git a/.agents/skills/dagger-like-website/assets/styles/utilities.css b/.agents/skills/dagger-like-website/assets/styles/utilities.css new file mode 100644 index 0000000..336e284 --- /dev/null +++ b/.agents/skills/dagger-like-website/assets/styles/utilities.css @@ -0,0 +1,22 @@ +/* Utility classes */ +@layer utilities { + .visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } + + .text-center { + text-align: center; + } + + .text-muted { + color: var(--color-ink-muted); + } +} diff --git a/.agents/skills/dagger-like-website/assets/styles/variables.css b/.agents/skills/dagger-like-website/assets/styles/variables.css new file mode 100644 index 0000000..f120b92 --- /dev/null +++ b/.agents/skills/dagger-like-website/assets/styles/variables.css @@ -0,0 +1,87 @@ +/* Design Tokens - Dagger.io Clone */ +/* Two-tier OKLCH color system: raw values -> semantic colors */ + +:root { + /* Tier 1: Raw OKLCH values */ + --lch-cream: 96.5% 0.015 80; + --lch-navy: 18% 0.04 280; + --lch-navy-deep: 16% 0.04 280; + --lch-white: 100% 0 0; + --lch-teal: 70% 0.1 190; + --lch-orange: 65% 0.18 50; + --lch-yellow: 82% 0.17 90; + --lch-gray-light: 92% 0.01 280; + --lch-ink: 15% 0.04 280; + --lch-ink-muted: 40% 0.02 280; + + /* Tier 2: Semantic colors */ + --color-canvas: oklch(var(--lch-cream)); + --color-surface: oklch(var(--lch-white)); + --color-ink: oklch(var(--lch-ink)); + --color-ink-muted: oklch(var(--lch-ink-muted)); + --color-navy: oklch(var(--lch-navy)); + --color-navy-deep: oklch(var(--lch-navy-deep)); + --color-teal: oklch(var(--lch-teal)); + --color-orange: oklch(var(--lch-orange)); + --color-yellow: oklch(var(--lch-yellow)); + --color-border: oklch(var(--lch-gray-light)); + + /* Spacing scale */ + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-3: 0.75rem; + --space-4: 1rem; + --space-5: 1.25rem; + --space-6: 1.5rem; + --space-8: 2rem; + --space-10: 2.5rem; + --space-12: 3rem; + --space-16: 4rem; + --space-20: 5rem; + --space-24: 6rem; + --space-32: 8rem; + + /* Typography */ + --font-sans: "General Sans", system-ui, -apple-system, sans-serif; + --font-mono: "Source Code Pro", ui-monospace, monospace; + + --text-xs: 0.75rem; + --text-sm: 0.875rem; + --text-base: 1rem; + --text-lg: 1.125rem; + --text-xl: 1.25rem; + --text-2xl: 1.5rem; + --text-3xl: 2.5rem; + --text-4xl: 3.5rem; + + --weight-regular: 400; + --weight-medium: 500; + --weight-semibold: 600; + --weight-bold: 700; + + --leading-tight: 1.1; + --leading-snug: 1.3; + --leading-normal: 1.4; + --leading-relaxed: 1.6; + + /* Borders & Radius */ + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 16px; + --radius-xl: 24px; + --radius-full: 9999px; + + /* Shadows */ + --shadow-sm: 0 1px 2px oklch(0% 0 0 / 0.05); + --shadow-md: 0 4px 12px oklch(0% 0 0 / 0.08); + + /* Z-index */ + --z-base: 1; + --z-navbar: 10; + --z-modal: 100; + + /* Layout */ + --page-max-width: 1200px; + --page-padding: 60px; + --navbar-height: 93px; +} diff --git a/.agents/skills/dagger-like-website/references/components.md b/.agents/skills/dagger-like-website/references/components.md new file mode 100644 index 0000000..f529e7d --- /dev/null +++ b/.agents/skills/dagger-like-website/references/components.md @@ -0,0 +1,211 @@ +# Component Patterns + +HTML structure and key CSS for each landing page section. + +## Navbar + +```html + +``` + +Key CSS: +- Fixed top, z-index `--z-navbar`, transparent background +- Inner: flex, space-between, `max-width: var(--page-max-width)` +- CTA: navy background, white text, `--radius-md`, `--text-xs` +- GitHub badge: border, white background, star count with left border separator + +## Hero + +```html +
+
+
+

Tagline here

+
+

First line of description.

+

Second line of description.

+
+
+ Get Started +

install command here

+
+
+
+
+
+``` + +Key CSS: +- `padding-top: 152px` (accounts for fixed navbar) +- Inner: flex, center aligned, gap 20px +- Title: inherits `h1` (3.5rem, weight 400, leading 1.1) +- CTA: navy background, `--text-lg`, `--radius-md` +- Brew: monospace font, 20px, medium weight +- Visual: 420x471px, gray background, `--radius-lg`, placeholder for illustration + +## Logo Ticker + +```html +
+
+ +
+``` + +Key CSS: +- Flex, center, gap 64px, height 120px, overflow hidden +- Items: 120x40px, muted gray background, `--radius-sm` + +## Feature Cards + +```html +
+
+
+
+

Feature Name

+
+

Description paragraph 1.

+

Description paragraph 2.

+
+ Read Docs +
+
+
+ +
+
+``` + +Key CSS: +- Section: `padding-top: 140px` +- List: flex column, gap 100px, max-width constrained +- Card: flex, white background, `--radius-lg`, `padding: 48px 80px`, min-height 450px +- Content: width 368px, flex column, gap 28px +- Text paragraphs: `--text-base`, regular weight +- Link: inline-flex, `--text-xs`, border outline, `--radius-md` +- Visual: flex 1, `--radius-lg`, min-height 350px +- Color modifiers: `--teal`, `--orange`, `--yellow` + +## Community + +```html +
+
+
+ Community +

Join the community

+

Description text.

+
+ +
+
+``` + +Key CSS: +- `padding-top: 160px`, text-align center +- Label: `::before` pseudo-element with colored dot (10px circle, teal) +- Cards: 3-column grid, gap 20px +- Card: white background, `--radius-lg`, padding 40px, flex column center +- Card hover: translateY(-2px), `--shadow-md`, opacity stays 1 +- Icon: 64px, muted blue-gray color (`#D0E0F9`) + +## Newsletter + +```html + +``` + +Key CSS: +- Max-width constrained, flex column center, gap 24px +- Input wrap: 371px wide, 48px tall, white background, radius 10px +- Submit: navy background, white text, `--text-xs`, 48px tall + +## Footer + +```html +
+ +
+``` + +Key CSS: +- Background: `rgb(26, 24, 51)`, white text +- Padding: 100px horizontal, 100px top, 40px bottom +- Top: flex space-between, logo left (169x56px), 4-column link grid right +- Column titles: 20px, gap 22px below +- Column links: flex column, gap 20px, `--text-base` +- Divider: 1px white at 10% opacity +- Bottom: flex space-between, copyright + legal left, social icons right +- Social icons: 20px SVGs, white + +## Section Header Pattern (for Community, Features, etc.) + +```html +
+ +

Section Title

+

Description text.

+
+``` + +- Dot: 0.5rem circle, accent color (teal or orange) +- Title: clamp(2rem, 4vw, 3rem), weight 400 +- Description: `--text-lg`, muted color, max-width 40rem diff --git a/.agents/skills/dagger-like-website/references/design-tokens.md b/.agents/skills/dagger-like-website/references/design-tokens.md new file mode 100644 index 0000000..3eddadb --- /dev/null +++ b/.agents/skills/dagger-like-website/references/design-tokens.md @@ -0,0 +1,119 @@ +# Design Tokens + +Complete token values for the dagger-like design system. + +## Two-Tier Color System + +Tier 1 stores raw OKLCH lightness/chroma/hue. Tier 2 maps to semantic names. + +```css +:root { + /* Tier 1: Raw OKLCH values */ + --lch-cream: 96.5% 0.015 80; + --lch-navy: 18% 0.04 280; + --lch-navy-deep: 16% 0.04 280; + --lch-white: 100% 0 0; + --lch-teal: 70% 0.1 190; + --lch-orange: 65% 0.18 50; + --lch-yellow: 82% 0.17 90; + --lch-gray-light: 92% 0.01 280; + --lch-ink: 15% 0.04 280; + --lch-ink-muted: 40% 0.02 280; + + /* Tier 2: Semantic colors */ + --color-canvas: oklch(var(--lch-cream)); + --color-surface: oklch(var(--lch-white)); + --color-ink: oklch(var(--lch-ink)); + --color-ink-muted: oklch(var(--lch-ink-muted)); + --color-navy: oklch(var(--lch-navy)); + --color-navy-deep: oklch(var(--lch-navy-deep)); + --color-teal: oklch(var(--lch-teal)); + --color-orange: oklch(var(--lch-orange)); + --color-yellow: oklch(var(--lch-yellow)); + --color-border: oklch(var(--lch-gray-light)); +} +``` + +## Spacing + +```css +--space-1: 0.25rem; /* 4px */ +--space-2: 0.5rem; /* 8px */ +--space-3: 0.75rem; /* 12px */ +--space-4: 1rem; /* 16px */ +--space-5: 1.25rem; /* 20px */ +--space-6: 1.5rem; /* 24px */ +--space-8: 2rem; /* 32px */ +--space-10: 2.5rem; /* 40px */ +--space-12: 3rem; /* 48px */ +--space-16: 4rem; /* 64px */ +--space-20: 5rem; /* 80px */ +--space-24: 6rem; /* 96px */ +--space-32: 8rem; /* 128px */ +``` + +## Typography + +```css +/* Fonts */ +--font-sans: "General Sans", system-ui, -apple-system, sans-serif; +--font-mono: "Source Code Pro", ui-monospace, monospace; + +/* Scale */ +--text-xs: 0.75rem; /* 12px */ +--text-sm: 0.875rem; /* 14px */ +--text-base: 1rem; /* 16px */ +--text-lg: 1.125rem; /* 18px */ +--text-xl: 1.25rem; /* 20px */ +--text-2xl: 1.5rem; /* 24px */ +--text-3xl: 2.5rem; /* 40px */ +--text-4xl: 3.5rem; /* 56px */ + +/* Weights */ +--weight-regular: 400; +--weight-medium: 500; +--weight-semibold: 600; + +/* Line height */ +--leading-tight: 1.1; +--leading-snug: 1.3; +--leading-normal: 1.4; +--leading-relaxed: 1.6; +``` + +## Layout + +```css +--page-max-width: 1200px; +--page-padding: 60px; +--navbar-height: 69px; /* can vary 69-93px */ +``` + +## Borders and Radius + +```css +--radius-sm: 4px; +--radius-md: 8px; +--radius-lg: 16px; +--radius-xl: 24px; +--radius-full: 9999px; +``` + +## Shadows + +```css +--shadow-sm: 0 1px 2px oklch(0% 0 0 / 0.05); +--shadow-md: 0 4px 12px oklch(0% 0 0 / 0.08); +``` + +## Z-Index + +```css +--z-base: 1; +--z-navbar: 10; +--z-modal: 100; +``` + +## Footer Dark Background + +The footer uses a specific dark navy: `rgb(26, 24, 51)` or approximately `oklch(16% 0.04 280)`. diff --git a/.agents/skills/dagger-like-website/references/docs-layout.md b/.agents/skills/dagger-like-website/references/docs-layout.md new file mode 100644 index 0000000..988b723 --- /dev/null +++ b/.agents/skills/dagger-like-website/references/docs-layout.md @@ -0,0 +1,275 @@ +# Documentation Layout Patterns + +Patterns for building documentation pages in the dagger-like style. + +## Page Structure + +```html +
+ +
+
+ +
+
+
+``` + +## Sidebar CSS + +```css +@layer components { + .docs { + display: grid; + grid-template-columns: 16rem 1fr; + min-height: calc(100dvh - var(--navbar-height)); + } + + .docs__sidebar { + position: sticky; + top: var(--navbar-height); + height: calc(100dvh - var(--navbar-height)); + overflow-y: auto; + padding: var(--space-8) var(--space-6); + border-right: 1px solid var(--color-border); + } + + .docs-nav__section { + margin-bottom: var(--space-6); + } + + .docs-nav__title { + font-size: var(--text-xs); + font-weight: var(--weight-semibold); + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-ink-muted); + margin-bottom: var(--space-2); + } + + .docs-nav__link { + display: block; + padding: var(--space-1) var(--space-3); + font-size: var(--text-sm); + color: var(--color-ink-muted); + border-radius: var(--radius-sm); + transition: color 0.15s ease, background 0.15s ease; + } + + .docs-nav__link:hover { + color: var(--color-ink); + opacity: 1; + } + + .docs-nav__link--active { + color: var(--color-ink); + background: var(--color-border); + font-weight: var(--weight-medium); + } +} +``` + +## Content Area CSS + +```css +@layer components { + .docs__content { + padding: var(--space-8) var(--space-12); + max-width: 48rem; + } +} +``` + +## Prose Styling + +For markdown-rendered content inside `.prose`: + +```css +@layer components { + .prose { + font-size: var(--text-base); + line-height: var(--leading-relaxed); + color: var(--color-ink); + } + + .prose h2 { + font-size: var(--text-2xl); + font-weight: var(--weight-medium); + margin-top: var(--space-12); + margin-bottom: var(--space-4); + padding-bottom: var(--space-2); + border-bottom: 1px solid var(--color-border); + } + + .prose h3 { + font-size: var(--text-xl); + font-weight: var(--weight-medium); + margin-top: var(--space-8); + margin-bottom: var(--space-3); + } + + .prose p { + margin-bottom: var(--space-4); + } + + .prose a { + color: var(--color-teal); + text-decoration: underline; + text-underline-offset: 2px; + } + + .prose code { + font-family: var(--font-mono); + font-size: 0.875em; + background: var(--color-border); + padding: 2px 6px; + border-radius: var(--radius-sm); + } + + .prose pre { + background: oklch(var(--lch-navy-deep)); + color: oklch(90% 0.01 80); + padding: var(--space-4) var(--space-6); + border-radius: var(--radius-md); + overflow-x: auto; + margin-bottom: var(--space-6); + } + + .prose pre code { + background: none; + padding: 0; + font-size: var(--text-sm); + } + + .prose ul, .prose ol { + padding-left: var(--space-6); + margin-bottom: var(--space-4); + } + + .prose li { + margin-bottom: var(--space-2); + } + + .prose table { + width: 100%; + border-collapse: collapse; + margin-bottom: var(--space-6); + } + + .prose th { + text-align: left; + font-weight: var(--weight-medium); + padding: var(--space-2) var(--space-3); + border-bottom: 2px solid var(--color-border); + } + + .prose td { + padding: var(--space-2) var(--space-3); + border-bottom: 1px solid var(--color-border); + } + + .prose img { + max-width: 100%; + border-radius: var(--radius-md); + } +} +``` + +## Callout Boxes + +```html +
+

Informational note here.

+
+
+

Warning note here.

+
+``` + +```css +@layer components { + .callout { + padding: var(--space-4) var(--space-6); + border-left: 3px solid var(--color-teal); + background: oklch(var(--lch-teal) / 0.08); + border-radius: 0 var(--radius-md) var(--radius-md) 0; + margin-bottom: var(--space-6); + } + + .callout--warning { + border-left-color: var(--color-orange); + background: oklch(var(--lch-orange) / 0.08); + } +} +``` + +## Responsive (Docs) + +```css +/* 1024px: sidebar becomes collapsible overlay */ +@media (max-width: 1024px) { + .docs { + grid-template-columns: 1fr; + } + .docs__sidebar { + position: fixed; + left: -16rem; + width: 16rem; + z-index: var(--z-navbar); + background: var(--color-canvas); + transition: left 0.2s ease; + } + .docs__sidebar--open { + left: 0; + } +} + +/* 768px: tighter padding */ +@media (max-width: 768px) { + .docs__content { + padding: var(--space-4) var(--space-6); + } +} +``` + +## Hugo Integration for Docs + +### Content structure +``` +content/docs/ + _index.md # Docs landing + quickstart.md + architecture.md + configuration.md +``` + +### Layout template (`layouts/docs/single.html`) +```go-html-template +{{ define "main" }} + {{ partial "dagger/navbar.html" . }} +
+ +
+
+ {{ .Content }} +
+
+
+ {{ partial "dagger/footer.html" . }} +{{ end }} +``` diff --git a/.agents/skills/diagnose/SKILL.md b/.agents/skills/diagnose/SKILL.md new file mode 100644 index 0000000..ed55bda --- /dev/null +++ b/.agents/skills/diagnose/SKILL.md @@ -0,0 +1,117 @@ +--- +name: diagnose +description: Disciplined diagnosis loop for hard bugs and performance regressions. Reproduce → minimise → hypothesise → instrument → fix → regression-test. Use when user says "diagnose this" / "debug this", reports a bug, says something is broken/throwing/failing, or describes a performance regression. +--- + +# Diagnose + +A discipline for hard bugs. Skip phases only when explicitly justified. + +When exploring the codebase, use the project's domain glossary to get a clear mental model of the relevant modules, and check ADRs in the area you're touching. + +## Phase 1 — Build a feedback loop + +**This is the skill.** Everything else is mechanical. If you have a fast, deterministic, agent-runnable pass/fail signal for the bug, you will find the cause — bisection, hypothesis-testing, and instrumentation all just consume that signal. If you don't have one, no amount of staring at code will save you. + +Spend disproportionate effort here. **Be aggressive. Be creative. Refuse to give up.** + +### Ways to construct one — try them in roughly this order + +1. **Failing test** at whatever seam reaches the bug — unit, integration, e2e. +2. **Curl / HTTP script** against a running dev server. +3. **CLI invocation** with a fixture input, diffing stdout against a known-good snapshot. +4. **Headless browser script** (Playwright / Puppeteer) — drives the UI, asserts on DOM/console/network. +5. **Replay a captured trace.** Save a real network request / payload / event log to disk; replay it through the code path in isolation. +6. **Throwaway harness.** Spin up a minimal subset of the system (one service, mocked deps) that exercises the bug code path with a single function call. +7. **Property / fuzz loop.** If the bug is "sometimes wrong output", run 1000 random inputs and look for the failure mode. +8. **Bisection harness.** If the bug appeared between two known states (commit, dataset, version), automate "boot at state X, check, repeat" so you can `git bisect run` it. +9. **Differential loop.** Run the same input through old-version vs new-version (or two configs) and diff outputs. +10. **HITL bash script.** Last resort. If a human must click, drive _them_ with `scripts/hitl-loop.template.sh` so the loop is still structured. Captured output feeds back to you. + +Build the right feedback loop, and the bug is 90% fixed. + +### Iterate on the loop itself + +Treat the loop as a product. Once you have _a_ loop, ask: + +- Can I make it faster? (Cache setup, skip unrelated init, narrow the test scope.) +- Can I make the signal sharper? (Assert on the specific symptom, not "didn't crash".) +- Can I make it more deterministic? (Pin time, seed RNG, isolate filesystem, freeze network.) + +A 30-second flaky loop is barely better than no loop. A 2-second deterministic loop is a debugging superpower. + +### Non-deterministic bugs + +The goal is not a clean repro but a **higher reproduction rate**. Loop the trigger 100×, parallelise, add stress, narrow timing windows, inject sleeps. A 50%-flake bug is debuggable; 1% is not — keep raising the rate until it's debuggable. + +### When you genuinely cannot build a loop + +Stop and say so explicitly. List what you tried. Ask the user for: (a) access to whatever environment reproduces it, (b) a captured artifact (HAR file, log dump, core dump, screen recording with timestamps), or (c) permission to add temporary production instrumentation. Do **not** proceed to hypothesise without a loop. + +Do not proceed to Phase 2 until you have a loop you believe in. + +## Phase 2 — Reproduce + +Run the loop. Watch the bug appear. + +Confirm: + +- [ ] The loop produces the failure mode the **user** described — not a different failure that happens to be nearby. Wrong bug = wrong fix. +- [ ] The failure is reproducible across multiple runs (or, for non-deterministic bugs, reproducible at a high enough rate to debug against). +- [ ] You have captured the exact symptom (error message, wrong output, slow timing) so later phases can verify the fix actually addresses it. + +Do not proceed until you reproduce the bug. + +## Phase 3 — Hypothesise + +Generate **3–5 ranked hypotheses** before testing any of them. Single-hypothesis generation anchors on the first plausible idea. + +Each hypothesis must be **falsifiable**: state the prediction it makes. + +> Format: "If is the cause, then will make the bug disappear / will make it worse." + +If you cannot state the prediction, the hypothesis is a vibe — discard or sharpen it. + +**Show the ranked list to the user before testing.** They often have domain knowledge that re-ranks instantly ("we just deployed a change to #3"), or know hypotheses they've already ruled out. Cheap checkpoint, big time saver. Don't block on it — proceed with your ranking if the user is AFK. + +## Phase 4 — Instrument + +Each probe must map to a specific prediction from Phase 3. **Change one variable at a time.** + +Tool preference: + +1. **Debugger / REPL inspection** if the env supports it. One breakpoint beats ten logs. +2. **Targeted logs** at the boundaries that distinguish hypotheses. +3. Never "log everything and grep". + +**Tag every debug log** with a unique prefix, e.g. `[DEBUG-a4f2]`. Cleanup at the end becomes a single grep. Untagged logs survive; tagged logs die. + +**Perf branch.** For performance regressions, logs are usually wrong. Instead: establish a baseline measurement (timing harness, `performance.now()`, profiler, query plan), then bisect. Measure first, fix second. + +## Phase 5 — Fix + regression test + +Write the regression test **before the fix** — but only if there is a **correct seam** for it. + +A correct seam is one where the test exercises the **real bug pattern** as it occurs at the call site. If the only available seam is too shallow (single-caller test when the bug needs multiple callers, unit test that can't replicate the chain that triggered the bug), a regression test there gives false confidence. + +**If no correct seam exists, that itself is the finding.** Note it. The codebase architecture is preventing the bug from being locked down. Flag this for the next phase. + +If a correct seam exists: + +1. Turn the minimised repro into a failing test at that seam. +2. Watch it fail. +3. Apply the fix. +4. Watch it pass. +5. Re-run the Phase 1 feedback loop against the original (un-minimised) scenario. + +## Phase 6 — Cleanup + post-mortem + +Required before declaring done: + +- [ ] Original repro no longer reproduces (re-run the Phase 1 loop) +- [ ] Regression test passes (or absence of seam is documented) +- [ ] All `[DEBUG-...]` instrumentation removed (`grep` the prefix) +- [ ] Throwaway prototypes deleted (or moved to a clearly-marked debug location) +- [ ] The hypothesis that turned out correct is stated in the commit / PR message — so the next debugger learns + +**Then ask: what would have prevented this bug?** If the answer involves architectural change (no good test seam, tangled callers, hidden coupling) hand off to the `/improve-codebase-architecture` skill with the specifics. Make the recommendation **after** the fix is in, not before — you have more information now than when you started. diff --git a/.agents/skills/diagnose/scripts/hitl-loop.template.sh b/.agents/skills/diagnose/scripts/hitl-loop.template.sh new file mode 100644 index 0000000..40afc46 --- /dev/null +++ b/.agents/skills/diagnose/scripts/hitl-loop.template.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Human-in-the-loop reproduction loop. +# Copy this file, edit the steps below, and run it. +# The agent runs the script; the user follows prompts in their terminal. +# +# Usage: +# bash hitl-loop.template.sh +# +# Two helpers: +# step "" → show instruction, wait for Enter +# capture VAR "" → show question, read response into VAR +# +# At the end, captured values are printed as KEY=VALUE for the agent to parse. + +set -euo pipefail + +step() { + printf '\n>>> %s\n' "$1" + read -r -p " [Enter when done] " _ +} + +capture() { + local var="$1" question="$2" answer + printf '\n>>> %s\n' "$question" + read -r -p " > " answer + printf -v "$var" '%s' "$answer" +} + +# --- edit below --------------------------------------------------------- + +step "Open the app at http://localhost:3000 and sign in." + +capture ERRORED "Click the 'Export' button. Did it throw an error? (y/n)" + +capture ERROR_MSG "Paste the error message (or 'none'):" + +# --- edit above --------------------------------------------------------- + +printf '\n--- Captured ---\n' +printf 'ERRORED=%s\n' "$ERRORED" +printf 'ERROR_MSG=%s\n' "$ERROR_MSG" diff --git a/.agents/skills/doc-coauthoring/SKILL.md b/.agents/skills/doc-coauthoring/SKILL.md new file mode 100644 index 0000000..4968488 --- /dev/null +++ b/.agents/skills/doc-coauthoring/SKILL.md @@ -0,0 +1,236 @@ +--- +name: doc-coauthoring +description: Guide users through a structured workflow for co-authoring documentation. Use when user wants to write documentation, proposals, technical specs, decision docs, or similar structured content. This workflow helps users efficiently transfer context, refine content through iteration, and verify the doc works for readers. Trigger when user mentions writing docs, creating proposals, drafting specs, or similar documentation tasks. +--- + +# Doc Co-Authoring Workflow + +This skill provides a structured workflow for guiding users through collaborative document creation. Act as an active guide, walking users through three stages: Context Gathering, Refinement & Structure, and Reader Testing. + +## When to Offer This Workflow + +**Trigger conditions:** +- User mentions writing documentation: "write a doc", "draft a proposal", "create a spec", "write up" +- User mentions specific doc types: "PRD", "design doc", "decision doc", "RFC" +- User seems to be starting a substantial writing task + +**Initial offer:** +Offer the user a structured workflow for co-authoring the document. Explain the three stages: + +1. **Context Gathering**: User provides all relevant context while Claude asks clarifying questions +2. **Refinement & Structure**: Iteratively build each section through brainstorming and editing +3. **Reader Testing**: Test the doc with a fresh Claude (no context) to catch blind spots before others read it + +Explain that this approach helps ensure the doc works well when others read it (including when they paste it into Claude). Ask if they want to try this workflow or prefer to work freeform. + +If user declines, work freeform. If user accepts, proceed to Stage 1. + +## Stage 1: Context Gathering + +**Goal:** Close the gap between what the user knows and what Claude knows, enabling smart guidance later. + +### Initial Questions + +Start by asking the user for meta-context about the document: + +1. What type of document is this? (e.g., technical spec, decision doc, proposal) +2. Who's the primary audience? +3. What's the desired impact when someone reads this? +4. Is there a template or specific format to follow? +5. Any other constraints or context to know? + +Inform them they can answer in shorthand or dump information however works best for them. + +**If user provides a template or mentions a doc type:** +- Ask if they have a template document to share +- If they provide a link to a shared document, use the appropriate integration to fetch it +- If they provide a file, read it + +**If user mentions editing an existing shared document:** +- Use the appropriate integration to read the current state +- Check for images without alt-text +- If images exist without alt-text, explain that when others use Claude to understand the doc, Claude won't be able to see them. Ask if they want alt-text generated. If so, request they paste each image into chat for descriptive alt-text generation. + +### Info Dumping + +Once initial questions are answered, encourage the user to dump all the context they have. Request information such as: +- Background on the project/problem +- Related team discussions or shared documents +- Why alternative solutions aren't being used +- Organizational context (team dynamics, past incidents, politics) +- Timeline pressures or constraints +- Technical architecture or dependencies +- Stakeholder concerns + +Advise them not to worry about organizing it - just get it all out. Offer multiple ways to provide context: +- Info dump stream-of-consciousness +- Point to team channels or threads to read +- Link to shared documents + +**If integrations are available** (e.g., Slack, Teams, Google Drive, SharePoint, or other MCP servers), mention that these can be used to pull in context directly. + +**If no integrations are detected and in Claude.ai or Claude app:** Suggest they can enable connectors in their Claude settings to allow pulling context from messaging apps and document storage directly. + +Inform them clarifying questions will be asked once they've done their initial dump. + +**During context gathering:** + +- If user mentions team channels or shared documents: + - If integrations available: Inform them the content will be read now, then use the appropriate integration + - If integrations not available: Explain lack of access. Suggest they enable connectors in Claude settings, or paste the relevant content directly. + +- If user mentions entities/projects that are unknown: + - Ask if connected tools should be searched to learn more + - Wait for user confirmation before searching + +- As user provides context, track what's being learned and what's still unclear + +**Asking clarifying questions:** + +When user signals they've done their initial dump (or after substantial context provided), ask clarifying questions to ensure understanding: + +Generate 5-10 numbered questions based on gaps in the context. + +Inform them they can use shorthand to answer (e.g., "1: yes, 2: see #channel, 3: no because backwards compat"), link to more docs, point to channels to read, or just keep info-dumping. Whatever's most efficient for them. + +**Exit condition:** +Sufficient context has been gathered when questions show understanding - when edge cases and trade-offs can be asked about without needing basics explained. + +**Transition:** +Ask if there's any more context they want to provide at this stage, or if it's time to move on to drafting the document. + +If user wants to add more, let them. When ready, proceed to Stage 2. + +## Stage 2: Refinement & Structure + +**Goal:** Build the document section by section through brainstorming, curation, and iterative refinement. + +**Instructions to user:** +Explain that the document will be built section by section. For each section: +1. Clarifying questions will be asked about what to include +2. 5-20 options will be brainstormed +3. User will indicate what to keep/remove/combine +4. The section will be drafted +5. It will be refined through surgical edits + +Start with whichever section has the most unknowns (usually the core decision/proposal), then work through the rest. + +**Section ordering:** + +If the document structure is clear: +Ask which section they'd like to start with. + +Suggest starting with whichever section has the most unknowns. For decision docs, that's usually the core proposal. For specs, it's typically the technical approach. Summary sections are best left for last. + +If user doesn't know what sections they need: +Based on the type of document and template, suggest 3-5 sections appropriate for the doc type. + +Ask if this structure works, or if they want to adjust it. + +**Once structure is agreed:** + +Create the initial document structure with placeholder text for all sections. + +**If access to artifacts is available:** +Use `create_file` to create an artifact. This gives both Claude and the user a scaffold to work from. + +**If no access to artifacts:** +Create a markdown file in the working directory. Name it appropriately (e.g., `decision-doc.md`, `technical-spec.md`). + +**For each section:** + +### Step 1: Clarifying Questions + +Announce work will begin on the [SECTION NAME] section. Ask 5-10 clarifying questions about what should be included. + +### Step 2: Brainstorming + +Brainstorm [5-20] things that might be included, depending on the section's complexity. Look for: +- Context shared that might have been forgotten +- Angles or considerations not yet mentioned + +### Step 3: Curation + +Ask which points should be kept, removed, or combined. Request brief justifications to help learn priorities for the next sections. + +### Step 4: Gap Check + +Based on what they've selected, ask if there's anything important missing for the section. + +### Step 5: Drafting + +Use `str_replace` to replace the placeholder text for this section with the actual drafted content. + +**Key instruction for user (include when drafting the first section):** +Instead of editing the doc directly, ask them to indicate what to change. This helps learning of their style for future sections. + +### Step 6: Iterative Refinement + +As user provides feedback: +- Use `str_replace` to make edits (never reprint the whole doc) +- If user edits doc directly and asks to read it: mentally note the changes they made and keep them in mind for future sections + +**Continue iterating** until user is satisfied with the section. + +### Quality Checking + +After 3 consecutive iterations with no substantial changes, ask if anything can be removed without losing important information. + +### Near Completion + +As approaching completion (80%+ of sections done), re-read the entire document and check for: +- Flow and consistency across sections +- Redundancy or contradictions +- Anything that feels like "slop" or generic filler +- Whether every sentence carries weight + +## Stage 3: Reader Testing + +**Goal:** Test the document with a fresh Claude (no context bleed) to verify it works for readers. + +### Testing Approach + +**If access to sub-agents is available (e.g., in Claude Code):** + +Perform the testing directly without user involvement: + +1. Predict 5-10 reader questions +2. Test each with a sub-agent that only has the document content +3. Run additional checks for ambiguity, false assumptions, contradictions +4. Report and fix any issues found + +**If no access to sub-agents (e.g., claude.ai web interface):** + +Guide the user through manual testing: + +1. Predict reader questions together +2. Instruct user to open a fresh Claude conversation +3. Have them paste the doc and ask the predicted questions +4. Iterate based on what Reader Claude got wrong + +### Exit Condition + +When Reader Claude consistently answers questions correctly and doesn't surface new gaps or ambiguities, the doc is ready. + +## Final Review + +When Reader Testing passes: + +1. Recommend they do a final read-through themselves +2. Suggest double-checking any facts, links, or technical details +3. Ask them to verify it achieves the impact they wanted + +Final tips: +- Consider linking this conversation in an appendix +- Use appendices to provide depth without bloating the main doc +- Update the doc as feedback is received from real readers + +## Tips for Effective Guidance + +**Tone:** Be direct and procedural. Explain rationale briefly when it affects user behavior. + +**Handling Deviations:** If user wants to skip a stage, let them. Always give user agency. + +**Context Management:** Don't let gaps accumulate - address them as they come up. + +**Quality over Speed:** Each iteration should make meaningful improvements. The goal is a document that actually works for readers. diff --git a/.agents/skills/edit-article/SKILL.md b/.agents/skills/edit-article/SKILL.md new file mode 100644 index 0000000..b319b7c --- /dev/null +++ b/.agents/skills/edit-article/SKILL.md @@ -0,0 +1,14 @@ +--- +name: edit-article +description: Edit and improve articles by restructuring sections, improving clarity, and tightening prose. Use when user wants to edit, revise, or improve an article draft. +--- + +1. First, divide the article into sections based on its headings. Think about the main points you want to make during those sections. + +Consider that information is a directed acyclic graph, and that pieces of information can depend on other pieces of information. Make sure that the order of the sections and their contents respects these dependencies. + +Confirm the sections with the user. + +2. For each section: + +2a. Rewrite the section to improve clarity, coherence, and flow. Use maximum 240 characters per paragraph. diff --git a/.agents/skills/find-skills/SKILL.md b/.agents/skills/find-skills/SKILL.md new file mode 100644 index 0000000..c797184 --- /dev/null +++ b/.agents/skills/find-skills/SKILL.md @@ -0,0 +1,133 @@ +--- +name: find-skills +description: Helps users discover and install agent skills when they ask questions like "how do I do X", "find a skill for X", "is there a skill that can...", or express interest in extending capabilities. This skill should be used when the user is looking for functionality that might exist as an installable skill. +--- + +# Find Skills + +This skill helps you discover and install skills from the open agent skills ecosystem. + +## When to Use This Skill + +Use this skill when the user: + +- Asks "how do I do X" where X might be a common task with an existing skill +- Says "find a skill for X" or "is there a skill for X" +- Asks "can you do X" where X is a specialized capability +- Expresses interest in extending agent capabilities +- Wants to search for tools, templates, or workflows +- Mentions they wish they had help with a specific domain (design, testing, deployment, etc.) + +## What is the Skills CLI? + +The Skills CLI (`npx skills`) is the package manager for the open agent skills ecosystem. Skills are modular packages that extend agent capabilities with specialized knowledge, workflows, and tools. + +**Key commands:** + +- `npx skills find [query]` - Search for skills interactively or by keyword +- `npx skills add ` - Install a skill from GitHub or other sources +- `npx skills check` - Check for skill updates +- `npx skills update` - Update all installed skills + +**Browse skills at:** https://skills.sh/ + +## How to Help Users Find Skills + +### Step 1: Understand What They Need + +When a user asks for help with something, identify: + +1. The domain (e.g., React, testing, design, deployment) +2. The specific task (e.g., writing tests, creating animations, reviewing PRs) +3. Whether this is a common enough task that a skill likely exists + +### Step 2: Search for Skills + +Run the find command with a relevant query: + +```bash +npx skills find [query] +``` + +For example: + +- User asks "how do I make my React app faster?" → `npx skills find react performance` +- User asks "can you help me with PR reviews?" → `npx skills find pr review` +- User asks "I need to create a changelog" → `npx skills find changelog` + +The command will return results like: + +``` +Install with npx skills add + +vercel-labs/agent-skills@vercel-react-best-practices +└ https://skills.sh/vercel-labs/agent-skills/vercel-react-best-practices +``` + +### Step 3: Present Options to the User + +When you find relevant skills, present them to the user with: + +1. The skill name and what it does +2. The install command they can run +3. A link to learn more at skills.sh + +Example response: + +``` +I found a skill that might help! The "vercel-react-best-practices" skill provides +React and Next.js performance optimization guidelines from Vercel Engineering. + +To install it: +npx skills add vercel-labs/agent-skills@vercel-react-best-practices + +Learn more: https://skills.sh/vercel-labs/agent-skills/vercel-react-best-practices +``` + +### Step 4: Offer to Install + +If the user wants to proceed, you can install the skill for them: + +```bash +npx skills add -g -y +``` + +The `-g` flag installs globally (user-level) and `-y` skips confirmation prompts. + +## Common Skill Categories + +When searching, consider these common categories: + +| Category | Example Queries | +| --------------- | ---------------------------------------- | +| Web Development | react, nextjs, typescript, css, tailwind | +| Testing | testing, jest, playwright, e2e | +| DevOps | deploy, docker, kubernetes, ci-cd | +| Documentation | docs, readme, changelog, api-docs | +| Code Quality | review, lint, refactor, best-practices | +| Design | ui, ux, design-system, accessibility | +| Productivity | workflow, automation, git | + +## Tips for Effective Searches + +1. **Use specific keywords**: "react testing" is better than just "testing" +2. **Try alternative terms**: If "deploy" doesn't work, try "deployment" or "ci-cd" +3. **Check popular sources**: Many skills come from `vercel-labs/agent-skills` or `ComposioHQ/awesome-claude-skills` + +## When No Skills Are Found + +If no relevant skills exist: + +1. Acknowledge that no existing skill was found +2. Offer to help with the task directly using your general capabilities +3. Suggest the user could create their own skill with `npx skills init` + +Example: + +``` +I searched for skills related to "xyz" but didn't find any matches. +I can still help you with this task directly! Would you like me to proceed? + +If this is something you do often, you could create your own skill: +npx skills init my-xyz-skill +``` diff --git a/.agents/skills/go/SKILL.md b/.agents/skills/go/SKILL.md new file mode 100644 index 0000000..3018fa9 --- /dev/null +++ b/.agents/skills/go/SKILL.md @@ -0,0 +1,98 @@ +--- +name: go-idiomatic +description: > + Write idiomatic, production-grade Go code following Google's Go Style Guide. + Use when writing, reviewing, or refactoring any Go (.go) code including + libraries, CLI tools, servers, and tests. Triggers: any Go code generation, + "write Go", "Go function", "Go package", "Go test", "refactor Go", + "review Go code", "fix Go style", "idiomatic Go", or producing .go files. + Do NOT use for non-Go languages. +--- + +# Idiomatic Go + +Write Go code that is clear, simple, concise, and maintainable — in that priority order. +Based on Google's Go Style Guide. + +## Core Workflow + +When writing Go code, apply these principles in order: + +1. **Clarity first** — purpose and rationale obvious to the reader +2. **Simplicity** — accomplish the goal the simplest way +3. **Concision** — high signal-to-noise ratio +4. **Maintainability** — easy to evolve +5. **Consistency** — match surrounding code and Go conventions + +## Quick Reference + +### Naming + +- Short, clear names; shorter in smaller scopes, descriptive in larger ones +- `MixedCaps` / `mixedCaps`, never `snake_case` +- Don't repeat package name: `yamlconfig.Parse()` not `yamlconfig.ParseYAMLConfig()` +- Don't repeat receiver: `c.WriteTo()` not `c.WriteConfigTo()` +- No `Get` prefix on getters: `c.JobName()` not `c.GetJobName()` +- Functions returning values → noun-like; functions doing things → verb-like +- Avoid `util`, `helper`, `common` package names +- Interfaces: single-method → method name + `er` suffix (`Reader`, `Stringer`) + +### Error Handling + +- Always handle errors explicitly; never ignore with `_` unless truly intentional +- Use structured errors (`errors.New`, custom types) over string matching +- Wrap with `fmt.Errorf("context: %w", err)` to preserve chain; `%v` at API boundaries +- Place `%w` at the end of format strings +- Add non-redundant context; don't duplicate info the underlying error already provides +- Don't wrap with "failed:" — the error itself conveys failure +- Use `errors.Is` / `errors.As` for checking; never `regexp` on `.Error()` + +### Types + +- Use `any` instead of `interface{}`; e.g. `map[string]any` not `map[string]interface{}` + +### Variable Declarations + +- `:=` for non-zero initialization: `i := 42` +- `var` for zero values ready for later use: `var coords Point` +- Composite literals for known initial values: `primes := []int{2, 3, 5}` +- `new(T)` or `&T{}` for pointer zero values +- Specify channel direction: `func sum(values <-chan int) int` + +### Function Design + +- Keep signatures short; if growing complex, use an option struct or functional options +- `context.Context` is always the first parameter, never in option structs +- Option struct: when most callers set multiple fields +- Variadic options `...Option`: when most callers need zero options + +### Testing + +- Table-driven tests with named fields for readability +- Use `t.Helper()` in test helpers +- Prefer `cmp.Diff` for comparisons over manual field checks +- `t.Fatal` only in setup/preconditions, `t.Error` for test assertions +- Never call `t.Fatal` from goroutines +- Scope setup to tests that need it; avoid package-level `init()` +- Test packages: append `test` to package name (`creditcardtest`) + +### Documentation + +- Every exported name gets a doc comment starting with the name +- Don't document the obvious (parameter types, context cancellation) +- Do document non-obvious behavior, concurrency safety of mutating ops, cleanup requirements, and error types returned +- Runnable `Example` functions over code-in-comments + +### Imports + +- Group: stdlib, then third-party, then internal (blank line between groups) +- Rename proto imports with `pb` / `grpc` suffix: `foopb "path/to/foo_go_proto"` + +## Detailed Guidance + +For pattern-specific examples and anti-patterns, consult these references: + +- **Naming & package design**: See [references/naming.md](references/naming.md) +- **Error handling patterns**: See [references/errors.md](references/errors.md) +- **Testing patterns**: See [references/testing.md](references/testing.md) +- **API design (options, concurrency, globals)**: See [references/api-design.md](references/api-design.md) diff --git a/.agents/skills/go/references/api-design.md b/.agents/skills/go/references/api-design.md new file mode 100644 index 0000000..7c7b321 --- /dev/null +++ b/.agents/skills/go/references/api-design.md @@ -0,0 +1,299 @@ +# API Design Patterns + +## Table of Contents +- Option structs +- Variadic functional options +- Global state avoidance +- Documentation conventions +- Concurrency documentation +- String concatenation +- Variable declarations and size hints + +## Option Structs + +Use when most callers set multiple fields. The struct should be the last parameter. + +```go +// BAD: too many parameters +func EnableReplication(ctx context.Context, config *replicator.Config, + primaryRegions, readonlyRegions []string, + replicateExisting, overwritePolicies bool, + replicationInterval time.Duration, copyWorkers int, + healthWatcher health.Watcher) { ... } + +// GOOD: option struct +type ReplicationOptions struct { + Config *replicator.Config + PrimaryRegions []string + ReadonlyRegions []string + ReplicateExisting bool + OverwritePolicies bool + ReplicationInterval time.Duration + CopyWorkers int + HealthWatcher health.Watcher +} + +func EnableReplication(ctx context.Context, opts ReplicationOptions) { ... } +``` + +Call site reads cleanly with field names: + +```go +storage.EnableReplication(ctx, storage.ReplicationOptions{ + Config: config, + PrimaryRegions: []string{"us-east1", "us-central2"}, +}) +``` + +Use when: all callers set 1+ options, many callers set many options, options shared between functions. + +## Variadic Functional Options + +Use when most callers need zero options and the function should take no space at the call site for defaults. + +```go +type replicationOptions struct { + readonlyCells []string + replicateExisting bool + replicationInterval time.Duration + copyWorkers int +} + +type ReplicationOption func(*replicationOptions) + +func ReadonlyCells(cells ...string) ReplicationOption { + return func(opts *replicationOptions) { + opts.readonlyCells = append(opts.readonlyCells, cells...) + } +} + +func ReplicateExisting(enabled bool) ReplicationOption { + return func(opts *replicationOptions) { + opts.replicateExisting = enabled + } +} + +// Provide defaults +var DefaultReplicationOptions = []ReplicationOption{ + OverwritePolicies(true), + ReplicationInterval(12 * time.Hour), + CopyWorkers(10), +} + +func EnableReplication(ctx context.Context, config *placer.Config, + primaryCells []string, opts ...ReplicationOption) { + var options replicationOptions + for _, opt := range DefaultReplicationOptions { + opt(&options) + } + for _, opt := range opts { + opt(&options) + } +} +``` + +Call sites scale from simple to complex: + +```go +// Simple — no options needed +storage.EnableReplication(ctx, config, []string{"po", "is", "ea"}) + +// Complex — configure as needed +storage.EnableReplication(ctx, config, []string{"po", "is", "ea"}, + storage.ReadonlyCells("ix", "gg"), + storage.ReplicationInterval(1*time.Hour), + storage.CopyWorkers(100), +) +``` + +Key rules: +- Options accept parameters (not presence-based): `FailFast(enable bool)` not `EnableFailFast()` +- Unexported options struct restricts definitions to the package +- Last option wins on conflict +- Process in order + +## Global State Avoidance + +Never force clients to use package-level global state. Provide instance values instead. + +```go +// BAD: global registry +package sidecar + +var registry = make(map[string]*Plugin) + +func Register(name string, p *Plugin) error { ... } + +// GOOD: instance-based +package sidecar + +type Registry struct { plugins map[string]*Plugin } + +func New() *Registry { return &Registry{plugins: make(map[string]*Plugin)} } + +func (r *Registry) Register(name string, p *Plugin) error { ... } +``` + +Users pass dependencies explicitly: + +```go +func main() { + sidecars := sidecar.New() + sidecars.Register("Cloud Logger", cloudlogger.New()) + cfg := &myapp.Config{Sidecars: sidecars} + myapp.Run(context.Background(), cfg) +} +``` + +Why global state fails: +- Tests become order-dependent and can't run in parallel +- Can't have multiple independent instances in one process +- Can't replace with test doubles hermetically +- Registration timing becomes fragile (`init` vs after flags vs after main) + +## Documentation Conventions + +### What to document + +- Non-obvious parameters and gotchas — skip obvious ones +- Cleanup requirements: what the caller must close/stop + +```go +// GOOD: documents cleanup +// NewTicker returns a new Ticker containing a channel that will send the +// current time on the channel after each tick. +// +// Call Stop to release the Ticker's associated resources when done. +func NewTicker(d Duration) *Ticker +``` + +- Error types returned: + +```go +// GOOD: documents error type +// Chdir changes the current working directory to the named directory. +// +// If there is an error, it will be of type *PathError. +func Chdir(dir string) error +``` + +### What NOT to document + +- Context cancellation behavior (it's implied) +- Concurrency safety of read-only operations (assumed safe) +- Parameter types already visible in the signature + +```go +// BAD: restates the obvious +// Run executes the worker's run loop. +// +// The method will process work until the context is cancelled and accordingly +// returns an error. +func (Worker) Run(ctx context.Context) error + +// GOOD: context cancellation is implied +// Run executes the worker's run loop. +func (Worker) Run(ctx context.Context) error +``` + +### When to document concurrency + +Document when: operations look read-only but mutate internally, synchronization is provided, or interfaces require goroutine-safety from implementors. + +```go +// GOOD: lookup mutates LRU cache internally +// Lookup returns the data associated with the key from the cache. +// +// This operation is not safe for concurrent use. +func (*Cache) Lookup(key string) (data []byte, ok bool) +``` + +### Signal Boosting + +Comment unusual conditions to draw attention: + +```go +// GOOD: boosts the unusual == nil check +if err := doSomething(); err == nil { // if NO error + // ... +} +``` + +## String Concatenation + +Choose by context: + +| Method | Use When | +|---|---| +| `+` | Few strings, simple cases: `key := "id: " + p` | +| `fmt.Sprintf` | Formatting needed: `fmt.Sprintf("%s [%s:%d]", src, qos, mtu)` | +| `strings.Builder` | Building piecemeal in a loop (amortized linear time) | +| `text/template` | Complex templating | + +```go +// GOOD: Sprintf for formatted output +str := fmt.Sprintf("%s [%s:%d]-> %s", src, qos, mtu, dst) + +// BAD: + with conversions +str := src.String() + " [" + qos.String() + ":" + strconv.Itoa(mtu) + "]-> " + dst.String() +``` + +```go +// GOOD: Builder for loops +b := new(strings.Builder) +for i, d := range digitsOfPi { + fmt.Fprintf(b, "the %d digit of pi is: %d\n", i, d) +} +str := b.String() +``` + +When writing to `io.Writer`, use `fmt.Fprintf` directly — don't build a string first. + +Use backticks for multi-line constants: + +```go +// GOOD +usage := `Usage: + +custom_tool [args]` + +// BAD +usage := "" + + "Usage:\n" + + "\n" + + "custom_tool [args]" +``` + +## Variable Declarations and Size Hints + +### Zero values + +```go +var ( + coords Point // ready for json.Unmarshal + magic [4]byte + primes []int +) +``` + +### Composite literals + +```go +var ( + coords = Point{X: x, Y: y} + magic = [4]byte{'I', 'W', 'A', 'D'} + captains = map[string]string{"Kirk": "James Tiberius"} +) +``` + +### Size hints — only with empirical evidence + +```go +buf := make([]byte, 131072) // known filesystem block size +q := make([]Node, 0, 16) // empirically 8-10 per run +seen := make(map[string]bool, shardSize) // known shard size +``` + +Maps must be explicitly initialized before writing. Reading from nil maps is fine. + +Default to zero init or composite literals unless profiling shows preallocation helps. diff --git a/.agents/skills/go/references/errors.md b/.agents/skills/go/references/errors.md new file mode 100644 index 0000000..816fd62 --- /dev/null +++ b/.agents/skills/go/references/errors.md @@ -0,0 +1,206 @@ +# Error Handling Patterns + +## Table of Contents +- Structured errors +- Adding context with %v vs %w +- Placement of %w +- Error logging +- Program initialization and panics + +## Structured Errors + +Give errors structure so callers can programmatically inspect them. + +### Sentinel errors for simple cases + +```go +var ( + ErrDuplicate = errors.New("duplicate") + ErrMarsupial = errors.New("marsupials are not supported") +) + +func process(animal Animal) error { + switch { + case seen[animal]: + return ErrDuplicate + case marsupial(animal): + return ErrMarsupial + } + seen[animal] = true + return nil +} +``` + +Callers use `errors.Is` (supports wrapped errors): + +```go +// GOOD +switch err := process(an); { +case errors.Is(err, ErrDuplicate): + return fmt.Errorf("feed %q: %v", an, err) +case errors.Is(err, ErrMarsupial): + alternate = an.BackupAnimal() + return handlePet(..., alternate, ...) +} + +// BAD: string matching +if regexp.MatchString(`duplicate`, err.Error()) { ... } +``` + +### Custom error types for extra info + +```go +type PathError struct { + Op string + Path string + Err error +} +``` + +## Adding Context: %v vs %w + +### Use %v at API boundaries — creates a new error, hides internals + +```go +// GOOD: RPC boundary — client doesn't need internal error details +func (*FortuneTeller) SuggestFortune(ctx context.Context, req *pb.SuggestionRequest) (*pb.SuggestionResponse, error) { + if err != nil { + return nil, fmt.Errorf("couldn't find fortune database: %v", err) + } +} +``` + +### Use %w within your application — preserves error chain for inspection + +```go +// GOOD: internal helper — caller may need to check underlying error +func (s *Server) internalFunction(ctx context.Context) error { + if err != nil { + return fmt.Errorf("couldn't find remote file: %w", err) + } +} +// Caller can do: errors.Is(err, fs.ErrNotExist) +``` + +### Add non-redundant context + +```go +// GOOD: adds meaning the underlying error doesn't have +if err := os.Open("settings.txt"); err != nil { + return fmt.Errorf("launch codes unavailable: %v", err) +} +// Output: launch codes unavailable: open settings.txt: no such file or directory + +// BAD: duplicates the file path +if err := os.Open("settings.txt"); err != nil { + return fmt.Errorf("could not open settings.txt: %v", err) +} +// Output: could not open settings.txt: open settings.txt: no such file or directory + +// BAD: adds nothing +return fmt.Errorf("failed: %v", err) // just return err +``` + +## Placement of %w + +Always place `%w` at the end so printed output reads newest-to-oldest: + +```go +// GOOD: prints in logical order +err1 := fmt.Errorf("err1") +err2 := fmt.Errorf("err2: %w", err1) +err3 := fmt.Errorf("err3: %w", err2) +fmt.Println(err3) // err3: err2: err1 + +// BAD: prints in reverse order +err2 := fmt.Errorf("%w: err2", err1) +err3 := fmt.Errorf("%w: err3", err2) +fmt.Println(err3) // err1: err2: err3 +``` + +## Error Logging + +- Don't log and return — let the caller decide +- Use `log.Error` sparingly (causes flush, expensive); prefer warning level +- ERROR level should be actionable +- Be careful with PII in logs +- Use verbose logging levels: `V(1)` small extra info, `V(2)` traces, `V(3)` large state dumps + +```go +// GOOD: guard expensive calls +for _, sql := range queries { + log.V(1).Infof("Handling %v", sql) + if log.V(2) { + log.Infof("Handling %v", sql.Explain()) + } + sql.Run(...) +} + +// BAD: sql.Explain() called even when log is off +log.V(2).Infof("Handling %v", sql.Explain()) +``` + +## Program Initialization + +Propagate init errors upward to `main`; use `log.Exit` with actionable messages: + +```go +// GOOD: tells user how to fix the problem +func main() { + cfg, err := loadConfig(*configPath) + if err != nil { + log.Exitf("Invalid config at %s: %v. See docs at ...", *configPath, err) + } +} +``` + +Don't use `log.Fatal` for init errors — a stack trace pointing at a check is less helpful than a clear message. + +## Panics + +- Prefer `log.Fatal` over `panic` for invariant violations (panic can deadlock in defers) +- Never recover panics to avoid crashes — corrupted state propagates +- Acceptable to panic on API misuse (like `reflect` does) +- Acceptable as internal implementation detail with matching `recover` at package boundary: + +```go +// GOOD: panic/recover contained within package +type syntaxError struct{ msg string } + +func parseInt(in string) int { + n, err := strconv.Atoi(in) + if err != nil { + panic(&syntaxError{"not a valid integer"}) + } + return n +} + +func Parse(in string) (_ *Node, err error) { + defer func() { + if p := recover(); p != nil { + sErr, ok := p.(*syntaxError) + if !ok { + panic(p) // re-panic: not ours + } + err = fmt.Errorf("syntax error: %v", sErr.msg) + } + }() + // ... uses parseInt internally +} +``` + +Key rule: **panics must never escape across package boundaries**. + +Use `panic("unreachable")` after `log.Fatal` calls to satisfy the compiler: + +```go +func answer(i int) string { + switch i { + case 42: + return "yup" + default: + log.Fatalf("Sorry, %d is not the answer.", i) + panic("unreachable") + } +} +``` diff --git a/.agents/skills/go/references/naming.md b/.agents/skills/go/references/naming.md new file mode 100644 index 0000000..0ea64c3 --- /dev/null +++ b/.agents/skills/go/references/naming.md @@ -0,0 +1,200 @@ +# Naming & Package Design + +## Table of Contents +- Function and method naming +- Package naming and size +- Variable and receiver naming +- Test double naming +- Shadowing pitfalls + +## Function and Method Naming + +### Avoid Repetition at Call Sites + +Omit from names: input/output types, receiver type, pointer-ness. + +```go +// BAD: repeats package name +package yamlconfig +func ParseYAMLConfig(input string) (*Config, error) + +// GOOD +package yamlconfig +func Parse(input string) (*Config, error) +``` + +```go +// BAD: repeats receiver +func (c *Config) WriteConfigTo(w io.Writer) (int64, error) + +// GOOD +func (c *Config) WriteTo(w io.Writer) (int64, error) +``` + +```go +// BAD: repeats parameter names/types +func OverrideFirstWithSecond(dest, source *Config) error + +// GOOD +func Override(dest, source *Config) error +``` + +```go +// BAD: repeats return type +func TransformToJSON(input *Config) *jsonconfig.Config + +// GOOD +func Transform(input *Config) *jsonconfig.Config +``` + +Disambiguate only when necessary: + +```go +// GOOD: disambiguation needed +func (c *Config) WriteTextTo(w io.Writer) (int64, error) +func (c *Config) WriteBinaryTo(w io.Writer) (int64, error) +``` + +### Naming Conventions + +Functions returning something → noun-like. No `Get` prefix. + +```go +// GOOD +func (c *Config) JobName(key string) (string, bool) + +// BAD +func (c *Config) GetJobName(key string) (string, bool) +``` + +Functions doing something → verb-like: + +```go +func (c *Config) WriteDetail(w io.Writer) (int64, error) +``` + +Type-differentiated functions → type at end: + +```go +func ParseInt(input string) (int, error) +func ParseInt64(input string) (int64, error) +``` + +If there's a "primary" version, omit the type: + +```go +func (c *Config) Marshal() ([]byte, error) // primary +func (c *Config) MarshalText() (string, error) // variant +``` + +## Package Naming and Size + +Package name matters more than import path for readability. Call sites should read well: + +```go +// GOOD: clear at call site +db := spannertest.NewDatabaseFromFile(...) +_, err := f.Seek(0, io.SeekStart) +b := elliptic.Marshal(curve, x, y) + +// BAD: vague package names +db := test.NewDatabaseFromFile(...) +_, err := f.Seek(0, common.SeekStart) +b := helper.Marshal(curve, x, y) +``` + +### Package Size Guidelines + +- Group types that clients use together in the same package +- If two packages always need importing together, consider merging them +- Keep files focused: a maintainer should know which file contains what +- No "one type, one file" rule — group related code by file +- Dedicate `doc.go` for long package documentation if needed + +## Test Double Naming + +### Single type → short name + +```go +package creditcardtest + +// Stub stubs creditcard.Service and provides no behavior of its own. +type Stub struct{} + +func (Stub) Charge(*creditcard.Card, money.Money) error { return nil } +``` + +### Multiple behaviors → name by behavior + +```go +type AlwaysCharges struct{} +func (AlwaysCharges) Charge(*creditcard.Card, money.Money) error { return nil } + +type AlwaysDeclines struct{} +func (AlwaysDeclines) Charge(*creditcard.Card, money.Money) error { + return creditcard.ErrDeclined +} +``` + +### Multiple types → prefix with type name + +```go +type StubService struct{} +func (StubService) Charge(*creditcard.Card, money.Money) error { return nil } + +type StubStoredValue struct{} +func (StubStoredValue) Credit(*creditcard.Card, money.Money) error { return nil } +``` + +### Local variables for test doubles → prefix with role + +```go +// GOOD: prefix clarifies it's a double +var spyCC creditcardtest.Spy +proc := &Processor{CC: spyCC} + +// BAD: ambiguous +var cc creditcardtest.Spy +proc := &Processor{CC: cc} +``` + +## Shadowing Pitfalls + +**Stomping** (reusing with `:=`) is fine when original value is no longer needed: + +```go +// GOOD: ctx intentionally stomped +func (s *Server) innerHandler(ctx context.Context, req *pb.MyRequest) *pb.MyResponse { + ctx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + // ... +} +``` + +**Shadowing** in new scope is a common bug source: + +```go +// BAD: ctx shadowed inside if — outer ctx unchanged after block +if *shortenDeadlines { + ctx, cancel := context.WithTimeout(ctx, 3*time.Second) // new ctx! + defer cancel() +} +// BUG: ctx here is the original, not the shortened one + +// GOOD: use = assignment to modify outer variable +if *shortenDeadlines { + var cancel func() + ctx, cancel = context.WithTimeout(ctx, 3*time.Second) // modifies outer ctx + defer cancel() +} +``` + +Never shadow standard library package names in large scopes: + +```go +// BAD +func LongFunction() { + url := "https://example.com/" + // net/url is now inaccessible +} +``` diff --git a/.agents/skills/go/references/testing.md b/.agents/skills/go/references/testing.md new file mode 100644 index 0000000..23e16e7 --- /dev/null +++ b/.agents/skills/go/references/testing.md @@ -0,0 +1,245 @@ +# Testing Patterns + +## Table of Contents +- Test function design +- Table-driven tests +- Test helpers vs assertion helpers +- t.Error vs t.Fatal +- Test setup scoping +- Real transports +- Acceptance testing + +## Test Function Design + +Keep pass/fail logic inside the `Test` function. Don't push failure decisions into helpers. + +Three approaches when many test cases need the same validation: + +1. **Inline** — repeat the validation in `Test` (best for simple cases) +2. **Table-driven** — unify inputs into a table, loop with inline validation +3. **Return error** — validation function returns `error`, `Test` decides whether to fail + +```go +// GOOD: validation returns a value, Test decides +func polygonCmp() cmp.Option { + return cmp.Options{ + cmp.Transformer("polygon", func(p *s2.Polygon) []*s2.Loop { return p.Loops() }), + cmp.Transformer("loop", func(l *s2.Loop) []s2.Point { return l.Vertices() }), + cmpopts.EquateApprox(0.00000001, 0), + cmpopts.EquateEmpty(), + } +} + +func TestFenceposts(t *testing.T) { + got := Fencepost(tomsDiner, 1*meter) + if diff := cmp.Diff(want, got, polygonCmp()); diff != "" { + t.Errorf("Fencepost(tomsDiner, 1m) returned unexpected diff (-want+got):\n%v", diff) + } +} +``` + +## Table-Driven Tests + +Use named fields. Include `name` for subtests. + +```go +func TestStrJoin(t *testing.T) { + tests := []struct { + name string + slice []string + separator string + skipEmpty bool + want string + }{ + { + name: "with empty element", + slice: []string{"a", "b", ""}, + separator: ",", + want: "a,b,", + }, + { + name: "skip empty", + slice: []string{"a", "b", ""}, + separator: ",", + skipEmpty: true, + want: "a,b", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := StrJoin(tt.slice, tt.separator, tt.skipEmpty) + if got != tt.want { + t.Errorf("StrJoin() = %q, want %q", got, tt.want) + } + }) + } +} +``` + +## Test Helpers + +Mark with `t.Helper()`. Use `t.Fatal` for setup failures (not test assertions). + +```go +// GOOD: helper that fatals on setup failure +func mustAddGameAssets(t *testing.T, dir string) { + t.Helper() + if err := os.WriteFile(path.Join(dir, "pak0.pak"), pak0, 0644); err != nil { + t.Fatalf("Setup failed: could not write pak0 asset: %v", err) + } +} + +// BAD: helper that returns error (clutters call site) +func addGameAssets(t *testing.T, dir string) error { + // forces every caller to check err +} +``` + +Use `t.Cleanup` for teardown: + +```go +func setupDatabase(t *testing.T) *sql.DB { + t.Helper() + db, err := sql.Open("postgres", testDSN) + if err != nil { + t.Fatalf("Could not open test database: %v", err) + } + t.Cleanup(func() { db.Close() }) + return db +} +``` + +## t.Error vs t.Fatal + +- `t.Fatal`: setup failures, preconditions that prevent further testing +- `t.Error`: test assertions — keep going to find more failures +- In table tests without subtests: `t.Error` + `continue` +- In subtests: `t.Fatal` is ok (only ends that subtest) + +**Never call `t.Fatal` from a goroutine:** + +```go +// GOOD +func TestRevEngine(t *testing.T) { + engine, err := Start() + if err != nil { + t.Fatalf("Engine failed to start: %v", err) + } + + var wg sync.WaitGroup + wg.Add(num) + for i := 0; i < num; i++ { + go func() { + defer wg.Done() + if err := engine.Vroom(); err != nil { + t.Errorf("No vroom left: %v", err) // NOT t.Fatal + return + } + }() + } + wg.Wait() +} +``` + +## Test Setup Scoping + +Scope setup to tests that need it. Don't penalize unrelated tests. + +```go +// GOOD: only tests that need data call this +func TestParseData(t *testing.T) { + data := mustLoadDataset(t) + // ... +} + +func TestRegression682831(t *testing.T) { + // Doesn't need dataset — runs fast + if got, want := guessOS("zpc79.example.com"), "grhat"; got != want { + t.Errorf(`guessOS("zpc79.example.com") = %q, want %q`, got, want) + } +} + +// BAD: package-level init loads expensive data for ALL tests +var dataset []byte +func init() { + dataset = mustLoadDataset() +} +``` + +### Amortize with sync.Once when setup is expensive, applies to some tests, and needs no teardown: + +```go +var dataset struct { + once sync.Once + data []byte + err error +} + +func mustLoadDataset(t *testing.T) []byte { + t.Helper() + dataset.once.Do(func() { + dataset.data, dataset.err = os.ReadFile("testdata/dataset") + }) + if err := dataset.err; err != nil { + t.Fatalf("Could not load dataset: %v", err) + } + return dataset.data +} +``` + +### Custom TestMain only when ALL tests need shared setup with teardown: + +```go +func TestMain(m *testing.M) { + code, err := runMain(context.Background(), m) + if err != nil { + log.Fatal(err) + } + os.Exit(code) +} + +func runMain(ctx context.Context, m *testing.M) (int, error) { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + d, err := setupDatabase(ctx) + if err != nil { + return 0, err + } + defer d.Close() + db = d + + return m.Run(), nil +} +``` + +## Real Transports + +Prefer real HTTP/RPC clients connected to test-double servers over hand-implementing client behavior: + +```go +// GOOD: real client, test server +client := NewOperationsClient(testServer.Addr()) + +// BAD: hand-rolled client that may not match real behavior +client := &fakeOperationsClient{...} +``` + +## Acceptance Testing + +For validating user implementations of your interfaces, return errors instead of taking `*testing.T`: + +```go +// GOOD: acceptance test as library function +func ExercisePlayer(b *chess.Board, p chess.Player) error { + // validate moves, return structured errors +} + +// User's test +func TestAcceptance(t *testing.T) { + player := deepblue.New() + if err := chesstest.ExerciseGame(t, chesstest.SimpleGame, player); err != nil { + t.Errorf("Deep Blue failed acceptance: %v", err) + } +} +``` diff --git a/.agents/skills/grill-with-docs/ADR-FORMAT.md b/.agents/skills/grill-with-docs/ADR-FORMAT.md new file mode 100644 index 0000000..da7e78e --- /dev/null +++ b/.agents/skills/grill-with-docs/ADR-FORMAT.md @@ -0,0 +1,47 @@ +# ADR Format + +ADRs live in `docs/adr/` and use sequential numbering: `0001-slug.md`, `0002-slug.md`, etc. + +Create the `docs/adr/` directory lazily — only when the first ADR is needed. + +## Template + +```md +# {Short title of the decision} + +{1-3 sentences: what's the context, what did we decide, and why.} +``` + +That's it. An ADR can be a single paragraph. The value is in recording *that* a decision was made and *why* — not in filling out sections. + +## Optional sections + +Only include these when they add genuine value. Most ADRs won't need them. + +- **Status** frontmatter (`proposed | accepted | deprecated | superseded by ADR-NNNN`) — useful when decisions are revisited +- **Considered Options** — only when the rejected alternatives are worth remembering +- **Consequences** — only when non-obvious downstream effects need to be called out + +## Numbering + +Scan `docs/adr/` for the highest existing number and increment by one. + +## When to offer an ADR + +All three of these must be true: + +1. **Hard to reverse** — the cost of changing your mind later is meaningful +2. **Surprising without context** — a future reader will look at the code and wonder "why on earth did they do it this way?" +3. **The result of a real trade-off** — there were genuine alternatives and you picked one for specific reasons + +If a decision is easy to reverse, skip it — you'll just reverse it. If it's not surprising, nobody will wonder why. If there was no real alternative, there's nothing to record beyond "we did the obvious thing." + +### What qualifies + +- **Architectural shape.** "We're using a monorepo." "The write model is event-sourced, the read model is projected into Postgres." +- **Integration patterns between contexts.** "Ordering and Billing communicate via domain events, not synchronous HTTP." +- **Technology choices that carry lock-in.** Database, message bus, auth provider, deployment target. Not every library — just the ones that would take a quarter to swap out. +- **Boundary and scope decisions.** "Customer data is owned by the Customer context; other contexts reference it by ID only." The explicit no-s are as valuable as the yes-s. +- **Deliberate deviations from the obvious path.** "We're using manual SQL instead of an ORM because X." Anything where a reasonable reader would assume the opposite. These stop the next engineer from "fixing" something that was deliberate. +- **Constraints not visible in the code.** "We can't use AWS because of compliance requirements." "Response times must be under 200ms because of the partner API contract." +- **Rejected alternatives when the rejection is non-obvious.** If you considered GraphQL and picked REST for subtle reasons, record it — otherwise someone will suggest GraphQL again in six months. diff --git a/.agents/skills/grill-with-docs/CONTEXT-FORMAT.md b/.agents/skills/grill-with-docs/CONTEXT-FORMAT.md new file mode 100644 index 0000000..ddfa247 --- /dev/null +++ b/.agents/skills/grill-with-docs/CONTEXT-FORMAT.md @@ -0,0 +1,77 @@ +# CONTEXT.md Format + +## Structure + +```md +# {Context Name} + +{One or two sentence description of what this context is and why it exists.} + +## Language + +**Order**: +{A concise description of the term} +_Avoid_: Purchase, transaction + +**Invoice**: +A request for payment sent to a customer after delivery. +_Avoid_: Bill, payment request + +**Customer**: +A person or organization that places orders. +_Avoid_: Client, buyer, account + +## Relationships + +- An **Order** produces one or more **Invoices** +- An **Invoice** belongs to exactly one **Customer** + +## Example dialogue + +> **Dev:** "When a **Customer** places an **Order**, do we create the **Invoice** immediately?" +> **Domain expert:** "No — an **Invoice** is only generated once a **Fulfillment** is confirmed." + +## Flagged ambiguities + +- "account" was used to mean both **Customer** and **User** — resolved: these are distinct concepts. +``` + +## Rules + +- **Be opinionated.** When multiple words exist for the same concept, pick the best one and list the others as aliases to avoid. +- **Flag conflicts explicitly.** If a term is used ambiguously, call it out in "Flagged ambiguities" with a clear resolution. +- **Keep definitions tight.** One sentence max. Define what it IS, not what it does. +- **Show relationships.** Use bold term names and express cardinality where obvious. +- **Only include terms specific to this project's context.** General programming concepts (timeouts, error types, utility patterns) don't belong even if the project uses them extensively. Before adding a term, ask: is this a concept unique to this context, or a general programming concept? Only the former belongs. +- **Group terms under subheadings** when natural clusters emerge. If all terms belong to a single cohesive area, a flat list is fine. +- **Write an example dialogue.** A conversation between a dev and a domain expert that demonstrates how the terms interact naturally and clarifies boundaries between related concepts. + +## Single vs multi-context repos + +**Single context (most repos):** One `CONTEXT.md` at the repo root. + +**Multiple contexts:** A `CONTEXT-MAP.md` at the repo root lists the contexts, where they live, and how they relate to each other: + +```md +# Context Map + +## Contexts + +- [Ordering](./src/ordering/CONTEXT.md) — receives and tracks customer orders +- [Billing](./src/billing/CONTEXT.md) — generates invoices and processes payments +- [Fulfillment](./src/fulfillment/CONTEXT.md) — manages warehouse picking and shipping + +## Relationships + +- **Ordering → Fulfillment**: Ordering emits `OrderPlaced` events; Fulfillment consumes them to start picking +- **Fulfillment → Billing**: Fulfillment emits `ShipmentDispatched` events; Billing consumes them to generate invoices +- **Ordering ↔ Billing**: Shared types for `CustomerId` and `Money` +``` + +The skill infers which structure applies: + +- If `CONTEXT-MAP.md` exists, read it to find contexts +- If only a root `CONTEXT.md` exists, single context +- If neither exists, create a root `CONTEXT.md` lazily when the first term is resolved + +When multiple contexts exist, infer which one the current topic relates to. If unclear, ask. diff --git a/.agents/skills/grill-with-docs/SKILL.md b/.agents/skills/grill-with-docs/SKILL.md new file mode 100644 index 0000000..5ea0aa9 --- /dev/null +++ b/.agents/skills/grill-with-docs/SKILL.md @@ -0,0 +1,88 @@ +--- +name: grill-with-docs +description: Grilling session that challenges your plan against the existing domain model, sharpens terminology, and updates documentation (CONTEXT.md, ADRs) inline as decisions crystallise. Use when user wants to stress-test a plan against their project's language and documented decisions. +--- + + + +Interview me relentlessly about every aspect of this plan until we reach a shared understanding. Walk down each branch of the design tree, resolving dependencies between decisions one-by-one. For each question, provide your recommended answer. + +Ask the questions one at a time, waiting for feedback on each question before continuing. + +If a question can be answered by exploring the codebase, explore the codebase instead. + + + + + +## Domain awareness + +During codebase exploration, also look for existing documentation: + +### File structure + +Most repos have a single context: + +``` +/ +├── CONTEXT.md +├── docs/ +│ └── adr/ +│ ├── 0001-event-sourced-orders.md +│ └── 0002-postgres-for-write-model.md +└── src/ +``` + +If a `CONTEXT-MAP.md` exists at the root, the repo has multiple contexts. The map points to where each one lives: + +``` +/ +├── CONTEXT-MAP.md +├── docs/ +│ └── adr/ ← system-wide decisions +├── src/ +│ ├── ordering/ +│ │ ├── CONTEXT.md +│ │ └── docs/adr/ ← context-specific decisions +│ └── billing/ +│ ├── CONTEXT.md +│ └── docs/adr/ +``` + +Create files lazily — only when you have something to write. If no `CONTEXT.md` exists, create one when the first term is resolved. If no `docs/adr/` exists, create it when the first ADR is needed. + +## During the session + +### Challenge against the glossary + +When the user uses a term that conflicts with the existing language in `CONTEXT.md`, call it out immediately. "Your glossary defines 'cancellation' as X, but you seem to mean Y — which is it?" + +### Sharpen fuzzy language + +When the user uses vague or overloaded terms, propose a precise canonical term. "You're saying 'account' — do you mean the Customer or the User? Those are different things." + +### Discuss concrete scenarios + +When domain relationships are being discussed, stress-test them with specific scenarios. Invent scenarios that probe edge cases and force the user to be precise about the boundaries between concepts. + +### Cross-reference with code + +When the user states how something works, check whether the code agrees. If you find a contradiction, surface it: "Your code cancels entire Orders, but you just said partial cancellation is possible — which is right?" + +### Update CONTEXT.md inline + +When a term is resolved, update `CONTEXT.md` right there. Don't batch these up — capture them as they happen. Use the format in [CONTEXT-FORMAT.md](./CONTEXT-FORMAT.md). + +`CONTEXT.md` should be totally devoid of implementation details. Do not treat `CONTEXT.md` as a spec, a scratch pad, or a repository for implementation decisions. It is a glossary and nothing else. + +### Offer ADRs sparingly + +Only offer to create an ADR when all three are true: + +1. **Hard to reverse** — the cost of changing your mind later is meaningful +2. **Surprising without context** — a future reader will wonder "why did they do it this way?" +3. **The result of a real trade-off** — there were genuine alternatives and you picked one for specific reasons + +If any of the three is missing, skip the ADR. Use the format in [ADR-FORMAT.md](./ADR-FORMAT.md). + + diff --git a/.agents/skills/handoff/SKILL.md b/.agents/skills/handoff/SKILL.md new file mode 100644 index 0000000..a2d8bbb --- /dev/null +++ b/.agents/skills/handoff/SKILL.md @@ -0,0 +1,13 @@ +--- +name: handoff +description: Compact the current conversation into a handoff document for another agent to pick up. +argument-hint: "What will the next session be used for?" +--- + +Write a handoff document summarising the current conversation so a fresh agent can continue the work. Save it under a project-local temp directory such as `.opencode/tmp/` when available; otherwise use `~/.agents/tmp/`. Read the target file before writing if it already exists. + +Suggest the skills to be used, if any, by the next session. + +Do not duplicate content already captured in other artifacts (PRDs, plans, ADRs, issues, commits, diffs). Reference them by path or URL instead. + +If the user passed arguments, treat them as a description of what the next session will focus on and tailor the doc accordingly. diff --git a/.agents/skills/helm-chart/SKILL.md b/.agents/skills/helm-chart/SKILL.md new file mode 100644 index 0000000..fa400c9 --- /dev/null +++ b/.agents/skills/helm-chart/SKILL.md @@ -0,0 +1,563 @@ +--- +name: helm-chart +description: Generate production-ready Helm charts following official best practices. Use when creating Kubernetes Helm charts, chart templates, values.yaml files, or _helpers.tpl. Triggers on requests for Helm chart generation, Kubernetes packaging, or chart template development. +--- + +# Helm Chart Generation + +Generate production-ready Helm charts following official Helm best practices. + +## Chart Structure + +``` +mychart/ +├── Chart.yaml # Required: chart metadata +├── values.yaml # Default configuration values +├── charts/ # Dependencies +├── crds/ # CRD definitions (not templated) +├── templates/ +│ ├── NOTES.txt # Post-install instructions +│ ├── _helpers.tpl # Template helpers +│ ├── deployment.yaml +│ ├── service.yaml +│ ├── ingress.yaml +│ ├── serviceaccount.yaml +│ ├── hpa.yaml +│ └── tests/ +│ └── test-connection.yaml +└── .helmignore +``` + +## Naming Conventions + +**Chart names**: lowercase letters, numbers, hyphens only. Start with letter. +``` +✓ nginx-ingress, aws-cluster-autoscaler +✗ Nginx_Ingress, 1st-chart +``` + +**Template files**: dashed notation, reflect resource kind. +``` +✓ foo-deployment.yaml, bar-svc.yaml +✗ fooDeployment.yaml, bar.yaml +``` + +**Values**: camelCase, lowercase start. No hyphens. +```yaml +# ✓ Correct +replicaCount: 1 +image: + repository: nginx + pullPolicy: IfNotPresent + +# ✗ Incorrect +ReplicaCount: 1 # conflicts with built-ins +replica-count: 1 # hyphens break templates +``` + +## Template Formatting + +```yaml +# Whitespace after {{ and before }} +{{ .Values.foo }} # ✓ +{{.Values.foo}} # ✗ + +# Chomp whitespace with - +{{- if .Values.enabled }} + key: value +{{- end }} + +# Two-space indentation, never tabs +``` + +## Essential _helpers.tpl + +```yaml +{{/* +Chart name, truncated to 63 chars (DNS limit) +*/}} +{{- define "mychart.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Fully qualified app name. Release + chart name, max 63 chars. +*/}} +{{- define "mychart.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "mychart.labels" -}} +helm.sh/chart: {{ include "mychart.chart" . }} +{{ include "mychart.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels (immutable for Deployments) +*/}} +{{- define "mychart.selectorLabels" -}} +app.kubernetes.io/name: {{ include "mychart.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Chart label +*/}} +{{- define "mychart.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +ServiceAccount name +*/}} +{{- define "mychart.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "mychart.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} +``` + +## Labels vs Annotations + +**Labels**: For identification and querying. Used by Kubernetes selectors. +**Annotations**: For non-identifying metadata. Not used for selection. + +## Standard Labels + +| Label | Status | Description | +|-------|--------|-------------| +| `app.kubernetes.io/name` | **REC** | App name: `{{ template "name" . }}` | +| `app.kubernetes.io/instance` | **REC** | Release: `{{ .Release.Name }}` | +| `app.kubernetes.io/version` | OPT | App version: `{{ .Chart.AppVersion }}` | +| `app.kubernetes.io/managed-by` | **REC** | Always: `{{ .Release.Service }}` | +| `helm.sh/chart` | **REC** | Chart identifier: `{{ .Chart.Name }}-{{ .Chart.Version }}` | +| `app.kubernetes.io/component` | OPT | Role in app: `frontend`, `backend`, `database` | +| `app.kubernetes.io/part-of` | OPT | Parent application name (for multi-chart apps) | + +**REC** = Recommended (should always include), **OPT** = Optional + +## Values Best Practices + +See `references/values-patterns.md` for comprehensive patterns from Bitnami, ArgoCD, Grafana charts. + +### When to Use Subtrees vs Flat Values + +**Subtrees for K8s API objects** - Users `toYaml` entire sections: +```yaml +# ✓ Correct: subtree mirrors K8s API +resources: {} + # limits: + # cpu: 100m + # memory: 128Mi + +podSecurityContext: {} + # fsGroup: 2000 + +# ✗ Wrong: flat values for structured objects +resourceLimitsCpu: 100m +resourceLimitsMemory: 128Mi +``` + +**Flat for simple values**: +```yaml +# ✓ Correct: single values stay flat +replicaCount: 1 +revisionHistoryLimit: 3 +``` + +### Empty Defaults `{}` + +Allow users to override entire sections or leave empty: +```yaml +resources: {} # User provides full spec or nothing +nodeSelector: {} +tolerations: [] +affinity: {} +``` + +### Maps over Arrays (for `--set` compatibility) + +```yaml +# ✓ Easy: --set servers.foo.port=80 +servers: + foo: + port: 80 + +# ✗ Hard: --set servers[0].port=80 +servers: + - name: foo + port: 80 +``` + +### Document Every Value + +```yaml +# -- Number of pod replicas +replicaCount: 1 + +image: + # -- Container registry + registry: docker.io + # -- Image repository + repository: nginx + # -- Image tag (defaults to Chart.appVersion) + tag: "" +``` + +## Standard values.yaml Structure + +Industry-standard structure (matches `helm create` + Bitnami patterns): + +```yaml +# -- Number of replicas +replicaCount: 1 + +# -- Revision history limit for deployments +revisionHistoryLimit: 3 + +image: + # -- Container registry + registry: docker.io + # -- Image repository + repository: nginx + # -- Image tag (defaults to Chart.appVersion) + tag: "" + # -- Image pull policy + pullPolicy: IfNotPresent + +# -- Image pull secrets +imagePullSecrets: [] +# - name: regcred + +# -- Override chart name +nameOverride: "" +# -- Override full release name +fullnameOverride: "" + +serviceAccount: + # -- Create service account + create: true + # -- Automatically mount API credentials + automount: true + # -- Service account annotations + annotations: {} + # -- Service account name (auto-generated if empty) + name: "" + +# -- Pod annotations +podAnnotations: {} +# -- Pod labels (in addition to standard labels) +podLabels: {} + +# -- Pod security context (applied to pod spec) +podSecurityContext: {} + # fsGroup: 2000 + +# -- Container security context +securityContext: {} + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + # runAsUser: 1000 + +service: + # -- Service type + type: ClusterIP + # -- Service port + port: 80 + +ingress: + # -- Enable ingress + enabled: false + # -- Ingress class name + className: "" + # -- Ingress annotations + annotations: {} + # kubernetes.io/ingress.class: nginx + # cert-manager.io/cluster-issuer: letsencrypt + hosts: + - host: chart-example.local + paths: + - path: / + pathType: ImplementationSpecific + # -- TLS configuration + tls: [] + # - secretName: chart-example-tls + # hosts: + # - chart-example.local + +# -- Container resources +resources: {} + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + +livenessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + +readinessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + +autoscaling: + # -- Enable HPA + enabled: false + # -- Minimum replicas + minReplicas: 1 + # -- Maximum replicas + maxReplicas: 100 + # -- Target CPU utilization + targetCPUUtilizationPercentage: 80 + # targetMemoryUtilizationPercentage: 80 + +# -- Node selector +nodeSelector: {} + +# -- Tolerations +tolerations: [] + +# -- Affinity rules +affinity: {} + +# -- Extra environment variables +extraEnvVars: [] +# - name: FOO +# value: bar + +# -- Extra volumes +extraVolumes: [] + +# -- Extra volume mounts +extraVolumeMounts: [] +``` + +### Pattern: Subtrees for toYaml + +```yaml +# Template +{{- with .Values.nodeSelector }} +nodeSelector: + {{- toYaml . | nindent 8 }} +{{- end }} + +resources: + {{- toYaml .Values.resources | nindent 12 }} +``` + +### Pattern: Enabled Flag + Config + +```yaml +# values.yaml +metrics: + enabled: false + service: + port: 9090 + serviceMonitor: + enabled: false + interval: 30s + +# template +{{- if .Values.metrics.enabled }} +# metrics service/servicemonitor +{{- end }} +``` + +## RBAC Pattern + +```yaml +# values.yaml +rbac: + create: true + +serviceAccount: + create: true + name: "" +``` + +```yaml +# templates/serviceaccount.yaml +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "mychart.serviceAccountName" . }} + labels: + {{- include "mychart.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} +``` + +## Image Configuration Pattern + +```yaml +# values.yaml - use separate fields +image: + repository: nginx + pullPolicy: IfNotPresent + tag: "" # Overrides Chart.appVersion + +# template +image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" +imagePullPolicy: {{ .Values.image.pullPolicy }} +``` + +Never use `latest`, `head`, `canary` tags. + +## PodTemplate Selector Pattern + +Always declare explicit selectors (prevents breakage on label changes): +```yaml +spec: + selector: + matchLabels: + {{- include "mychart.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "mychart.selectorLabels" . | nindent 8 }} +``` + +## Flow Control + +```yaml +# if/else +{{- if .Values.ingress.enabled }} +# ... ingress resource +{{- end }} + +# with (changes scope) +{{- with .Values.nodeSelector }} +nodeSelector: + {{- toYaml . | nindent 8 }} +{{- end }} + +# range +{{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} +{{- end }} + +# Access parent scope in with/range +{{- with .Values.favorite }} +release: {{ $.Release.Name }} # $ = root scope +{{- end }} +``` + +## Key Functions + +See `references/functions.md` for full list. Most common: + +| Function | Usage | +|----------|-------| +| `default` | `{{ .Values.name \| default "app" }}` | +| `quote` | `{{ .Values.env \| quote }}` | +| `toYaml` | `{{- toYaml .Values.resources \| nindent 12 }}` | +| `include` | `{{ include "mychart.name" . }}` | +| `required` | `{{ required "msg" .Values.key }}` | +| `tpl` | `{{ tpl .Values.template . }}` | +| `lookup` | `{{ lookup "v1" "Secret" "ns" "name" }}` | + +## Validation & Debugging + +See `references/validation.md` for full validation pipeline with all tools. + +### Quick Validation + +```bash +# Lint for best practices +helm lint ./mychart --strict + +# Template locally + debug +helm template ./mychart --debug + +# Schema validation (kubeconform) +helm template ./mychart | kubeconform -strict -summary -kubernetes-version 1.29.0 + +# Deprecated API detection (pluto) +helm template ./mychart | pluto detect - + +# Security scan (trivy) +trivy config ./mychart --severity HIGH,CRITICAL + +# Server-side dry-run (validates against cluster) +helm install --dry-run=server myrelease ./mychart +``` + +### Debugging Tips + +- `helm template --debug` shows computed values +- Comment out broken sections, re-template to see rendered output +- `helm get manifest ` shows what's actually deployed +- kubeconform `-ignore-missing-schemas` skips unknown CRDs + +## Common Pitfalls + +See `references/common-pitfalls.md` for detailed patterns. Key issues: + +1. **Whitespace errors** - use `{{-` and `-}}` carefully +2. **Type coercion** - quote strings, use `{{ int $val }}` for numbers +3. **Nested value checks** - each level needs existence check +4. **Selector immutability** - don't include mutable labels in selectors +5. **YAML comments in templates** - `#` comments render, use `{{/* */}}` for template-only + +## Umbrella Charts + +For multi-component applications, see `references/umbrella-charts.md`: + +```yaml +# Chart.yaml +dependencies: + - name: frontend + version: "1.x.x" + repository: "file://charts/frontend" + - name: backend + version: "2.0.0" + repository: "https://charts.example.com" + condition: backend.enabled +``` + +```bash +helm dependency update ./myapp +``` + +## References + +| File | Content | +|------|---------| +| `references/values-patterns.md` | Industry-standard values.yaml patterns (Bitnami, ArgoCD, Grafana) | +| `references/functions.md` | Go/Sprig/Helm function reference | +| `references/common-pitfalls.md` | Template debugging patterns | +| `references/validation.md` | Full validation pipeline (ct, kubeconform, trivy, pluto) | +| `references/umbrella-charts.md` | Dependency management, multi-chart patterns | diff --git a/.agents/skills/helm-chart/references/common-pitfalls.md b/.agents/skills/helm-chart/references/common-pitfalls.md new file mode 100644 index 0000000..8e55774 --- /dev/null +++ b/.agents/skills/helm-chart/references/common-pitfalls.md @@ -0,0 +1,405 @@ +# Helm Chart Common Pitfalls + +## Whitespace Issues + +### Problem: Unwanted blank lines + +```yaml +# BAD - produces blank lines +data: + {{ if .Values.key }} + key: value + {{ end }} + +# GOOD - use {{- to chomp +data: + {{- if .Values.key }} + key: value + {{- end }} +``` + +### Problem: Chomped too much + +```yaml +# BAD - produces "key:value" (no space) +key: {{- .Values.name }} + +# GOOD - chomp on the right side only, or add space +key: {{ .Values.name -}} +key:{{ " " }}{{- .Values.name }} +``` + +### Problem: Indentation in conditionals + +```yaml +# BAD - mug is wrongly indented +data: + myvalue: "Hello" + {{ if .Values.coffee }} + mug: "true" # Wrong: extra indentation from template + {{ end }} + +# GOOD - align with output, not template structure +data: + myvalue: "Hello" + {{- if .Values.coffee }} + mug: "true" + {{- end }} +``` + +## Type Coercion + +### Problem: Numbers become floats or scientific notation + +```yaml +# values.yaml +port: 8080 +bigNumber: 12345678 + +# BAD - may render as 1.2345678e+07 +value: {{ .Values.bigNumber }} + +# GOOD - explicit conversion or quote +value: {{ int .Values.bigNumber }} +value: {{ .Values.bigNumber | quote }} +``` + +### Problem: Boolean vs string "false" + +```yaml +# values.yaml +enabled: false # boolean +enabled: "false" # string (not the same!) + +# Template check +{{- if .Values.enabled }} # "false" string is TRUTHY! +``` + +### Problem: Nil vs empty string + +```yaml +# If value is not set at all (nil) vs empty string "" +{{- if .Values.name }} # Both nil and "" are falsy +{{- if eq .Values.name "" }} # Only matches "" +{{- if not .Values.name }} # Matches nil, "", 0, false +``` + +## Nested Value Access + +### Problem: Nil pointer dereference + +```yaml +# values.yaml has no 'server' key + +# BAD - panics if .Values.server is nil +name: {{ .Values.server.name }} + +# GOOD - check each level +{{- if .Values.server }} +name: {{ .Values.server.name }} +{{- end }} + +# OR use with +{{- with .Values.server }} +name: {{ .name }} +{{- end }} + +# OR use dig (Sprig) +name: {{ dig "server" "name" "default" .Values }} +``` + +## Scope Issues + +### Problem: Can't access parent in with/range + +```yaml +# BAD - .Release not available inside with +{{- with .Values.config }} +release: {{ .Release.Name }} # ERROR +{{- end }} + +# GOOD - use $ for root scope +{{- with .Values.config }} +release: {{ $.Release.Name }} +{{- end }} + +# OR capture in variable first +{{- $releaseName := .Release.Name -}} +{{- with .Values.config }} +release: {{ $releaseName }} +{{- end }} +``` + +## Label Selector Immutability + +### Problem: Deployment selector can't be updated + +```yaml +# BAD - includes mutable labels in selector +spec: + selector: + matchLabels: + app: {{ .Chart.Name }} + version: {{ .Chart.Version }} # Changes on upgrade! + +# GOOD - only stable labels in selector +spec: + selector: + matchLabels: + app.kubernetes.io/name: {{ include "mychart.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} +``` + +## Comment Issues + +### Problem: YAML comments render in output + +```yaml +# This comment appears in rendered YAML +key: value + +# If using required, YAML comment causes error +# memory setting: +memory: {{ required "memory required" .Values.memory }} +``` + +### Solution: Use template comments for internal notes + +```yaml +{{/* This comment is stripped from output */}} +key: value + +{{- /* +Multi-line template comment +Also stripped from output +*/ -}} +``` + +## Quote Issues + +### Problem: Unquoted values break YAML + +```yaml +# values.yaml +message: "Hello: World" # Contains colon + +# BAD - breaks YAML parsing +message: {{ .Values.message }} +# Renders as: message: Hello: World (invalid YAML) + +# GOOD - always quote strings +message: {{ .Values.message | quote }} +# Renders as: message: "Hello: World" +``` + +### Problem: Quote vs squote in annotations + +```yaml +# BAD - double quotes may conflict with JSON inside +annotations: + config: {{ .Values.jsonConfig | quote }} + +# GOOD - use toJson for JSON values +annotations: + config: {{ .Values.config | toJson | quote }} +``` + +## include vs template + +### Problem: template output can't be piped + +```yaml +# BAD - template can't be indented +labels: + {{ template "mychart.labels" . }} + +# GOOD - include returns string, can be piped +labels: + {{- include "mychart.labels" . | nindent 4 }} +``` + +## Range Empty List + +### Problem: Range produces no output + +```yaml +# If .Values.hosts is empty or nil +{{- range .Values.hosts }} +- {{ . }} +{{- end }} +# Produces nothing - may break parent structure + +# GOOD - use range/else +{{- range .Values.hosts }} +- {{ . }} +{{- else }} +- "default.local" +{{- end }} +``` + +## toYaml Indentation + +### Problem: Wrong indentation with toYaml + +```yaml +# BAD - first line not indented +resources: + {{ toYaml .Values.resources }} + +# GOOD - use nindent (adds newline + indent) +resources: + {{- toYaml .Values.resources | nindent 2 }} + +# Or for inline (rare) +resources: {{ toYaml .Values.resources | indent 2 | trim }} +``` + +## lookup in Dry Run + +### Problem: lookup returns empty in template mode + +```yaml +# Works in real install, empty in helm template +{{- $secret := lookup "v1" "Secret" .Release.Namespace "my-secret" }} +{{- if $secret }} +# This block never executes during helm template +{{- end }} + +# GOOD - handle both cases +{{- $secret := lookup "v1" "Secret" .Release.Namespace "my-secret" }} +{{- if $secret.data }} +existingKey: {{ index $secret.data "key" | b64dec }} +{{- else }} +# Generate or use default +newKey: {{ randAlphaNum 32 | b64enc }} +{{- end }} +``` + +## Name Length Limits + +### Problem: Names exceed Kubernetes limits + +```yaml +# Kubernetes DNS names limited to 63 chars +# Release names can be long: my-very-long-release-name-for-testing + +# BAD - may exceed limit +name: {{ .Release.Name }}-{{ .Chart.Name }}-configmap + +# GOOD - truncate +name: {{ printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" }} + +# BEST - use helper +name: {{ include "mychart.fullname" . }} +``` + +## default Function Gotcha + +### Problem: default doesn't work with false/0 + +```yaml +# default only triggers on nil/empty +{{ default true .Values.enabled }} # If enabled: false, returns false not true + +# For boolean defaults, be explicit +{{- if hasKey .Values "enabled" }} +enabled: {{ .Values.enabled }} +{{- else }} +enabled: true +{{- end }} + +# Or use coalesce for first non-nil +{{ coalesce .Values.primary .Values.secondary "fallback" }} +``` + +## Chart.yaml appVersion + +### Problem: appVersion not quoted breaks image tags + +```yaml +# Chart.yaml +appVersion: 1.16.0 # Parsed as number! + +# Template +image: nginx:{{ .Chart.AppVersion }} +# May render as: nginx:1.16 (loses .0) + +# GOOD - quote in Chart.yaml +appVersion: "1.16.0" +``` + +## Multiline Strings + +### Problem: Multiline values break YAML structure + +```yaml +# values.yaml +config: | + line1 + line2 + +# BAD - indentation lost +data: + config: {{ .Values.config }} + +# GOOD - preserve with proper quoting +data: + config: | + {{- .Values.config | nindent 4 }} + +# Or use toYaml for complex structures +data: + {{- toYaml .Values.config | nindent 2 }} +``` + +## Hook Annotations + +### Problem: Hooks not in annotations block + +```yaml +# BAD - hook at wrong level +metadata: + name: my-job + "helm.sh/hook": post-install # Not an annotation! + +# GOOD +metadata: + name: my-job + annotations: + "helm.sh/hook": post-install + "helm.sh/hook-delete-policy": hook-succeeded +``` + +## CRD Timing + +### Problem: CRD not available when CR is created + +```yaml +# CRDs in crds/ dir are installed first, but may not be ready +# Resources using CRD may fail on first install + +# Solution 1: Put CRD in separate chart, install first +# Solution 2: Use helm.sh/hook: crd-install (deprecated in Helm 3) +# Solution 3: Wait/retry logic in application +``` + +## Secret Base64 + +### Problem: Double-encoding secrets + +```yaml +# values.yaml +password: mypassword # Plain text + +# BAD - if value already base64 +data: + password: {{ .Values.password | b64enc }} + +# GOOD - check if needs encoding +stringData: # Kubernetes handles encoding + password: {{ .Values.password }} + +# Or for data: field +data: + password: {{ .Values.password | b64enc }} +``` diff --git a/.agents/skills/helm-chart/references/functions.md b/.agents/skills/helm-chart/references/functions.md new file mode 100644 index 0000000..1b729ee --- /dev/null +++ b/.agents/skills/helm-chart/references/functions.md @@ -0,0 +1,369 @@ +# Helm Template Functions Reference + +Helm templates use Go templates + Sprig library + Helm-specific functions. + +## Go Built-in Functions + +### Comparison Operators (implemented as functions) + +```yaml +{{ eq .Values.env "prod" }} # == +{{ ne .Values.env "dev" }} # != +{{ lt .Values.count 10 }} # < +{{ le .Values.count 10 }} # <= +{{ gt .Values.count 5 }} # > +{{ ge .Values.count 5 }} # >= + +# eq accepts multiple args (OR) +{{ if eq .Values.env "prod" "staging" }} +``` + +### Boolean Logic + +```yaml +{{ and .Values.a .Values.b }} # Returns first empty or last arg +{{ or .Values.a .Values.b }} # Returns first non-empty or last arg +{{ not .Values.enabled }} # Boolean negation +``` + +### Output Functions + +```yaml +{{ print "hello" }} # fmt.Sprint +{{ printf "%s-%d" "app" 1 }} # fmt.Sprintf +{{ println "line" }} # fmt.Sprintln +``` + +### Other Built-ins + +```yaml +{{ len .Values.list }} # Length of string/slice/map +{{ index .Values.list 0 }} # Index access: list[0] +{{ slice .Values.list 1 3 }} # Slice: list[1:3] +{{ call .Values.fn arg }} # Call function value +``` + +## Sprig String Functions + +### Case Conversion + +```yaml +{{ upper "hello" }} # HELLO +{{ lower "HELLO" }} # hello +{{ title "hello world" }} # Hello World +{{ untitle "Hello World" }} # hello world +{{ camelcase "hello_world" }} # HelloWorld +{{ snakecase "HelloWorld" }} # hello_world +{{ kebabcase "HelloWorld" }} # hello-world +``` + +### Trimming & Padding + +```yaml +{{ trim " hello " }} # hello +{{ trimPrefix "pre-" "pre-fix"}} # fix +{{ trimSuffix "-suf" "word-suf"}}# word +{{ trimAll "$" "$5.00$" }} # 5.00 +{{ trunc 5 "hello world" }} # hello +{{ abbrev 10 "hello world" }} # hello w... +{{ nospace "h e l l o" }} # hello +``` + +### Search & Replace + +```yaml +{{ contains "cat" "catch" }} # true +{{ hasPrefix "pre" "prefix" }} # true +{{ hasSuffix "fix" "suffix" }} # true +{{ replace "o" "0" "foo" }} # f00 +{{ regexMatch "^[a-z]+$" "abc"}} # true +{{ regexReplaceAll "a(b+)" "abb" "${1}"}} # bb +``` + +### Split & Join + +```yaml +{{ split "," "a,b,c" }} # map: _0:a _1:b _2:c +{{ splitList "," "a,b,c" }} # list: [a b c] +{{ join "," (list "a" "b") }} # a,b +{{ sortAlpha (list "b" "a") }} # [a b] +``` + +### Conversion + +```yaml +{{ toString 123 }} # "123" +{{ toStrings (list 1 2) }} # ["1" "2"] +{{ atoi "123" }} # 123 (string to int) +{{ int64 "123" }} # 123 (to int64) +{{ float64 "1.5" }} # 1.5 +``` + +### Quoting + +```yaml +{{ quote .Values.name }} # "value" (with quotes) +{{ squote .Values.name }} # 'value' (single quotes) +{{ noquote .Values.name }} # value (no quotes, for template output) +``` + +## Sprig Data Structure Functions + +### Lists + +```yaml +{{ list "a" "b" "c" }} # Create list +{{ first (list 1 2 3) }} # 1 +{{ last (list 1 2 3) }} # 3 +{{ rest (list 1 2 3) }} # [2 3] +{{ initial (list 1 2 3) }} # [1 2] +{{ append (list 1 2) 3 }} # [1 2 3] +{{ prepend (list 2 3) 1 }} # [1 2 3] +{{ concat (list 1) (list 2) }} # [1 2] +{{ reverse (list 1 2 3) }} # [3 2 1] +{{ uniq (list 1 1 2) }} # [1 2] +{{ without (list 1 2 3) 2 }} # [1 3] +{{ has 2 (list 1 2 3) }} # true +{{ compact (list "" "a" "") }} # ["a"] +``` + +### Dictionaries + +```yaml +{{ dict "key1" "val1" "key2" "val2" }} # Create dict +{{ get .dict "key" }} # Get value +{{ set .dict "key" "val" }} # Set value (mutates) +{{ unset .dict "key" }} # Remove key (mutates) +{{ hasKey .dict "key" }} # Check key exists +{{ keys .dict }} # List of keys +{{ values .dict }} # List of values +{{ pluck "key" .dict1 .dict2 }} # Get "key" from multiple dicts +{{ merge .dest .src1 .src2 }} # Merge dicts (mutates dest) +{{ mergeOverwrite .dest .src }} # Merge, overwriting +{{ pick .dict "key1" "key2" }} # Dict with only listed keys +{{ omit .dict "key1" "key2" }} # Dict without listed keys +{{ deepCopy .dict }} # Deep copy +``` + +## Sprig Type Functions + +```yaml +{{ kindOf .Values.x }} # "string", "int", "map", etc. +{{ typeOf .Values.x }} # Go type +{{ kindIs "string" .Values.x }} # true if string +{{ typeIs "int" .Values.x }} # true if int type +{{ deepEqual .a .b }} # Deep equality check +``` + +## Sprig Math Functions + +```yaml +{{ add 1 2 }} # 3 +{{ sub 3 1 }} # 2 +{{ mul 2 3 }} # 6 +{{ div 6 2 }} # 3 +{{ mod 5 2 }} # 1 +{{ max 1 2 3 }} # 3 +{{ min 1 2 3 }} # 1 +{{ floor 1.9 }} # 1 +{{ ceil 1.1 }} # 2 +{{ round 1.5 0 }} # 2 +{{ add1 5 }} # 6 (increment) +``` + +## Sprig Date Functions + +```yaml +{{ now }} # Current time +{{ date "2006-01-02" now }} # Format date (Go layout) +{{ dateModify "-1h" now }} # Subtract 1 hour +{{ toDate "2006-01-02" "2024-01-15" }} # Parse date +{{ unixEpoch now }} # Unix timestamp +``` + +## Sprig Encoding Functions + +```yaml +{{ b64enc "hello" }} # Base64 encode +{{ b64dec "aGVsbG8=" }} # Base64 decode +{{ sha256sum "data" }} # SHA256 hash +{{ sha1sum "data" }} # SHA1 hash +{{ derivePassword 1 "long" "pass" "user" "example.com" }} +``` + +## Helm-Specific Functions + +### include vs template + +```yaml +# include returns string (can be piped) +{{ include "mychart.labels" . | nindent 4 }} + +# template outputs directly (cannot be piped) +{{ template "mychart.name" . }} +``` + +Always use `include` for Helm - it supports pipelines. + +### required + +```yaml +# Fail if value not provided +{{ required "image.tag is required" .Values.image.tag }} +``` + +### tpl + +```yaml +# Render string as template +# values.yaml: greeting: "Hello {{ .Release.Name }}" +{{ tpl .Values.greeting . }} +``` + +### toYaml / toJson / toToml + +```yaml +# Convert to YAML (most common) +{{- toYaml .Values.resources | nindent 12 }} + +# Convert to JSON +{{ toJson .Values.config }} + +# Convert to TOML +{{ toToml .Values.config }} + +# From YAML/JSON string +{{ fromYaml .Values.yamlString }} +{{ fromJson .Values.jsonString }} +``` + +### lookup + +```yaml +# Query cluster for existing resources +{{ $secret := lookup "v1" "Secret" "namespace" "name" }} +{{- if $secret }} +# Secret exists +{{- end }} + +# List all secrets in namespace +{{ $secrets := lookup "v1" "Secret" "namespace" "" }} + +# List all secrets in all namespaces +{{ $secrets := lookup "v1" "Secret" "" "" }} +``` + +Note: `lookup` returns empty during `helm template` (no cluster connection). + +### Indentation + +```yaml +{{ nindent 4 "text" }} # Newline + indent +{{ indent 4 "text" }} # Indent only (no newline) +``` + +## Flow Control + +### if/else + +```yaml +{{- if .Values.enabled }} +key: value +{{- else if .Values.fallback }} +key: fallback +{{- else }} +key: default +{{- end }} +``` + +False values: `false`, `0`, `nil`, empty string, empty collection. + +### with (scope change) + +```yaml +{{- with .Values.config }} +# . is now .Values.config +setting: {{ .setting }} +# Use $ for root scope +release: {{ $.Release.Name }} +{{- end }} +``` + +### range + +```yaml +# List iteration +{{- range .Values.hosts }} +- {{ . | quote }} +{{- end }} + +# With index +{{- range $index, $host := .Values.hosts }} +- {{ $index }}: {{ $host }} +{{- end }} + +# Map iteration +{{- range $key, $val := .Values.labels }} +{{ $key }}: {{ $val | quote }} +{{- end }} + +# Inline list +{{- range tuple "a" "b" "c" }} +- {{ . }} +{{- end }} +``` + +### define/template/block + +```yaml +# Define reusable template +{{- define "mychart.labels" -}} +app: {{ .name }} +{{- end -}} + +# Use with include (preferred) +{{ include "mychart.labels" (dict "name" "myapp") }} + +# Block: define + execute +{{- block "mychart.extra" . }} +# Default content (can be overridden) +{{- end }} +``` + +## Variables + +```yaml +# Declare +{{- $name := .Values.name -}} + +# Reassign +{{- $name = "newvalue" -}} + +# $ always refers to root scope +{{- range .Values.items }} +release: {{ $.Release.Name }} +{{- end }} +``` + +## Whitespace Control + +```yaml +{{- trim left whitespace }} # Chomp left +{{ trim right whitespace -}} # Chomp right +{{- both sides -}} # Chomp both + +# Common pattern +{{- if .Values.enabled }} +key: value +{{- end }} +``` + +## Pipeline Pattern + +```yaml +# Chain functions left to right +{{ .Values.name | default "app" | quote }} + +# Equivalent to +{{ quote (default "app" .Values.name) }} +``` diff --git a/.agents/skills/helm-chart/references/umbrella-charts.md b/.agents/skills/helm-chart/references/umbrella-charts.md new file mode 100644 index 0000000..5c7e123 --- /dev/null +++ b/.agents/skills/helm-chart/references/umbrella-charts.md @@ -0,0 +1,289 @@ +# Umbrella Chart Patterns + +Umbrella charts (meta-charts) bundle multiple sub-charts as dependencies to deploy complete application stacks. + +## Structure + +``` +myapp-umbrella/ +├── Chart.yaml +├── values.yaml +├── charts/ # Sub-charts (dependencies) +│ ├── frontend/ +│ ├── backend/ +│ └── database/ +└── templates/ + └── NOTES.txt # Optional: aggregated instructions +``` + +## Chart.yaml Dependencies + +```yaml +apiVersion: v2 +name: myapp +version: 1.0.0 +type: application + +dependencies: + # Local sub-chart + - name: frontend + version: "1.x.x" + repository: "file://charts/frontend" + + # Remote repository + - name: backend + version: "2.3.4" + repository: "https://charts.example.com" + + # OCI registry + - name: database + version: "5.0.0" + repository: "oci://registry.example.com/charts" + + # Conditional dependency + - name: monitoring + version: "1.0.0" + repository: "https://prometheus-community.github.io/helm-charts" + condition: monitoring.enabled + + # Alias for multiple instances + - name: redis + version: "17.0.0" + repository: "https://charts.bitnami.com/bitnami" + alias: cache + + - name: redis + version: "17.0.0" + repository: "https://charts.bitnami.com/bitnami" + alias: session +``` + +## Dependency Management + +```bash +# Download dependencies to charts/ +helm dependency update ./myapp-umbrella + +# List dependencies +helm dependency list ./myapp-umbrella + +# Build (update + package) +helm dependency build ./myapp-umbrella +``` + +## Values Propagation + +### Scoped Values (Default) + +Sub-chart values are namespaced by chart name: + +```yaml +# values.yaml +frontend: + replicaCount: 3 + image: + tag: "v2.0.0" + +backend: + replicaCount: 2 + config: + debug: false + +# Aliased chart uses alias name +cache: + auth: + enabled: false +``` + +### Global Values + +Shared across all sub-charts: + +```yaml +global: + imageRegistry: "myregistry.example.com" + imagePullSecrets: + - name: regcred + storageClass: "fast-ssd" +``` + +Access in sub-chart templates: +```yaml +image: {{ .Values.global.imageRegistry }}/{{ .Values.image.repository }} +``` + +### Import Values + +Import sub-chart exports into parent: + +```yaml +# Sub-chart (backend) Chart.yaml +exports: + - data: + apiEndpoint: "http://backend:8080" + +# Parent Chart.yaml +dependencies: + - name: backend + import-values: + - data +``` + +## Common Patterns + +### 1. Application Stack + +```yaml +# Chart.yaml +dependencies: + - name: app + version: "1.0.0" + repository: "file://charts/app" + - name: postgresql + version: "12.0.0" + repository: "https://charts.bitnami.com/bitnami" + - name: redis + version: "17.0.0" + repository: "https://charts.bitnami.com/bitnami" + +# values.yaml +app: + database: + host: "{{ .Release.Name }}-postgresql" + redis: + host: "{{ .Release.Name }}-redis-master" + +postgresql: + auth: + database: myapp + username: myapp + +redis: + auth: + enabled: false +``` + +### 2. Environment-Specific Overlays + +``` +myapp-umbrella/ +├── Chart.yaml +├── values.yaml # Base/defaults +├── values-dev.yaml # Development overrides +├── values-staging.yaml # Staging overrides +└── values-prod.yaml # Production overrides +``` + +```bash +helm install myapp ./myapp-umbrella -f values-prod.yaml +``` + +### 3. Optional Components + +```yaml +# Chart.yaml +dependencies: + - name: monitoring + condition: monitoring.enabled + - name: logging + condition: logging.enabled + tags: + - observability + +# values.yaml +monitoring: + enabled: false + +logging: + enabled: false + +tags: + observability: false # Disable all tagged deps +``` + +### 4. Cross-Chart References + +Use named templates in parent for consistency: + +```yaml +# templates/_helpers.tpl +{{- define "myapp.databaseHost" -}} +{{ .Release.Name }}-postgresql +{{- end }} + +{{- define "myapp.redisHost" -}} +{{ .Release.Name }}-redis-master +{{- end }} +``` + +Pass via global values: +```yaml +global: + database: + host: '{{ include "myapp.databaseHost" . }}' +``` + +## Best Practices + +### Do + +- **Version pin dependencies** - use exact versions or tight ranges +- **Use conditions** for optional components +- **Document sub-chart configurations** in parent values.yaml +- **Test upgrade paths** - sub-chart upgrades can break +- **Use global values** for shared config (registry, secrets) + +### Don't + +- **Don't nest too deep** - max 2-3 levels +- **Don't duplicate values** - use globals or imports +- **Don't bypass sub-chart values** - respect their interface +- **Don't mix local and remote** without reason + +## Upgrading Dependencies + +```bash +# Check for updates +helm dependency list ./myapp-umbrella + +# Update Chart.yaml versions, then: +helm dependency update ./myapp-umbrella + +# Test before deploy +helm upgrade --install --dry-run myapp ./myapp-umbrella +``` + +## Troubleshooting + +### Dependency not found + +```bash +# Ensure repo is added +helm repo add bitnami https://charts.bitnami.com/bitnami +helm repo update + +# Re-download +helm dependency update ./myapp-umbrella +``` + +### Values not propagating + +Check namespace: +```yaml +# Wrong: top-level +replicaCount: 3 + +# Correct: scoped to sub-chart +frontend: + replicaCount: 3 +``` + +### Conflicting resource names + +Use fullnameOverride per sub-chart: +```yaml +frontend: + fullnameOverride: "myapp-frontend" +backend: + fullnameOverride: "myapp-backend" +``` diff --git a/.agents/skills/helm-chart/references/validation.md b/.agents/skills/helm-chart/references/validation.md new file mode 100644 index 0000000..7949af0 --- /dev/null +++ b/.agents/skills/helm-chart/references/validation.md @@ -0,0 +1,427 @@ +# Validation & Quality Assurance + +## Helm Debugging + +### Basic Commands + +```bash +# Lint: verify chart follows best practices +helm lint ./mychart +helm lint ./mychart --strict # Treat warnings as errors + +# Template locally (no cluster needed) +helm template ./mychart +helm template ./mychart --debug # Show computed values +helm template ./mychart -f custom.yaml # With custom values + +# Dry-run against cluster (checks conflicts, runs lookups) +helm install --dry-run --debug myrelease ./mychart +helm install --dry-run=server myrelease ./mychart # Server-side validation + lookup + +# Inspect installed release +helm get manifest myrelease +helm get values myrelease +``` + +### Debugging Broken YAML + +Comment out problematic sections to isolate issues: +```yaml +apiVersion: v2 +# some: problem section +# {{ .Values.foo | quote }} +``` + +Re-run `helm template --debug` - comments render with values resolved. + +--- + +## kubeconform + +**Purpose**: Fast Kubernetes manifest validation against JSON schemas. Supports CRDs. + +### Installation + +```bash +brew install kubeconform +# or +go install github.com/yannh/kubeconform/cmd/kubeconform@latest +``` + +### Helm Integration + +```bash +# Validate rendered templates +helm template ./mychart | kubeconform -strict -summary + +# Specify Kubernetes version +helm template ./mychart | kubeconform -kubernetes-version 1.29.0 -strict + +# Multiple workers for speed +helm template ./mychart | kubeconform -n 8 -summary + +# JSON output for CI +helm template ./mychart | kubeconform -output json +``` + +### CRD Support + +```bash +# Use Datree's CRD catalog for common CRDs +helm template ./mychart | kubeconform \ + -schema-location default \ + -schema-location 'https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json' + +# Local CRD schemas +kubeconform -schema-location 'schemas/{{ .ResourceKind }}{{ .KindSuffix }}.json' manifest.yaml +``` + +### Key Options + +| Flag | Purpose | +|------|---------| +| `-strict` | Disallow additional properties | +| `-ignore-missing-schemas` | Skip unknown CRDs instead of failing | +| `-summary` | Print validation summary | +| `-kubernetes-version` | Target K8s version (default: master) | +| `-n` | Parallel workers (default: 4) | +| `-output` | json, junit, tap, text (default: text) | + +--- + +## Trivy + +**Purpose**: Security scanner for misconfigurations, vulnerabilities, secrets. + +### Installation + +```bash +brew install trivy +# or +docker run aquasec/trivy +``` + +### Helm Chart Scanning + +```bash +# Scan chart directory for misconfigs +trivy config ./mychart + +# Scan rendered manifests +helm template ./mychart > manifests.yaml +trivy config manifests.yaml + +# Multiple scanners +trivy fs --scanners vuln,secret,misconfig ./mychart + +# JSON output for CI +trivy config --format json --output results.json ./mychart +``` + +### Common Findings + +Trivy checks for: + +- **Misconfigurations**: Running as root, missing resource limits, privilege escalation +- **Secrets**: Hardcoded passwords, API keys in templates +- **Known CVEs**: If scanning container images referenced in values + +### Severity Filtering + +```bash +# Only critical/high issues +trivy config --severity CRITICAL,HIGH ./mychart + +# Exit code on findings (for CI) +trivy config --exit-code 1 ./mychart +``` + +--- + +## Pluto + +**Purpose**: Detect deprecated/removed Kubernetes API versions. + +### Installation + +```bash +brew install FairwindsOps/tap/pluto +# or +go install github.com/FairwindsOps/pluto/v5/cmd/pluto@latest +``` + +### Helm Integration + +```bash +# Scan rendered templates +helm template ./mychart | pluto detect - + +# Scan chart directory +pluto detect-files -d ./mychart/templates + +# Target specific K8s version +helm template ./mychart | pluto detect --target-versions k8s=v1.29.0 - + +# Check installed Helm releases +pluto detect-helm --helm-version 3 +``` + +### Output Format + +```bash +# Detailed output +pluto detect-files -d ./mychart -o wide + +# JSON for CI +pluto detect-files -d ./mychart -o json + +# Markdown for docs +pluto detect-files -d ./mychart -o markdown +``` + +### Exit Codes + +| Code | Meaning | +|------|---------| +| 0 | No deprecated/removed APIs | +| 1 | Error during execution | +| 2 | Deprecated APIs found | +| 3 | Removed APIs found | + +--- + +## Complete Validation Pipeline + +```bash +#!/bin/bash +set -e + +CHART_DIR="${1:-.}" +K8S_VERSION="${2:-1.29.0}" + +echo "=== Helm Lint ===" +helm lint "$CHART_DIR" --strict + +echo "=== Template Generation ===" +helm template "$CHART_DIR" > /tmp/manifests.yaml + +echo "=== Schema Validation (kubeconform) ===" +kubeconform -strict -summary \ + -kubernetes-version "$K8S_VERSION" \ + -schema-location default \ + -schema-location 'https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json' \ + /tmp/manifests.yaml + +echo "=== Deprecated API Check (pluto) ===" +pluto detect --target-versions "k8s=v$K8S_VERSION" - < /tmp/manifests.yaml + +echo "=== Security Scan (trivy) ===" +trivy config --severity HIGH,CRITICAL --exit-code 1 /tmp/manifests.yaml + +echo "=== All validations passed ===" +``` + +--- + +## Chart Testing (ct) + +**Purpose**: Official Helm tool for linting and testing charts in monorepos. Auto-detects changed charts, enforces SemVer. + +### Installation + +```bash +# Binary +wget https://github.com/helm/chart-testing/releases/latest/download/chart-testing_linux_amd64.tar.gz +tar xzf chart-testing_linux_amd64.tar.gz + +# Docker +docker pull quay.io/helmpack/chart-testing +``` + +### Commands + +```bash +# Lint changed charts against target branch +ct lint --target-branch main + +# Lint specific charts +ct lint --charts ./charts/myapp + +# Lint all charts +ct lint --all + +# Install and test in cluster +ct install --target-branch main + +# Lint + install +ct lint-and-install --target-branch main + +# List changed charts +ct list-changed --target-branch main +``` + +### Configuration (ct.yaml) + +```yaml +# ct.yaml +chart-dirs: + - charts + +chart-repos: + - bitnami=https://charts.bitnami.com/bitnami + +target-branch: main +remote: origin + +helm-extra-args: --timeout 600s + +# Validate Chart.yaml schema +validate-chart-schema: true +validate-maintainers: true +``` + +### CI Values Files + +Place test values in `ci/` directory: +``` +mychart/ +├── Chart.yaml +├── values.yaml +└── ci/ + ├── test-values.yaml # Tested automatically + └── prod-values.yaml # Also tested +``` + +### Upgrade Testing + +```bash +# Test upgrade from previous version (SemVer-aware) +ct install --upgrade --target-branch main +``` + +Only runs upgrade test if version change is non-breaking (patch/minor). + +--- + +## Helm Test Hooks + +Built-in chart testing via test pods: + +```yaml +# templates/tests/test-connection.yaml +apiVersion: v1 +kind: Pod +metadata: + name: "{{ include "mychart.fullname" . }}-test-connection" + labels: + {{- include "mychart.labels" . | nindent 4 }} + annotations: + "helm.sh/hook": test +spec: + containers: + - name: wget + image: busybox + command: ['wget'] + args: ['{{ include "mychart.fullname" . }}:{{ .Values.service.port }}'] + restartPolicy: Never +``` + +```bash +# Run after install +helm test myrelease + +# Run with timeout +helm test myrelease --timeout 5m +``` + +--- + +## CI Integration Examples + +### GitHub Actions (Full ct Workflow) + +```yaml +name: Lint and Test Charts +on: pull_request + +jobs: + lint-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: azure/setup-helm@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - uses: helm/chart-testing-action@v2.8.0 + + - name: List changed charts + id: list-changed + run: | + changed=$(ct list-changed --target-branch ${{ github.event.repository.default_branch }}) + if [[ -n "$changed" ]]; then + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Lint charts + if: steps.list-changed.outputs.changed == 'true' + run: ct lint --target-branch ${{ github.event.repository.default_branch }} + + - name: Create kind cluster + if: steps.list-changed.outputs.changed == 'true' + uses: helm/kind-action@v1.12.0 + + - name: Install and test charts + if: steps.list-changed.outputs.changed == 'true' + run: ct install --target-branch ${{ github.event.repository.default_branch }} +``` + +### GitHub Actions (Quick Validation) + +```yaml +- name: Validate Helm Chart + run: | + helm template ./mychart > manifests.yaml + kubeconform -strict -summary manifests.yaml + pluto detect - < manifests.yaml + trivy config --exit-code 1 manifests.yaml +``` + +### GitLab CI + +```yaml +stages: + - lint + - test + +lint-charts: + stage: lint + image: quay.io/helmpack/chart-testing:latest + script: + - ct lint --all --chart-dirs charts/ + +test-charts: + stage: test + image: quay.io/helmpack/chart-testing:latest + services: + - docker:dind + before_script: + - kind create cluster + script: + - ct install --all --chart-dirs charts/ +``` + +### Local Development + +```bash +# Quick iteration +helm lint ./mychart && helm template ./mychart | kubeconform -strict + +# Full local test (requires cluster) +ct lint-and-install --charts ./mychart +``` diff --git a/.agents/skills/helm-chart/references/values-patterns.md b/.agents/skills/helm-chart/references/values-patterns.md new file mode 100644 index 0000000..decd4c5 --- /dev/null +++ b/.agents/skills/helm-chart/references/values-patterns.md @@ -0,0 +1,538 @@ +# values.yaml Patterns + +Industry-standard patterns from Bitnami, Grafana, ArgoCD, and official Helm charts. + +## Core Principles + +1. **Subtrees for complex objects** - Use nested structures for K8s API objects that users will `toYaml` +2. **Empty defaults `{}`** - Let users override entire sections, show examples in comments +3. **Flat for simple values** - Single values like `replicaCount` stay top-level +4. **Consistent naming** - camelCase throughout, match K8s API field names + +--- + +## Image Configuration + +**Always use subtree** - allows registry, repository, tag, and policy to be set independently: + +```yaml +image: + registry: docker.io + repository: nginx + tag: "" # Defaults to Chart.appVersion + # digest: "" # Overrides tag if set + pullPolicy: IfNotPresent + +imagePullSecrets: [] +# - name: regcred +``` + +**Template usage:** +```yaml +image: "{{ .Values.image.registry }}/{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" +imagePullPolicy: {{ .Values.image.pullPolicy }} +``` + +--- + +## Resources + +**Empty default with commented examples** - production users will set their own: + +```yaml +resources: {} + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi +``` + +**Template usage:** +```yaml +resources: + {{- toYaml .Values.resources | nindent 12 }} +``` + +**Alternative: Bitnami style with resourcesPreset:** +```yaml +resourcesPreset: "small" # none, nano, micro, small, medium, large, xlarge, 2xlarge +resources: {} # Overrides preset if set +``` + +--- + +## Security Contexts + +### Simple Style (helm create default) + +Empty objects, users provide full context: + +```yaml +podSecurityContext: {} + # fsGroup: 2000 + +securityContext: {} + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + # runAsUser: 1000 +``` + +### Bitnami Style (Production) + +Enabled flag + explicit fields for OpenShift compatibility: + +```yaml +podSecurityContext: + enabled: true + fsGroupChangePolicy: Always + sysctls: [] + supplementalGroups: [] + fsGroup: 1001 + +containerSecurityContext: + enabled: true + seLinuxOptions: {} + runAsUser: 1001 + runAsGroup: 1001 + runAsNonRoot: true + privileged: false + readOnlyRootFilesystem: true + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + seccompProfile: + type: RuntimeDefault +``` + +**Template (Bitnami style):** +```yaml +{{- if .Values.podSecurityContext.enabled }} +securityContext: {{- omit .Values.podSecurityContext "enabled" | toYaml | nindent 8 }} +{{- end }} +``` + +--- + +## Service + +```yaml +service: + type: ClusterIP + port: 80 + # nodePort: 30080 # Only for NodePort/LoadBalancer + # clusterIP: "" # Set to None for headless + # loadBalancerIP: "" + # loadBalancerSourceRanges: [] + # externalTrafficPolicy: Cluster + annotations: {} + labels: {} +``` + +--- + +## Ingress + +```yaml +ingress: + enabled: false + className: "" # nginx, traefik, etc. + annotations: {} + # kubernetes.io/ingress.class: nginx + # cert-manager.io/cluster-issuer: letsencrypt + hosts: + - host: chart-example.local + paths: + - path: / + pathType: ImplementationSpecific + tls: [] + # - secretName: chart-example-tls + # hosts: + # - chart-example.local +``` + +--- + +## ServiceAccount + +```yaml +serviceAccount: + create: true + automount: true + annotations: {} + name: "" # Defaults to fullname if create=true +``` + +--- + +## Autoscaling (HPA) + +```yaml +autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 100 + targetCPUUtilizationPercentage: 80 + # targetMemoryUtilizationPercentage: 80 + # behavior: {} +``` + +**Template:** +```yaml +{{- if .Values.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +# ... +{{- end }} +``` + +--- + +## Probes + +### Simple Style + +```yaml +livenessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 1 + failureThreshold: 3 + successThreshold: 1 + +readinessProbe: + httpGet: + path: /ready + port: http + initialDelaySeconds: 5 + periodSeconds: 10 +``` + +### Flexible Style (Bitnami) + +Allow users to define custom probe or use defaults: + +```yaml +livenessProbe: + enabled: true + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 1 + failureThreshold: 3 + successThreshold: 1 + +customLivenessProbe: {} +# httpGet: +# path: /custom +# port: http +``` + +--- + +## Persistence + +```yaml +persistence: + enabled: false + storageClass: "" # "" = default, "-" = no class + accessModes: + - ReadWriteOnce + size: 8Gi + annotations: {} + # existingClaim: "" # Use existing PVC + # selector: {} +``` + +--- + +## RBAC + +```yaml +rbac: + create: true + rules: [] + # - apiGroups: [""] + # resources: ["pods"] + # verbs: ["get", "list", "watch"] + +serviceAccount: + create: true + name: "" +``` + +--- + +## Scheduling + +```yaml +nodeSelector: {} + +tolerations: [] +# - key: "key" +# operator: "Equal" +# value: "value" +# effect: "NoSchedule" + +affinity: {} +# nodeAffinity: +# requiredDuringSchedulingIgnoredDuringExecution: +# nodeSelectorTerms: [...] + +topologySpreadConstraints: [] +``` + +--- + +## Pod Configuration + +```yaml +replicaCount: 1 + +podAnnotations: {} +podLabels: {} + +# Update strategy +updateStrategy: + type: RollingUpdate + # rollingUpdate: + # maxUnavailable: 1 + # maxSurge: 1 + +# Lifecycle +terminationGracePeriodSeconds: 30 + +# Priority +priorityClassName: "" + +# DNS +dnsPolicy: ClusterFirst +dnsConfig: {} + +# Host settings +hostNetwork: false +hostPID: false +hostIPC: false +``` + +--- + +## Environment Variables + +```yaml +# Direct env vars +env: [] +# - name: FOO +# value: "bar" +# - name: SECRET +# valueFrom: +# secretKeyRef: +# name: mysecret +# key: password + +# From ConfigMaps/Secrets +envFrom: [] +# - configMapRef: +# name: config-map-name +# - secretRef: +# name: secret-name +``` + +--- + +## Extra/Custom Resources + +Allow injection of arbitrary content: + +```yaml +# Extra volumes +extraVolumes: [] +# - name: extra-volume +# configMap: +# name: my-config + +extraVolumeMounts: [] +# - name: extra-volume +# mountPath: /etc/extra + +# Extra containers (sidecars) +extraContainers: [] + +# Extra init containers +extraInitContainers: [] + +# Extra env vars +extraEnvVars: [] +``` + +--- + +## Metrics/Monitoring + +```yaml +metrics: + enabled: false + + service: + port: 9090 + annotations: {} + + serviceMonitor: + enabled: false + namespace: "" + interval: 30s + scrapeTimeout: 10s + labels: {} + selector: {} + relabelings: [] + metricRelabelings: [] + + prometheusRule: + enabled: false + namespace: "" + rules: [] +``` + +--- + +## Complete Example + +```yaml +# Deployment settings +replicaCount: 1 +revisionHistoryLimit: 3 + +# Image +image: + registry: docker.io + repository: nginx + tag: "" + pullPolicy: IfNotPresent + +imagePullSecrets: [] + +# Naming +nameOverride: "" +fullnameOverride: "" + +# Service Account +serviceAccount: + create: true + automount: true + annotations: {} + name: "" + +# Pod settings +podAnnotations: {} +podLabels: {} + +podSecurityContext: {} + # fsGroup: 2000 + +securityContext: {} + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + # runAsUser: 1000 + +# Service +service: + type: ClusterIP + port: 80 + +# Ingress +ingress: + enabled: false + className: "" + annotations: {} + hosts: + - host: chart-example.local + paths: + - path: / + pathType: ImplementationSpecific + tls: [] + +# Resources +resources: {} + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + +# Probes +livenessProbe: + httpGet: + path: / + port: http +readinessProbe: + httpGet: + path: / + port: http + +# Autoscaling +autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 100 + targetCPUUtilizationPercentage: 80 + +# Scheduling +nodeSelector: {} +tolerations: [] +affinity: {} +``` + +--- + +## Anti-Patterns + +### DON'T: Flat structure for complex objects + +```yaml +# Bad - hard to override, loses structure +resourceLimitsCpu: 100m +resourceLimitsMemory: 128Mi +resourceRequestsCpu: 50m +``` + +### DON'T: Hardcoded values users can't disable + +```yaml +# Bad - users can't remove securityContext +securityContext: + runAsNonRoot: true # No way to disable +``` + +### DON'T: Inconsistent nesting + +```yaml +# Bad - mixing patterns +image: nginx:latest # Flat +service: # Nested + port: 80 +ingressEnabled: false # Flat again +``` + +### DO: Consistent, overridable structure + +```yaml +# Good - consistent nesting, empty defaults +image: + repository: nginx + tag: latest +service: + port: 80 +ingress: + enabled: false +securityContext: {} # User provides if needed +``` diff --git a/.agents/skills/improve-codebase-architecture/DEEPENING.md b/.agents/skills/improve-codebase-architecture/DEEPENING.md new file mode 100644 index 0000000..ecaf5d7 --- /dev/null +++ b/.agents/skills/improve-codebase-architecture/DEEPENING.md @@ -0,0 +1,37 @@ +# Deepening + +How to deepen a cluster of shallow modules safely, given its dependencies. Assumes the vocabulary in [LANGUAGE.md](LANGUAGE.md) — **module**, **interface**, **seam**, **adapter**. + +## Dependency categories + +When assessing a candidate for deepening, classify its dependencies. The category determines how the deepened module is tested across its seam. + +### 1. In-process + +Pure computation, in-memory state, no I/O. Always deepenable — merge the modules and test through the new interface directly. No adapter needed. + +### 2. Local-substitutable + +Dependencies that have local test stand-ins (PGLite for Postgres, in-memory filesystem). Deepenable if the stand-in exists. The deepened module is tested with the stand-in running in the test suite. The seam is internal; no port at the module's external interface. + +### 3. Remote but owned (Ports & Adapters) + +Your own services across a network boundary (microservices, internal APIs). Define a **port** (interface) at the seam. The deep module owns the logic; the transport is injected as an **adapter**. Tests use an in-memory adapter. Production uses an HTTP/gRPC/queue adapter. + +Recommendation shape: *"Define a port at the seam, implement an HTTP adapter for production and an in-memory adapter for testing, so the logic sits in one deep module even though it's deployed across a network."* + +### 4. True external (Mock) + +Third-party services (Stripe, Twilio, etc.) you don't control. The deepened module takes the external dependency as an injected port; tests provide a mock adapter. + +## Seam discipline + +- **One adapter means a hypothetical seam. Two adapters means a real one.** Don't introduce a port unless at least two adapters are justified (typically production + test). A single-adapter seam is just indirection. +- **Internal seams vs external seams.** A deep module can have internal seams (private to its implementation, used by its own tests) as well as the external seam at its interface. Don't expose internal seams through the interface just because tests use them. + +## Testing strategy: replace, don't layer + +- Old unit tests on shallow modules become waste once tests at the deepened module's interface exist — delete them. +- Write new tests at the deepened module's interface. The **interface is the test surface**. +- Tests assert on observable outcomes through the interface, not internal state. +- Tests should survive internal refactors — they describe behaviour, not implementation. If a test has to change when the implementation changes, it's testing past the interface. diff --git a/.agents/skills/improve-codebase-architecture/INTERFACE-DESIGN.md b/.agents/skills/improve-codebase-architecture/INTERFACE-DESIGN.md new file mode 100644 index 0000000..3197723 --- /dev/null +++ b/.agents/skills/improve-codebase-architecture/INTERFACE-DESIGN.md @@ -0,0 +1,44 @@ +# Interface Design + +When the user wants to explore alternative interfaces for a chosen deepening candidate, use this parallel sub-agent pattern. Based on "Design It Twice" (Ousterhout) — your first idea is unlikely to be the best. + +Uses the vocabulary in [LANGUAGE.md](LANGUAGE.md) — **module**, **interface**, **seam**, **adapter**, **leverage**. + +## Process + +### 1. Frame the problem space + +Before spawning sub-agents, write a user-facing explanation of the problem space for the chosen candidate: + +- The constraints any new interface would need to satisfy +- The dependencies it would rely on, and which category they fall into (see [DEEPENING.md](DEEPENING.md)) +- A rough illustrative code sketch to ground the constraints — not a proposal, just a way to make the constraints concrete + +Show this to the user, then immediately proceed to Step 2. The user reads and thinks while the sub-agents work in parallel. + +### 2. Spawn sub-agents + +Spawn 3+ sub-agents in parallel using the Agent tool. Each must produce a **radically different** interface for the deepened module. + +Prompt each sub-agent with a separate technical brief (file paths, coupling details, dependency category from [DEEPENING.md](DEEPENING.md), what sits behind the seam). The brief is independent of the user-facing problem-space explanation in Step 1. Give each agent a different design constraint: + +- Agent 1: "Minimize the interface — aim for 1–3 entry points max. Maximise leverage per entry point." +- Agent 2: "Maximise flexibility — support many use cases and extension." +- Agent 3: "Optimise for the most common caller — make the default case trivial." +- Agent 4 (if applicable): "Design around ports & adapters for cross-seam dependencies." + +Include both [LANGUAGE.md](LANGUAGE.md) vocabulary and CONTEXT.md vocabulary in the brief so each sub-agent names things consistently with the architecture language and the project's domain language. + +Each sub-agent outputs: + +1. Interface (types, methods, params — plus invariants, ordering, error modes) +2. Usage example showing how callers use it +3. What the implementation hides behind the seam +4. Dependency strategy and adapters (see [DEEPENING.md](DEEPENING.md)) +5. Trade-offs — where leverage is high, where it's thin + +### 3. Present and compare + +Present designs sequentially so the user can absorb each one, then compare them in prose. Contrast by **depth** (leverage at the interface), **locality** (where change concentrates), and **seam placement**. + +After comparing, give your own recommendation: which design you think is strongest and why. If elements from different designs would combine well, propose a hybrid. Be opinionated — the user wants a strong read, not a menu. diff --git a/.agents/skills/improve-codebase-architecture/LANGUAGE.md b/.agents/skills/improve-codebase-architecture/LANGUAGE.md new file mode 100644 index 0000000..530c276 --- /dev/null +++ b/.agents/skills/improve-codebase-architecture/LANGUAGE.md @@ -0,0 +1,53 @@ +# Language + +Shared vocabulary for every suggestion this skill makes. Use these terms exactly — don't substitute "component," "service," "API," or "boundary." Consistent language is the whole point. + +## Terms + +**Module** +Anything with an interface and an implementation. Deliberately scale-agnostic — applies equally to a function, class, package, or tier-spanning slice. +_Avoid_: unit, component, service. + +**Interface** +Everything a caller must know to use the module correctly. Includes the type signature, but also invariants, ordering constraints, error modes, required configuration, and performance characteristics. +_Avoid_: API, signature (too narrow — those refer only to the type-level surface). + +**Implementation** +What's inside a module — its body of code. Distinct from **Adapter**: a thing can be a small adapter with a large implementation (a Postgres repo) or a large adapter with a small implementation (an in-memory fake). Reach for "adapter" when the seam is the topic; "implementation" otherwise. + +**Depth** +Leverage at the interface — the amount of behaviour a caller (or test) can exercise per unit of interface they have to learn. A module is **deep** when a large amount of behaviour sits behind a small interface. A module is **shallow** when the interface is nearly as complex as the implementation. + +**Seam** _(from Michael Feathers)_ +A place where you can alter behaviour without editing in that place. The *location* at which a module's interface lives. Choosing where to put the seam is its own design decision, distinct from what goes behind it. +_Avoid_: boundary (overloaded with DDD's bounded context). + +**Adapter** +A concrete thing that satisfies an interface at a seam. Describes *role* (what slot it fills), not substance (what's inside). + +**Leverage** +What callers get from depth. More capability per unit of interface they have to learn. One implementation pays back across N call sites and M tests. + +**Locality** +What maintainers get from depth. Change, bugs, knowledge, and verification concentrate at one place rather than spreading across callers. Fix once, fixed everywhere. + +## Principles + +- **Depth is a property of the interface, not the implementation.** A deep module can be internally composed of small, mockable, swappable parts — they just aren't part of the interface. A module can have **internal seams** (private to its implementation, used by its own tests) as well as the **external seam** at its interface. +- **The deletion test.** Imagine deleting the module. If complexity vanishes, the module wasn't hiding anything (it was a pass-through). If complexity reappears across N callers, the module was earning its keep. +- **The interface is the test surface.** Callers and tests cross the same seam. If you want to test *past* the interface, the module is probably the wrong shape. +- **One adapter means a hypothetical seam. Two adapters means a real one.** Don't introduce a seam unless something actually varies across it. + +## Relationships + +- A **Module** has exactly one **Interface** (the surface it presents to callers and tests). +- **Depth** is a property of a **Module**, measured against its **Interface**. +- A **Seam** is where a **Module**'s **Interface** lives. +- An **Adapter** sits at a **Seam** and satisfies the **Interface**. +- **Depth** produces **Leverage** for callers and **Locality** for maintainers. + +## Rejected framings + +- **Depth as ratio of implementation-lines to interface-lines** (Ousterhout): rewards padding the implementation. We use depth-as-leverage instead. +- **"Interface" as the TypeScript `interface` keyword or a class's public methods**: too narrow — interface here includes every fact a caller must know. +- **"Boundary"**: overloaded with DDD's bounded context. Say **seam** or **interface**. diff --git a/.agents/skills/improve-codebase-architecture/SKILL.md b/.agents/skills/improve-codebase-architecture/SKILL.md new file mode 100644 index 0000000..05984a6 --- /dev/null +++ b/.agents/skills/improve-codebase-architecture/SKILL.md @@ -0,0 +1,71 @@ +--- +name: improve-codebase-architecture +description: Find deepening opportunities in a codebase, informed by the domain language in CONTEXT.md and the decisions in docs/adr/. Use when the user wants to improve architecture, find refactoring opportunities, consolidate tightly-coupled modules, or make a codebase more testable and AI-navigable. +--- + +# Improve Codebase Architecture + +Surface architectural friction and propose **deepening opportunities** — refactors that turn shallow modules into deep ones. The aim is testability and AI-navigability. + +## Glossary + +Use these terms exactly in every suggestion. Consistent language is the point — don't drift into "component," "service," "API," or "boundary." Full definitions in [LANGUAGE.md](LANGUAGE.md). + +- **Module** — anything with an interface and an implementation (function, class, package, slice). +- **Interface** — everything a caller must know to use the module: types, invariants, error modes, ordering, config. Not just the type signature. +- **Implementation** — the code inside. +- **Depth** — leverage at the interface: a lot of behaviour behind a small interface. **Deep** = high leverage. **Shallow** = interface nearly as complex as the implementation. +- **Seam** — where an interface lives; a place behaviour can be altered without editing in place. (Use this, not "boundary.") +- **Adapter** — a concrete thing satisfying an interface at a seam. +- **Leverage** — what callers get from depth. +- **Locality** — what maintainers get from depth: change, bugs, knowledge concentrated in one place. + +Key principles (see [LANGUAGE.md](LANGUAGE.md) for the full list): + +- **Deletion test**: imagine deleting the module. If complexity vanishes, it was a pass-through. If complexity reappears across N callers, it was earning its keep. +- **The interface is the test surface.** +- **One adapter = hypothetical seam. Two adapters = real seam.** + +This skill is _informed_ by the project's domain model. The domain language gives names to good seams; ADRs record decisions the skill should not re-litigate. + +## Process + +### 1. Explore + +Read the project's domain glossary and any ADRs in the area you're touching first. + +Then use the Agent tool with `subagent_type=Explore` to walk the codebase. Don't follow rigid heuristics — explore organically and note where you experience friction: + +- Where does understanding one concept require bouncing between many small modules? +- Where are modules **shallow** — interface nearly as complex as the implementation? +- Where have pure functions been extracted just for testability, but the real bugs hide in how they're called (no **locality**)? +- Where do tightly-coupled modules leak across their seams? +- Which parts of the codebase are untested, or hard to test through their current interface? + +Apply the **deletion test** to anything you suspect is shallow: would deleting it concentrate complexity, or just move it? A "yes, concentrates" is the signal you want. + +### 2. Present candidates + +Present a numbered list of deepening opportunities. For each candidate: + +- **Files** — which files/modules are involved +- **Problem** — why the current architecture is causing friction +- **Solution** — plain English description of what would change +- **Benefits** — explained in terms of locality and leverage, and also in how tests would improve + +**Use CONTEXT.md vocabulary for the domain, and [LANGUAGE.md](LANGUAGE.md) vocabulary for the architecture.** If `CONTEXT.md` defines "Order," talk about "the Order intake module" — not "the FooBarHandler," and not "the Order service." + +**ADR conflicts**: if a candidate contradicts an existing ADR, only surface it when the friction is real enough to warrant revisiting the ADR. Mark it clearly (e.g. _"contradicts ADR-0007 — but worth reopening because…"_). Don't list every theoretical refactor an ADR forbids. + +Do NOT propose interfaces yet. Ask the user: "Which of these would you like to explore?" + +### 3. Grilling loop + +Once the user picks a candidate, drop into a grilling conversation. Walk the design tree with them — constraints, dependencies, the shape of the deepened module, what sits behind the seam, what tests survive. + +Side effects happen inline as decisions crystallize: + +- **Naming a deepened module after a concept not in `CONTEXT.md`?** Add the term to `CONTEXT.md` — same discipline as `/grill-with-docs` (see [CONTEXT-FORMAT.md](../grill-with-docs/CONTEXT-FORMAT.md)). Create the file lazily if it doesn't exist. +- **Sharpening a fuzzy term during the conversation?** Update `CONTEXT.md` right there. +- **User rejects the candidate with a load-bearing reason?** Offer an ADR, framed as: _"Want me to record this as an ADR so future architecture reviews don't re-suggest it?"_ Only offer when the reason would actually be needed by a future explorer to avoid re-suggesting the same thing — skip ephemeral reasons ("not worth it right now") and self-evident ones. See [ADR-FORMAT.md](../grill-with-docs/ADR-FORMAT.md). +- **Want to explore alternative interfaces for the deepened module?** See [INTERFACE-DESIGN.md](INTERFACE-DESIGN.md). diff --git a/.agents/skills/neovim-pr-review/SKILL.md b/.agents/skills/neovim-pr-review/SKILL.md new file mode 100644 index 0000000..c7a1c16 --- /dev/null +++ b/.agents/skills/neovim-pr-review/SKILL.md @@ -0,0 +1,60 @@ +--- +name: neovim-pr-review +description: Prepare GitHub pull requests for local Neovim code review by checking out the PR, uncommitting its changes, and leaving all PR edits unstaged in the working tree. Use when the user provides a GitHub PR URL or PR number and wants to review highlighted file changes in Neovim, inspect a raw PR patch/diff, or turn a PR into local unstaged changes without committing anything. +--- + +# Neovim PR Review + +## Workflow + +Use the helper script for checkout/reset operations instead of rewriting the git sequence manually. It has guardrails for dirty worktrees, PR URL parsing, base branch fetching, and branch collisions. + +From inside the target repository, run: + +```bash +python ~/.agents/skills/neovim-pr-review/scripts/prepare_pr_review.py +``` + +The script will: + +1. Require a clean worktree before doing anything. +2. Resolve PR metadata with `gh pr view`. +3. Fetch `refs/pull//head` and the PR base branch from the matching GitHub remote. +4. Create a new local review branch from the PR head. +5. Reset that branch back to the merge-base with `git reset --mixed`, leaving the PR changes unstaged. + +After it finishes, open Neovim normally: + +```bash +nvim . +``` + +Git signs, file explorers, and `git status` integrations should now show the PR's files as ordinary unstaged changes. + +## Raw Patch + +To get the entire PR as a raw patch: + +```bash +gh pr diff 109 -R container-registry/8gcr --patch > pr-109.patch +``` + +For public repositories, appending `.patch` or `.diff` to the PR URL also works: + +```bash +curl -L https://github.com/container-registry/8gcr/pull/109.patch > pr-109.patch +curl -L https://github.com/container-registry/8gcr/pull/109.diff > pr-109.diff +``` + +Prefer `gh pr diff` for private repositories because it uses the existing GitHub CLI authentication. + +## Safety Rules + +- Do not run the checkout/reset workflow with local edits present; stash or commit them first. +- Do not use `git reset --hard` for this workflow. +- Do not force-update an existing review branch unless the user explicitly asks. +- If the repository remote does not match the PR's base repository, stop and ask which remote to use. + +## Helper + +The bundled helper is `scripts/prepare_pr_review.py`. Read or patch it only when the user needs different behavior, such as a custom branch name or support for a non-GitHub host. diff --git a/.agents/skills/neovim-pr-review/scripts/prepare_pr_review.py b/.agents/skills/neovim-pr-review/scripts/prepare_pr_review.py new file mode 100755 index 0000000..2e30900 --- /dev/null +++ b/.agents/skills/neovim-pr-review/scripts/prepare_pr_review.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +"""Prepare a GitHub PR as unstaged working-tree changes for Neovim review.""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +import time +from pathlib import Path +from urllib.parse import urlparse + + +def run(args: list[str], cwd: Path | None = None, check: bool = True) -> str: + proc = subprocess.run( + args, + cwd=cwd, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if check and proc.returncode != 0: + message = [f"command failed: {' '.join(args)}"] + if proc.stdout.strip(): + message.append(proc.stdout.strip()) + if proc.stderr.strip(): + message.append(proc.stderr.strip()) + raise SystemExit("\n".join(message)) + return proc.stdout.strip() + + +def parse_pr(value: str, repo: str | None) -> tuple[str, str, str]: + value = value.strip() + parsed = urlparse(value) + if parsed.netloc.endswith("github.com"): + parts = [part for part in parsed.path.split("/") if part] + if len(parts) >= 4 and parts[2] == "pull" and parts[3].isdigit(): + return parts[0], parts[1], parts[3] + + match = re.fullmatch(r"([^/\s]+)/([^#\s]+)#(\d+)", value) + if match: + return match.group(1), match.group(2), match.group(3) + + if value.isdigit() and repo: + owner, name = repo.split("/", 1) + return owner, name, value + + raise SystemExit( + "pass a GitHub PR URL, owner/repo#number, or --repo owner/repo with a PR number" + ) + + +def normalize_github_remote(url: str) -> str | None: + patterns = [ + r"github\.com[:/]([^/]+)/([^/]+?)(?:\.git)?$", + r"https://github\.com/([^/]+)/([^/]+?)(?:\.git)?$", + r"ssh://git@github\.com/([^/]+)/([^/]+?)(?:\.git)?$", + ] + for pattern in patterns: + match = re.search(pattern, url) + if match: + return f"{match.group(1)}/{match.group(2)}".lower() + return None + + +def find_remote(root: Path, owner: str, repo: str) -> str: + target = f"{owner}/{repo}".lower() + remotes = run(["git", "remote"], cwd=root).splitlines() + matches: list[str] = [] + seen: list[str] = [] + for remote in remotes: + url = run(["git", "remote", "get-url", remote], cwd=root, check=False) + normalized = normalize_github_remote(url) + if normalized: + seen.append(f"{remote}={normalized}") + if normalized == target: + matches.append(remote) + + if len(matches) == 1: + return matches[0] + if len(matches) > 1: + raise SystemExit(f"multiple remotes match {target}: {', '.join(matches)}") + + details = f"; saw {', '.join(seen)}" if seen else "" + raise SystemExit(f"no git remote matches GitHub repository {target}{details}") + + +def ensure_clean(root: Path) -> None: + status = run( + ["git", "status", "--porcelain=v1", "--untracked-files=all"], + cwd=root, + ) + if status: + raise SystemExit( + "worktree is not clean; stash or commit local changes before preparing a PR review\n" + + status + ) + + +def branch_exists(root: Path, branch: str) -> bool: + proc = subprocess.run( + ["git", "show-ref", "--verify", "--quiet", f"refs/heads/{branch}"], + cwd=root, + ) + return proc.returncode == 0 + + +def validate_branch(root: Path, branch: str) -> None: + run(["git", "check-ref-format", "--branch", branch], cwd=root) + if branch_exists(root, branch): + raise SystemExit(f"local branch already exists: {branch}") + + +def pr_metadata(owner: str, repo: str, number: str) -> dict[str, object]: + url = f"https://github.com/{owner}/{repo}/pull/{number}" + output = run( + [ + "gh", + "pr", + "view", + url, + "--json", + "baseRefName,headRefName,number,title,url", + ] + ) + return json.loads(output) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Prepare a GitHub PR as unstaged changes for Neovim review." + ) + parser.add_argument("pr", help="GitHub PR URL, owner/repo#number, or PR number with --repo") + parser.add_argument("--repo", help="owner/repo, required when passing only a PR number") + parser.add_argument("--branch", help="local review branch to create") + args = parser.parse_args() + + owner, repo, number = parse_pr(args.pr, args.repo) + root = Path(run(["git", "rev-parse", "--show-toplevel"])).resolve() + ensure_clean(root) + + metadata = pr_metadata(owner, repo, number) + base = str(metadata["baseRefName"]) + remote = find_remote(root, owner, repo) + branch = args.branch or f"review-pr-{number}-{time.strftime('%Y%m%d-%H%M%S')}" + validate_branch(root, branch) + + pr_ref = f"refs/remotes/{remote}/pull/{number}/head" + base_ref = f"refs/remotes/{remote}/{base}" + + run( + ["git", "fetch", "--no-tags", remote, f"+refs/pull/{number}/head:{pr_ref}"], + cwd=root, + ) + run( + ["git", "fetch", "--no-tags", remote, f"+refs/heads/{base}:{base_ref}"], + cwd=root, + ) + run(["git", "switch", "--create", branch, pr_ref], cwd=root) + merge_base = run(["git", "merge-base", "HEAD", base_ref], cwd=root) + run(["git", "reset", "--mixed", merge_base], cwd=root) + + status = run(["git", "status", "--short"], cwd=root) + short_base = run(["git", "rev-parse", "--short", merge_base], cwd=root) + + print(f"Prepared PR #{metadata['number']}: {metadata['title']}") + print(f"URL: {metadata['url']}") + print(f"Repository: {owner}/{repo}") + print(f"Branch: {branch}") + print(f"Base: {base} at merge-base {short_base}") + print("\nUnstaged changes:") + print(status or "(none)") + print("\nOpen with: nvim .") + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + sys.exit("interrupted") diff --git a/.agents/skills/obsidian-vault/SKILL.md b/.agents/skills/obsidian-vault/SKILL.md new file mode 100644 index 0000000..358a3fe --- /dev/null +++ b/.agents/skills/obsidian-vault/SKILL.md @@ -0,0 +1,63 @@ +--- +name: obsidian-vault +description: Search, create, and manage notes in the Obsidian vault with wikilinks and index notes. Use when user wants to find, create, or organize notes in Obsidian. +--- + +# Obsidian Vault + +## Vault location + +Resolve the vault path in this order: + +1. `OBSIDIAN_VAULT` environment variable, if set. +2. A repo-local or home config value documented by the user. +3. Common locations such as `~/Obsidian`, `~/Obsidian Vault`, `~/Documents/Obsidian`, or mounted drives. + +If no vault path is clear, ask the user for it before reading or writing notes. + +## Naming conventions + +- **Index notes**: aggregate related topics (e.g., `Ralph Wiggum Index.md`, `Skills Index.md`, `RAG Index.md`) +- **Title case** for all note names +- No folders for organization - use links and index notes instead + +## Linking + +- Use Obsidian `[[wikilinks]]` syntax: `[[Note Title]]` +- Notes link to dependencies/related notes at the bottom +- Index notes are just lists of `[[wikilinks]]` + +## Workflows + +### Search for notes + +```bash +# Search by filename +find "$OBSIDIAN_VAULT" -name "*.md" | grep -i "keyword" + +# Search by content +grep -rl "keyword" "$OBSIDIAN_VAULT" --include="*.md" +``` + +Or use Grep/Glob tools directly on the vault path. + +### Create a new note + +1. Use **Title Case** for filename +2. Write content as a unit of learning (per vault rules) +3. Add `[[wikilinks]]` to related notes at the bottom +4. If part of a numbered sequence, use the hierarchical numbering scheme + +### Find related notes + +Search for `[[Note Title]]` across the vault to find backlinks: + +```bash +grep -rl "\\[\\[Note Title\\]\\]" "$OBSIDIAN_VAULT" +``` + +### Find index notes + +```bash +find "$OBSIDIAN_VAULT" -name "*Index*" +``` diff --git a/.agents/skills/prototype/LOGIC.md b/.agents/skills/prototype/LOGIC.md new file mode 100644 index 0000000..526ecb1 --- /dev/null +++ b/.agents/skills/prototype/LOGIC.md @@ -0,0 +1,79 @@ +# Logic Prototype + +A tiny interactive terminal app that lets the user drive a state model by hand. Use this when the question is about **business logic, state transitions, or data shape** — the kind of thing that looks reasonable on paper but only feels wrong once you push it through real cases. + +## When this is the right shape + +- "I'm not sure if this state machine handles the edge case where X then Y." +- "Does this data model actually let me represent the case where..." +- "I want to feel out what the API should look like before writing it." +- Anything where the user wants to **press buttons and watch state change**. + +If the question is "what should this look like" — wrong branch. Use [UI.md](UI.md). + +## Process + +### 1. State the question + +Before writing code, write down what state model and what question you're prototyping. One paragraph, in the prototype's README or a comment at the top of the file. A logic prototype that answers the wrong question is pure waste — make the question explicit so it can be checked later, whether the user is watching now or returning to it AFK. + +### 2. Pick the language + +Use whatever the host project uses. If the project has no obvious runtime (e.g. a docs repo), ask. + +Match the project's existing conventions for tooling — don't add a new package manager or runtime just for the prototype. + +### 3. Isolate the logic in a portable module + +Put the actual logic — the bit that's answering the question — behind a small, pure interface that could be lifted out and dropped into the real codebase later. The TUI around it is throwaway; the logic module shouldn't be. + +The right shape depends on the question: + +- **A pure reducer** — `(state, action) => state`. Good when actions are discrete events and state is a single value. +- **A state machine** — explicit states and transitions. Good when "which actions are even legal right now" is part of the question. +- **A small set of pure functions** over a plain data type. Good when there's no implicit current state — just transformations. +- **A class or module with a clear method surface** when the logic genuinely owns ongoing internal state. + +Pick whichever shape best fits the question being asked, *not* whichever is easiest to wire to a TUI. Keep it pure: no I/O, no terminal code, no `console.log` for control flow. The TUI imports it and calls into it; nothing flows the other direction. + +This is what makes the prototype useful past its own lifetime. When the question's been answered, the validated reducer / machine / function set can be lifted into the real module — the TUI shell gets deleted. + +### 4. Build the smallest TUI that exposes the state + +Build it as a **lightweight TUI** — on every tick, clear the screen (`console.clear()` / `print("\033[2J\033[H")` / equivalent) and re-render the whole frame. The user should always see one stable view, not an ever-growing scrollback. + +Each frame has two parts, in this order: + +1. **Current state**, pretty-printed and diff-friendly (one field per line, or formatted JSON). Use **bold** for field names or section headers and **dim** for less important context (timestamps, IDs, derived values). Native ANSI escape codes are fine — `\x1b[1m` bold, `\x1b[2m` dim, `\x1b[0m` reset. No need to pull in a styling library unless one is already in the project. +2. **Keyboard shortcuts**, listed at the bottom: `[a] add user [d] delete user [t] tick clock [q] quit`. Bold the key, dim the description, or vice-versa — whatever reads cleanly. + +Behaviour: + +1. **Initialise state** — a single in-memory object/struct. Render the first frame on start. +2. **Read one keystroke (or one line)** at a time, dispatch to a handler that mutates state. +3. **Re-render** the full frame after every action — don't append, replace. +4. **Loop until quit.** + +The whole frame should fit on one screen. + +### 5. Make it runnable in one command + +Add a script to the project's existing task runner (`package.json` scripts, `Makefile`, `justfile`, `pyproject.toml`). The user should run `pnpm run ` or equivalent — never need to remember a path. + +If the host project has no task runner, just put the command at the top of the prototype's README. + +### 6. Hand it over + +Give the user the run command. They'll drive it themselves; the interesting moments are when they say "wait, that shouldn't be possible" or "huh, I assumed X would be different" — those are the bugs in the _idea_, which is the whole point. If they want new actions added, add them. Prototypes evolve. + +### 7. Capture the answer + +When the prototype has done its job, the answer to the question is the only thing worth keeping. If the user is around, ask what it taught them. If not, leave a `NOTES.md` next to the prototype so the answer can be filled in (or filled in by you, if you've watched the session) before the prototype gets deleted. + +## Anti-patterns + +- **Don't add tests.** A prototype that needs tests is no longer a prototype. +- **Don't wire it to the real database.** Use an in-memory store unless the question is specifically about persistence. +- **Don't generalise.** No "what if we wanted to support X later." The prototype answers one question. +- **Don't blur the logic and the TUI together.** If the reducer / state machine references `console.log`, prompts, or terminal escape codes, it's no longer portable. Keep the TUI as a thin shell over a pure module. +- **Don't ship the TUI shell into production.** The shell is optimised for being driven by hand from a terminal. The logic module behind it is the bit worth keeping. diff --git a/.agents/skills/prototype/SKILL.md b/.agents/skills/prototype/SKILL.md new file mode 100644 index 0000000..64f3e61 --- /dev/null +++ b/.agents/skills/prototype/SKILL.md @@ -0,0 +1,30 @@ +--- +name: prototype +description: Build a throwaway prototype to flesh out a design before committing to it. Routes between two branches — a runnable terminal app for state/business-logic questions, or several radically different UI variations toggleable from one route. Use when the user wants to prototype, sanity-check a data model or state machine, mock up a UI, explore design options, or says "prototype this", "let me play with it", "try a few designs". +--- + +# Prototype + +A prototype is **throwaway code that answers a question**. The question decides the shape. + +## Pick a branch + +Identify which question is being answered — from the user's prompt, the surrounding code, or by asking if the user is around: + +- **"Does this logic / state model feel right?"** → [LOGIC.md](LOGIC.md). Build a tiny interactive terminal app that pushes the state machine through cases that are hard to reason about on paper. +- **"What should this look like?"** → [UI.md](UI.md). Generate several radically different UI variations on a single route, switchable via a URL search param and a floating bottom bar. + +The two branches produce very different artifacts — getting this wrong wastes the whole prototype. If the question is genuinely ambiguous and the user isn't reachable, default to whichever branch better matches the surrounding code (a backend module → logic; a page or component → UI) and state the assumption at the top of the prototype. + +## Rules that apply to both + +1. **Throwaway from day one, and clearly marked as such.** Locate the prototype code close to where it will actually be used (next to the module or page it's prototyping for) so context is obvious — but name it so a casual reader can see it's a prototype, not production. For throwaway UI routes, obey whatever routing convention the project already uses; don't invent a new top-level structure. +2. **One command to run.** Whatever the project's existing task runner supports — `pnpm `, `python `, `bun `, etc. The user must be able to start it without thinking. +3. **No persistence by default.** State lives in memory. Persistence is the thing the prototype is _checking_, not something it should depend on. If the question explicitly involves a database, hit a scratch DB or a local file with a clear "PROTOTYPE — wipe me" name. +4. **Skip the polish.** No tests, no error handling beyond what makes the prototype _runnable_, no abstractions. The point is to learn something fast and then delete it. +5. **Surface the state.** After every action (logic) or on every variant switch (UI), print or render the full relevant state so the user can see what changed. +6. **Delete or absorb when done.** When the prototype has answered its question, either delete it or fold the validated decision into the real code — don't leave it rotting in the repo. + +## When done + +The _answer_ is the only thing worth keeping from a prototype. Capture it somewhere durable (commit message, ADR, issue, or a `NOTES.md` next to the prototype) along with the question it was answering. If the user is around, that capture is a quick conversation; if not, leave the placeholder so they (or you, on the next pass) can fill in the verdict before deleting the prototype. diff --git a/.agents/skills/prototype/UI.md b/.agents/skills/prototype/UI.md new file mode 100644 index 0000000..f3b6e64 --- /dev/null +++ b/.agents/skills/prototype/UI.md @@ -0,0 +1,112 @@ +# UI Prototype + +Generate **several radically different UI variations** on a single route, switchable from a floating bottom bar. The user flips between variants in the browser, picks one (or steals bits from each), then throws the rest away. + +If the question is about logic/state rather than what something looks like — wrong branch. Use [LOGIC.md](LOGIC.md). + +## When this is the right shape + +- "What should this page look like?" +- "I want to see a few options for this dashboard before committing." +- "Try a different layout for the settings screen." +- Any time the user would otherwise spend a day picking between three vague mockups in their head. + +## Two sub-shapes — strongly prefer sub-shape A + +A UI prototype is much easier to judge when it's **butting up against the rest of the app** — real header, real sidebar, real data, real density. A throwaway route on its own is a vacuum: every variant looks fine in isolation. Default to sub-shape A whenever there's a plausible existing page to host the variants. Only reach for sub-shape B if the prototype genuinely has no nearby home. + +### Sub-shape A — adjustment to an existing page (preferred) + +The route already exists. Variants are rendered **on the same route**, gated by a `?variant=` URL search param. The existing data fetching, params, and auth all stay — only the rendering swaps. This is the default; pick it unless there's a specific reason not to. + +If the prototype is for something that doesn't yet have a page but *would naturally live inside one* (a new section of the dashboard, a new card on the settings screen, a new step in an existing flow) — that's still sub-shape A. Mount the variants inside the host page. + +### Sub-shape B — a new page (last resort) + +Only use this when the thing being prototyped genuinely has no existing page to live inside — e.g. an entirely new top-level surface, or a flow that can't be embedded anywhere sensible. + +Create a **throwaway route** following whatever routing convention the project already uses — don't invent a new top-level structure. Name it so it's obviously a prototype (e.g. include the word `prototype` in the path or filename). Same `?variant=` pattern. + +Before committing to sub-shape B, sanity-check: is there really no existing page this could be embedded in? An empty route hides design problems that a populated one would expose. + +In both sub-shapes the floating bottom bar is identical. + +## Process + +### 1. State the question and pick N + +Default to **3 variants**. More than 5 stops being radically different and starts being noise — cap there. + +Write down the plan in one line, in the prototype's location or a top-of-file comment: + +> "Three variants of the settings page, switchable via `?variant=`, on the existing `/settings` route." + +This works whether the user is here to push back or not. + +### 2. Generate radically different variants + +Draft each variant. Hold each one to: + +- The page's purpose and the data it has access to. +- The project's component library / styling system (TailwindCSS, shadcn, MUI, plain CSS, whatever). +- A clear exported component name, e.g. `VariantA`, `VariantB`, `VariantC`. + +Variants must be **structurally different** — different layout, different information hierarchy, different primary affordance, not just different colours. Three slightly-tweaked card grids isn't a UI prototype, it's wallpaper. If two drafts come out too similar, redo one with explicit "do not use a card grid" guidance. + +### 3. Wire them together + +Create a single switcher component on the route: + +```tsx +// pseudo-code — adapt to the project's framework +const variant = searchParams.get('variant') ?? 'A'; +return ( + <> + {variant === 'A' && } + {variant === 'B' && } + {variant === 'C' && } + + +); +``` + +For sub-shape A (existing page): keep all the existing data fetching above the switcher; only the rendered subtree changes per variant. + +For sub-shape B (new page): the throwaway route under `/prototype/` mounts the same switcher. + +### 4. Build the floating switcher + +A small fixed-position bar at the bottom-centre of the screen with three pieces: + +- **Left arrow** — cycles to the previous variant (wraps around). +- **Variant label** — shows the current variant key and, if the variant exports a name, that name too. e.g. `B — Sidebar layout`. +- **Right arrow** — cycles forward (wraps around). + +Behaviour: + +- Clicking an arrow updates the URL search param (use the framework's router — `router.replace` on Next, `navigate` on React Router, etc) so the variant is shareable and reload-stable. +- Keyboard: `←` and `→` arrow keys also cycle. Don't intercept arrow keys when an ``, `