diff --git a/bin/package-lock.json b/bin/package-lock.json
index 2e6ef4b..a84f592 100644
--- a/bin/package-lock.json
+++ b/bin/package-lock.json
@@ -5,7 +5,425 @@
"packages": {
"": {
"dependencies": {
- "gray-matter": "^4.0.3"
+ "gray-matter": "^4.0.3",
+ "tsx": "^4.19.2",
+ "yaml": "^2.7.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
+ "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
+ "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
+ "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
+ "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
+ "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
+ "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
+ "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
+ "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
+ "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
+ "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
+ "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
+ "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
+ "cpu": [
+ "loong64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
+ "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
+ "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
+ "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
+ "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
+ "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
+ "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
+ "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
+ "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
+ "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
+ "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
+ "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
+ "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
+ "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
+ "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
}
},
"node_modules/argparse": {
@@ -17,6 +435,47 @@
"sprintf-js": "~1.0.2"
}
},
+ "node_modules/esbuild": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
+ "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.27.3",
+ "@esbuild/android-arm": "0.27.3",
+ "@esbuild/android-arm64": "0.27.3",
+ "@esbuild/android-x64": "0.27.3",
+ "@esbuild/darwin-arm64": "0.27.3",
+ "@esbuild/darwin-x64": "0.27.3",
+ "@esbuild/freebsd-arm64": "0.27.3",
+ "@esbuild/freebsd-x64": "0.27.3",
+ "@esbuild/linux-arm": "0.27.3",
+ "@esbuild/linux-arm64": "0.27.3",
+ "@esbuild/linux-ia32": "0.27.3",
+ "@esbuild/linux-loong64": "0.27.3",
+ "@esbuild/linux-mips64el": "0.27.3",
+ "@esbuild/linux-ppc64": "0.27.3",
+ "@esbuild/linux-riscv64": "0.27.3",
+ "@esbuild/linux-s390x": "0.27.3",
+ "@esbuild/linux-x64": "0.27.3",
+ "@esbuild/netbsd-arm64": "0.27.3",
+ "@esbuild/netbsd-x64": "0.27.3",
+ "@esbuild/openbsd-arm64": "0.27.3",
+ "@esbuild/openbsd-x64": "0.27.3",
+ "@esbuild/openharmony-arm64": "0.27.3",
+ "@esbuild/sunos-x64": "0.27.3",
+ "@esbuild/win32-arm64": "0.27.3",
+ "@esbuild/win32-ia32": "0.27.3",
+ "@esbuild/win32-x64": "0.27.3"
+ }
+ },
"node_modules/esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
@@ -42,6 +501,32 @@
"node": ">=0.10.0"
}
},
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/get-tsconfig": {
+ "version": "4.13.6",
+ "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
+ "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
+ "license": "MIT",
+ "dependencies": {
+ "resolve-pkg-maps": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
+ }
+ },
"node_modules/gray-matter": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz",
@@ -88,6 +573,15 @@
"node": ">=0.10.0"
}
},
+ "node_modules/resolve-pkg-maps": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
+ "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
+ }
+ },
"node_modules/section-matter": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
@@ -115,6 +609,40 @@
"engines": {
"node": ">=0.10.0"
}
+ },
+ "node_modules/tsx": {
+ "version": "4.21.0",
+ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
+ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "~0.27.0",
+ "get-tsconfig": "^4.7.5"
+ },
+ "bin": {
+ "tsx": "dist/cli.mjs"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ }
+ },
+ "node_modules/yaml": {
+ "version": "2.8.2",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
+ "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
+ "license": "ISC",
+ "bin": {
+ "yaml": "bin.mjs"
+ },
+ "engines": {
+ "node": ">= 14.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/eemeli"
+ }
}
}
}
diff --git a/skills/agent-md-refactor/LICENSE b/skills/agent-md-refactor/LICENSE
new file mode 100644
index 0000000..d60009a
--- /dev/null
+++ b/skills/agent-md-refactor/LICENSE
@@ -0,0 +1,7 @@
+Copyright © 2026 softaworks
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/skills/agent-md-refactor/SKILL.md b/skills/agent-md-refactor/SKILL.md
new file mode 100644
index 0000000..b842ef5
--- /dev/null
+++ b/skills/agent-md-refactor/SKILL.md
@@ -0,0 +1,292 @@
+---
+name: agent-md-refactor
+description: Refactor bloated AGENTS.md, CLAUDE.md, or similar agent instruction files to follow progressive disclosure principles. Splits monolithic files into organized, linked documentation.
+license: MIT
+metadata:
+ category: development
+ source:
+ repository: https://github.com/softaworks/agent-toolkit
+ path: skills/agent-md-refactor
+---
+
+# Agent MD Refactor
+
+Refactor bloated agent instruction files (AGENTS.md, CLAUDE.md, COPILOT.md, etc.) to follow **progressive disclosure principles** - keeping essentials at root and organizing the rest into linked, categorized files.
+
+---
+
+## Triggers
+
+Use this skill when:
+- "refactor my AGENTS.md" / "refactor my CLAUDE.md"
+- "split my agent instructions"
+- "organize my CLAUDE.md file"
+- "my AGENTS.md is too long"
+- "progressive disclosure for my instructions"
+- "clean up my agent config"
+
+---
+
+## Quick Reference
+
+| Phase | Action | Output |
+|-------|--------|--------|
+| 1. Analyze | Find contradictions | List of conflicts to resolve |
+| 2. Extract | Identify essentials | Core instructions for root file |
+| 3. Categorize | Group remaining instructions | Logical categories |
+| 4. Structure | Create file hierarchy | Root + linked files |
+| 5. Prune | Flag for deletion | Redundant/vague instructions |
+
+---
+
+## Process
+
+### Phase 1: Find Contradictions
+
+Identify any instructions that conflict with each other.
+
+**Look for:**
+- Contradictory style guidelines (e.g., "use semicolons" vs "no semicolons")
+- Conflicting workflow instructions
+- Incompatible tool preferences
+- Mutually exclusive patterns
+
+**For each contradiction found:**
+```markdown
+## Contradiction Found
+
+**Instruction A:** [quote]
+**Instruction B:** [quote]
+
+**Question:** Which should take precedence, or should both be conditional?
+```
+
+Ask the user to resolve before proceeding.
+
+---
+
+### Phase 2: Identify the Essentials
+
+Extract ONLY what belongs in the root agent file. The root should be minimal - information that applies to **every single task**.
+
+**Essential content (keep in root):**
+| Category | Example |
+|----------|---------|
+| Project description | One sentence: "A React dashboard for analytics" |
+| Package manager | Only if not npm (e.g., "Uses pnpm") |
+| Non-standard commands | Custom build/test/typecheck commands |
+| Critical overrides | Things that MUST override defaults |
+| Universal rules | Applies to 100% of tasks |
+
+**NOT essential (move to linked files):**
+- Language-specific conventions
+- Testing guidelines
+- Code style details
+- Framework patterns
+- Documentation standards
+- Git workflow details
+
+---
+
+### Phase 3: Group the Rest
+
+Organize remaining instructions into logical categories.
+
+**Common categories:**
+| Category | Contents |
+|----------|----------|
+| `typescript.md` | TS conventions, type patterns, strict mode rules |
+| `testing.md` | Test frameworks, coverage, mocking patterns |
+| `code-style.md` | Formatting, naming, comments, structure |
+| `git-workflow.md` | Commits, branches, PRs, reviews |
+| `architecture.md` | Patterns, folder structure, dependencies |
+| `api-design.md` | REST/GraphQL conventions, error handling |
+| `security.md` | Auth patterns, input validation, secrets |
+| `performance.md` | Optimization rules, caching, lazy loading |
+
+**Grouping rules:**
+1. Each file should be self-contained for its topic
+2. Aim for 3-8 files (not too granular, not too broad)
+3. Name files clearly: `{topic}.md`
+4. Include only actionable instructions
+
+---
+
+### Phase 4: Create the File Structure
+
+**Output structure:**
+```
+project-root/
+├── CLAUDE.md (or AGENTS.md) # Minimal root with links
+└── .claude/ # Or docs/agent-instructions/
+ ├── typescript.md
+ ├── testing.md
+ ├── code-style.md
+ ├── git-workflow.md
+ └── architecture.md
+```
+
+**Root file template:**
+```markdown
+# Project Name
+
+One-sentence description of the project.
+
+## Quick Reference
+
+- **Package Manager:** pnpm
+- **Build:** `pnpm build`
+- **Test:** `pnpm test`
+- **Typecheck:** `pnpm typecheck`
+
+## Detailed Instructions
+
+For specific guidelines, see:
+- [TypeScript Conventions](.claude/typescript.md)
+- [Testing Guidelines](.claude/testing.md)
+- [Code Style](.claude/code-style.md)
+- [Git Workflow](.claude/git-workflow.md)
+- [Architecture Patterns](.claude/architecture.md)
+```
+
+**Each linked file template:**
+```markdown
+# {Topic} Guidelines
+
+## Overview
+Brief context for when these guidelines apply.
+
+## Rules
+
+### Rule Category 1
+- Specific, actionable instruction
+- Another specific instruction
+
+### Rule Category 2
+- Specific, actionable instruction
+
+## Examples
+
+### Good
+\`\`\`typescript
+// Example of correct pattern
+\`\`\`
+
+### Avoid
+\`\`\`typescript
+// Example of what not to do
+\`\`\`
+```
+
+---
+
+### Phase 5: Flag for Deletion
+
+Identify instructions that should be removed entirely.
+
+**Delete if:**
+| Criterion | Example | Why Delete |
+|-----------|---------|------------|
+| Redundant | "Use TypeScript" (in a .ts project) | Agent already knows |
+| Too vague | "Write clean code" | Not actionable |
+| Overly obvious | "Don't introduce bugs" | Wastes context |
+| Default behavior | "Use descriptive variable names" | Standard practice |
+| Outdated | References deprecated APIs | No longer applies |
+
+**Output format:**
+```markdown
+## Flagged for Deletion
+
+| Instruction | Reason |
+|-------------|--------|
+| "Write clean, maintainable code" | Too vague to be actionable |
+| "Use TypeScript" | Redundant - project is already TS |
+| "Don't commit secrets" | Agent already knows this |
+| "Follow best practices" | Meaningless without specifics |
+```
+
+---
+
+## Execution Checklist
+
+```
+[ ] Phase 1: All contradictions identified and resolved
+[ ] Phase 2: Root file contains ONLY essentials
+[ ] Phase 3: All remaining instructions categorized
+[ ] Phase 4: File structure created with proper links
+[ ] Phase 5: Redundant/vague instructions removed
+[ ] Verify: Each linked file is self-contained
+[ ] Verify: Root file is under 50 lines
+[ ] Verify: All links work correctly
+```
+
+---
+
+## Anti-Patterns
+
+| Avoid | Why | Instead |
+|-------|-----|---------|
+| Keeping everything in root | Bloated, hard to maintain | Split into linked files |
+| Too many categories | Fragmentation | Consolidate related topics |
+| Vague instructions | Wastes tokens, no value | Be specific or delete |
+| Duplicating defaults | Agent already knows | Only override when needed |
+| Deep nesting | Hard to navigate | Flat structure with links |
+
+---
+
+## Examples
+
+### Before (Bloated Root)
+```markdown
+# CLAUDE.md
+
+This is a React project.
+
+## Code Style
+- Use 2 spaces
+- Use semicolons
+- Prefer const over let
+- Use arrow functions
+... (200 more lines)
+
+## Testing
+- Use Jest
+- Coverage > 80%
+... (100 more lines)
+
+## TypeScript
+- Enable strict mode
+... (150 more lines)
+```
+
+### After (Progressive Disclosure)
+```markdown
+# CLAUDE.md
+
+React dashboard for real-time analytics visualization.
+
+## Commands
+- `pnpm dev` - Start development server
+- `pnpm test` - Run tests with coverage
+- `pnpm build` - Production build
+
+## Guidelines
+- [Code Style](.claude/code-style.md)
+- [Testing](.claude/testing.md)
+- [TypeScript](.claude/typescript.md)
+```
+
+---
+
+## Verification
+
+After refactoring, verify:
+
+1. **Root file is minimal** - Under 50 lines, only universal info
+2. **Links work** - All referenced files exist
+3. **No contradictions** - Instructions are consistent
+4. **Actionable content** - Every instruction is specific
+5. **Complete coverage** - No instructions were lost (unless flagged for deletion)
+6. **Self-contained files** - Each linked file stands alone
+
+---
diff --git a/skills/angular-component/LICENSE.txt b/skills/angular-component/LICENSE.txt
new file mode 100644
index 0000000..7a4a3ea
--- /dev/null
+++ b/skills/angular-component/LICENSE.txt
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
\ No newline at end of file
diff --git a/skills/angular-component/SKILL.md b/skills/angular-component/SKILL.md
new file mode 100644
index 0000000..3dd51fc
--- /dev/null
+++ b/skills/angular-component/SKILL.md
@@ -0,0 +1,294 @@
+---
+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.
+license: Complete terms in LICENSE.txt
+metadata:
+ category: development
+ source:
+ repository: https://github.com/analogjs/angular-skills
+ path: skills/angular-component
+---
+
+# 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
+// Action
+//
+```
+
+## 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/skills/angular-component/references/component-patterns.md b/skills/angular-component/references/component-patterns.md
new file mode 100644
index 0000000..68e4e33
--- /dev/null
+++ b/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: `
+
+ @for (image of images(); track image.id) {
+
+ }
+
+ `,
+})
+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: `
+
+
+
+
+ `,
+})
+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: `Save `,
+})
+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: `Add ` })
+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 {
+ Load Comments
+ }
+ `,
+})
+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
+ Retry
+
+ } @else {
+
+ }
+ `,
+})
+export class ErrorBoundary {
+ hasError = signal(false);
+ private errorHandler = inject(ErrorHandler);
+
+ retry() {
+ this.hasError.set(false);
+ }
+}
+```
diff --git a/skills/angular-di/LICENSE.txt b/skills/angular-di/LICENSE.txt
new file mode 100644
index 0000000..7a4a3ea
--- /dev/null
+++ b/skills/angular-di/LICENSE.txt
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
\ No newline at end of file
diff --git a/skills/angular-di/SKILL.md b/skills/angular-di/SKILL.md
new file mode 100644
index 0000000..1046488
--- /dev/null
+++ b/skills/angular-di/SKILL.md
@@ -0,0 +1,393 @@
+---
+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.
+license: Complete terms in LICENSE.txt
+metadata:
+ category: development
+ source:
+ repository: https://github.com/analogjs/angular-skills
+ path: skills/angular-di
+---
+
+# 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/skills/angular-di/references/di-patterns.md b/skills/angular-di/references/di-patterns.md
new file mode 100644
index 0000000..03a9397
--- /dev/null
+++ b/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/skills/angular-directives/LICENSE.txt b/skills/angular-directives/LICENSE.txt
new file mode 100644
index 0000000..7a4a3ea
--- /dev/null
+++ b/skills/angular-directives/LICENSE.txt
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
\ No newline at end of file
diff --git a/skills/angular-directives/SKILL.md b/skills/angular-directives/SKILL.md
new file mode 100644
index 0000000..f0370ef
--- /dev/null
+++ b/skills/angular-directives/SKILL.md
@@ -0,0 +1,443 @@
+---
+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.
+license: Complete terms in LICENSE.txt
+metadata:
+ category: development
+ source:
+ repository: https://github.com/analogjs/angular-skills
+ path: skills/angular-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: Save
+```
+
+### 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: Click
+```
+
+### 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: Save (Ctrl+S)
+```
+
+## 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/skills/angular-directives/references/directive-patterns.md b/skills/angular-directives/references/directive-patterns.md
new file mode 100644
index 0000000..06c02d3
--- /dev/null
+++ b/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:
+//
+// Copy
+//
+```
+
+## 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:
+```
+
+### 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:
+// Admin Only
+// 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:
+//
+//
Toggle
+// @if (toggle.isOpen()) {
+//
Content
+// }
+//
+```
diff --git a/skills/angular-forms/LICENSE.txt b/skills/angular-forms/LICENSE.txt
new file mode 100644
index 0000000..7a4a3ea
--- /dev/null
+++ b/skills/angular-forms/LICENSE.txt
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
\ No newline at end of file
diff --git a/skills/angular-forms/SKILL.md b/skills/angular-forms/SKILL.md
new file mode 100644
index 0000000..cadd242
--- /dev/null
+++ b/skills/angular-forms/SKILL.md
@@ -0,0 +1,440 @@
+---
+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.
+license: Complete terms in LICENSE.txt
+metadata:
+ category: development
+ source:
+ repository: https://github.com/analogjs/angular-skills
+ path: skills/angular-forms
+---
+
+# 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: `
+
+ `,
+})
+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) {
+
+
+
+ Remove
+
+ }
+ Add Item
+ `,
+})
+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/skills/angular-forms/references/form-patterns.md b/skills/angular-forms/references/form-patterns.md
new file mode 100644
index 0000000..9b93ba4
--- /dev/null
+++ b/skills/angular-forms/references/form-patterns.md
@@ -0,0 +1,398 @@
+# 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: `
+
+ `,
+})
+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: `
+
+ `,
+})
+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 v21+)
+
+```typescript
+import {
+ ValueChangeEvent, StatusChangeEvent,
+ 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 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/skills/angular-http/LICENSE.txt b/skills/angular-http/LICENSE.txt
new file mode 100644
index 0000000..7a4a3ea
--- /dev/null
+++ b/skills/angular-http/LICENSE.txt
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
\ No newline at end of file
diff --git a/skills/angular-http/SKILL.md b/skills/angular-http/SKILL.md
new file mode 100644
index 0000000..8c5eb93
--- /dev/null
+++ b/skills/angular-http/SKILL.md
@@ -0,0 +1,372 @@
+---
+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.
+license: Complete terms in LICENSE.txt
+metadata:
+ category: development
+ source:
+ repository: https://github.com/analogjs/angular-skills
+ path: skills/angular-http
+---
+
+# 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 }}
+ Retry
+ } @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) }}
+
Retry
+
+ }
+ `,
+})
+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/skills/angular-http/references/http-patterns.md b/skills/angular-http/references/http-patterns.md
new file mode 100644
index 0000000..1791060
--- /dev/null
+++ b/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()) {
+ Load More
+ }
+ `,
+})
+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/skills/angular-routing/LICENSE.txt b/skills/angular-routing/LICENSE.txt
new file mode 100644
index 0000000..7a4a3ea
--- /dev/null
+++ b/skills/angular-routing/LICENSE.txt
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
\ No newline at end of file
diff --git a/skills/angular-routing/SKILL.md b/skills/angular-routing/SKILL.md
new file mode 100644
index 0000000..7831c71
--- /dev/null
+++ b/skills/angular-routing/SKILL.md
@@ -0,0 +1,401 @@
+---
+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.
+license: Complete terms in LICENSE.txt
+metadata:
+ category: development
+ source:
+ repository: https://github.com/analogjs/angular-skills
+ path: skills/angular-routing
+---
+
+# 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: `
+
+ Home
+ About
+
+
+ `,
+})
+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/skills/angular-routing/references/routing-patterns.md b/skills/angular-routing/references/routing-patterns.md
new file mode 100644
index 0000000..7ddd6ee
--- /dev/null
+++ b/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: `
+
+
+ Home
+ @for (crumb of breadcrumbService.breadcrumbs(); track crumb.url) {
+
+ {{ crumb.label }}
+
+ }
+
+
+ `,
+})
+export class BreadcrumbCmpt {
+ breadcrumbService = inject(Breadcrumb);
+}
+```
+
+## Tab Navigation
+
+```typescript
+// tabs-layout.component.ts
+@Component({
+ imports: [RouterOutlet, RouterLink, RouterLinkActive],
+ template: `
+
+
+
+
+ `,
+})
+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/skills/angular-signals/LICENSE.txt b/skills/angular-signals/LICENSE.txt
new file mode 100644
index 0000000..7a4a3ea
--- /dev/null
+++ b/skills/angular-signals/LICENSE.txt
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
\ No newline at end of file
diff --git a/skills/angular-signals/SKILL.md b/skills/angular-signals/SKILL.md
new file mode 100644
index 0000000..db86b8d
--- /dev/null
+++ b/skills/angular-signals/SKILL.md
@@ -0,0 +1,308 @@
+---
+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.
+license: Complete terms in LICENSE.txt
+metadata:
+ category: development
+ source:
+ repository: https://github.com/analogjs/angular-skills
+ path: skills/angular-signals
+---
+
+# 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: `
+
+ Add
+
+
+ @for (todo of filteredTodos(); track todo.id) {
+
+ {{ todo.text }}
+ Toggle
+
+ }
+
+
+ {{ 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/skills/angular-signals/references/signal-patterns.md b/skills/angular-signals/references/signal-patterns.md
new file mode 100644
index 0000000..ec6f3fc
--- /dev/null
+++ b/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/skills/angular-ssr/LICENSE.txt b/skills/angular-ssr/LICENSE.txt
new file mode 100644
index 0000000..7a4a3ea
--- /dev/null
+++ b/skills/angular-ssr/LICENSE.txt
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
\ No newline at end of file
diff --git a/skills/angular-ssr/SKILL.md b/skills/angular-ssr/SKILL.md
new file mode 100644
index 0000000..e7d358c
--- /dev/null
+++ b/skills/angular-ssr/SKILL.md
@@ -0,0 +1,441 @@
+---
+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.
+license: Complete terms in LICENSE.txt
+metadata:
+ category: development
+ source:
+ repository: https://github.com/analogjs/angular-skills
+ path: skills/angular-ssr
+---
+
+# 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 {
+
+ }
+
+
+ @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/skills/angular-ssr/references/ssr-patterns.md b/skills/angular-ssr/references/ssr-patterns.md
new file mode 100644
index 0000000..49ff709
--- /dev/null
+++ b/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/skills/angular-testing/LICENSE.txt b/skills/angular-testing/LICENSE.txt
new file mode 100644
index 0000000..7a4a3ea
--- /dev/null
+++ b/skills/angular-testing/LICENSE.txt
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
\ No newline at end of file
diff --git a/skills/angular-testing/SKILL.md b/skills/angular-testing/SKILL.md
new file mode 100644
index 0000000..1154b09
--- /dev/null
+++ b/skills/angular-testing/SKILL.md
@@ -0,0 +1,666 @@
+---
+name: angular-testing
+description: Write unit and integration tests for Angular v21+ applications using Vitest or Jasmine with TestBed, component harnesses, 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.
+license: Complete terms in LICENSE.txt
+metadata:
+ category: development
+ source:
+ repository: https://github.com/analogjs/angular-skills
+ path: skills/angular-testing
+---
+
+# Angular Testing
+
+Test Angular v21+ 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.
+
+### Installation
+
+```bash
+npm install -D vitest jsdom
+```
+
+### Configuration
+
+```json
+// angular.json - update test architect
+{
+ "projects": {
+ "your-app": {
+ "architect": {
+ "test": {
+ "builder": "@angular/build:unit-test",
+ "options": {
+ "tsConfig": "tsconfig.spec.json",
+ "buildTarget": "your-app:build"
+ }
+ }
+ }
+ }
+ }
+}
+```
+
+```json
+// tsconfig.spec.json
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "types": ["vitest/globals"]
+ },
+ "include": ["src/**/*.spec.ts"]
+}
+```
+
+### Running Tests
+
+```bash
+# Run tests
+ng test
+
+# Watch mode
+ng test --watch
+
+# Coverage
+ng test --code-coverage
+```
+
+### Vitest Test Example
+
+```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],
+ }).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);
+ });
+});
+```
+
+### Vitest Mocking
+
+```typescript
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+describe('UserCmpt', () => {
+ 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: [UserCmpt],
+ providers: [
+ { provide: User, useValue: mockUserService },
+ ],
+ }).compileComponents();
+ });
+
+ it('should call getUser on init', () => {
+ const fixture = TestBed.createComponent(UserCmpt);
+ fixture.detectChanges();
+
+ expect(mockUserService.getUser).toHaveBeenCalledWith('1');
+ });
+});
+```
+
+### Vitest with HTTP Testing
+
+```typescript
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
+import { provideHttpClient } from '@angular/common/http';
+
+describe('User', () => {
+ let service: User;
+ let httpMock: HttpTestingController;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ providers: [
+ provideHttpClient(),
+ provideHttpClientTesting(),
+ ],
+ });
+
+ service = TestBed.inject(User);
+ httpMock = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ httpMock.verify();
+ });
+
+ it('should fetch user', () => {
+ 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);
+ });
+});
+```
+
+---
+
+## Basic Component Test
+
+```typescript
+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);
+ });
+
+ it('should render filtered todos', () => {
+ component.todos.set([
+ { id: '1', text: 'Active Task', done: false },
+ { id: '2', text: 'Done Task', done: true },
+ ]);
+ component.filter.set('active');
+ fixture.detectChanges();
+
+ const items = fixture.nativeElement.querySelectorAll('li');
+ expect(items.length).toBe(1);
+ expect(items[0].textContent).toContain('Active Task');
+ });
+});
+```
+
+## Testing OnPush Components
+
+OnPush components require explicit change detection:
+
+```typescript
+@Component({
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ template: `{{ data().name }} `,
+})
+export class OnPush {
+ data = input.required<{ name: string }>();
+}
+
+describe('OnPush', () => {
+ it('should update when input signal changes', () => {
+ const fixture = TestBed.createComponent(OnPush);
+
+ // 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
+import { TestBed } from '@angular/core/testing';
+
+@Injectable({ providedIn: 'root' })
+export class CounterSvc {
+ private _count = signal(0);
+ readonly count = this._count.asReadonly();
+
+ increment() {
+ this._count.update(c => c + 1);
+ }
+
+ reset() {
+ this._count.set(0);
+ }
+}
+
+describe('CounterSvc', () => {
+ let service: CounterSvc;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({});
+ service = TestBed.inject(CounterSvc);
+ });
+
+ it('should increment count', () => {
+ expect(service.count()).toBe(0);
+
+ service.increment();
+ expect(service.count()).toBe(1);
+
+ service.increment();
+ expect(service.count()).toBe(2);
+ });
+
+ it('should reset count', () => {
+ service.increment();
+ service.increment();
+
+ service.reset();
+
+ expect(service.count()).toBe(0);
+ });
+});
+```
+
+### Service with Dependencies
+
+```typescript
+@Injectable({ providedIn: 'root' })
+export class User {
+ private http = inject(HttpClient);
+
+ getUser(id: string) {
+ return this.http.get(`/api/users/${id}`);
+ }
+}
+
+describe('User', () => {
+ let service: User;
+ let httpMock: HttpTestingController;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ providers: [
+ provideHttpClient(),
+ provideHttpClientTesting(),
+ ],
+ });
+
+ service = TestBed.inject(User);
+ httpMock = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ httpMock.verify(); // Verify no outstanding requests
+ });
+
+ it('should fetch user by id', () => {
+ const mockUser: User = { 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 Jasmine Spies
+
+```typescript
+describe('ComponentWithDependency', () => {
+ 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: [UserProfile],
+ providers: [
+ { provide: User, useValue: userServiceSpy },
+ ],
+ }).compileComponents();
+ });
+
+ it('should call getUser on init', () => {
+ const fixture = TestBed.createComponent(UserProfile);
+ fixture.detectChanges();
+
+ expect(userServiceSpy.getUser).toHaveBeenCalledWith('1');
+ });
+});
+```
+
+### Mock Signal-Based Service
+
+```typescript
+// Create mock with signal
+const mockAuth = {
+ user: signal(null),
+ isAuthenticated: computed(() => mockAuth.user() !== null),
+ login: jasmine.createSpy('login'),
+ logout: jasmine.createSpy('logout'),
+};
+
+beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [Protected],
+ providers: [
+ { provide: Auth, useValue: mockAuth },
+ ],
+ }).compileComponents();
+});
+
+it('should show content when authenticated', () => {
+ mockAuth.user.set({ id: '1', name: 'Test User' });
+
+ const fixture = TestBed.createComponent(Protected);
+ fixture.detectChanges();
+
+ expect(fixture.nativeElement.querySelector('.protected-content')).toBeTruthy();
+});
+```
+
+## Testing Inputs and Outputs
+
+```typescript
+@Component({
+ selector: 'app-item',
+ template: `
+ {{ item().name }}
+ `,
+})
+export class Item {
+ item = input.required- ();
+ selected = output
- ();
+
+ select() {
+ this.selected.emit(this.item());
+ }
+}
+
+describe('Item', () => {
+ it('should emit selected event on click', () => {
+ const fixture = TestBed.createComponent(Item);
+ const item: Item = { id: '1', name: 'Test Item' };
+
+ fixture.componentRef.setInput('item', item);
+ fixture.detectChanges();
+
+ // Subscribe to output
+ let emittedItem: Item | undefined;
+ fixture.componentInstance.selected.subscribe(i => emittedItem = i);
+
+ // Trigger click
+ 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(Search);
+ fixture.detectChanges();
+
+ // Type in search
+ fixture.componentInstance.query.set('test');
+
+ // Advance time for debounce
+ tick(300);
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.results().length).toBeGreaterThan(0);
+
+ // Flush any remaining timers
+ flush();
+}));
+```
+
+### Using waitForAsync
+
+```typescript
+import { waitForAsync } from '@angular/core/testing';
+
+it('should load data', waitForAsync(() => {
+ const fixture = TestBed.createComponent(Data);
+ 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();
+
+ // Initially loading
+ expect(fixture.nativeElement.textContent).toContain('Loading');
+
+ // Respond to request
+ const req = httpMock.expectOne('/api/users/1');
+ req.flush({ id: '1', name: 'John Doe' });
+ fixture.detectChanges();
+
+ expect(fixture.nativeElement.textContent).toContain('John Doe');
+ });
+});
+```
+
+## 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
+
+```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');
+```
+
+```typescript
+// Jasmine
+spyOn(service, 'method').and.returnValue(of(data));
+
+// Vitest
+vi.spyOn(service, 'method').mockReturnValue(of(data));
+```
+
+For advanced testing patterns, see [references/testing-patterns.md](references/testing-patterns.md).
diff --git a/skills/angular-testing/references/testing-patterns.md b/skills/angular-testing/references/testing-patterns.md
new file mode 100644
index 0000000..4dc4291
--- /dev/null
+++ b/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/skills/angular-tooling/LICENSE.txt b/skills/angular-tooling/LICENSE.txt
new file mode 100644
index 0000000..7a4a3ea
--- /dev/null
+++ b/skills/angular-tooling/LICENSE.txt
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
\ No newline at end of file
diff --git a/skills/angular-tooling/SKILL.md b/skills/angular-tooling/SKILL.md
new file mode 100644
index 0000000..3c08429
--- /dev/null
+++ b/skills/angular-tooling/SKILL.md
@@ -0,0 +1,358 @@
+---
+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.
+license: Complete terms in LICENSE.txt
+metadata:
+ category: development
+ source:
+ repository: https://github.com/analogjs/angular-skills
+ path: skills/angular-tooling
+---
+
+# 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/skills/angular-tooling/references/tooling-patterns.md b/skills/angular-tooling/references/tooling-patterns.md
new file mode 100644
index 0000000..b514366
--- /dev/null
+++ b/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/skills/frontend-design/LICENSE.txt b/skills/frontend-design/LICENSE.txt
new file mode 100644
index 0000000..7a4a3ea
--- /dev/null
+++ b/skills/frontend-design/LICENSE.txt
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
\ No newline at end of file
diff --git a/skills/frontend-design/SKILL.md b/skills/frontend-design/SKILL.md
new file mode 100644
index 0000000..e67f371
--- /dev/null
+++ b/skills/frontend-design/SKILL.md
@@ -0,0 +1,47 @@
+---
+name: frontend-design
+description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, artifacts, posters, or applications (examples include websites, landing pages, dashboards, React components, HTML/CSS layouts, or when styling/beautifying any web UI). Generates creative, polished code and UI design that avoids generic AI aesthetics.
+license: Complete terms in LICENSE.txt
+metadata:
+ category: development
+ source:
+ repository: https://github.com/anthropics/skills
+ path: skills/frontend-design
+---
+
+This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices.
+
+The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints.
+
+## Design Thinking
+
+Before coding, understand the context and commit to a BOLD aesthetic direction:
+- **Purpose**: What problem does this interface solve? Who uses it?
+- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction.
+- **Constraints**: Technical requirements (framework, performance, accessibility).
+- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?
+
+**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity.
+
+Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is:
+- Production-grade and functional
+- Visually striking and memorable
+- Cohesive with a clear aesthetic point-of-view
+- Meticulously refined in every detail
+
+## Frontend Aesthetics Guidelines
+
+Focus on:
+- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font.
+- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes.
+- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise.
+- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density.
+- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays.
+
+NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character.
+
+Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations.
+
+**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well.
+
+Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision.
diff --git a/skills/marketplace.yaml b/skills/marketplace.yaml
index 312eff7..630162c 100644
--- a/skills/marketplace.yaml
+++ b/skills/marketplace.yaml
@@ -104,6 +104,131 @@ items:
githubUrl: https://github.com/Kilo-Org/kilo-marketplace/tree/main/skills/artifacts-builder
rawUrl: https://raw.githubusercontent.com/Kilo-Org/kilo-marketplace/main/skills/artifacts-builder/SKILL.md
content: https://github.com/Kilo-Org/kilo-marketplace/releases/download/skills-latest/artifacts-builder.tar.gz
+ - id: agent-md-refactor
+ description: >-
+ Refactor bloated AGENTS.md, CLAUDE.md, or similar agent instruction files to follow progressive disclosure
+ principles. Splits monolithic files into organized, linked documentation.
+ category: development
+ githubUrl: https://github.com/Kilo-Org/kilo-marketplace/tree/main/skills/agent-md-refactor
+ rawUrl: https://raw.githubusercontent.com/Kilo-Org/kilo-marketplace/main/skills/agent-md-refactor/SKILL.md
+ content: https://github.com/Kilo-Org/kilo-marketplace/releases/download/skills-latest/agent-md-refactor.tar.gz
+ - id: 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.
+ category: development
+ githubUrl: https://github.com/Kilo-Org/kilo-marketplace/tree/main/skills/angular-component
+ rawUrl: https://raw.githubusercontent.com/Kilo-Org/kilo-marketplace/main/skills/angular-component/SKILL.md
+ content: https://github.com/Kilo-Org/kilo-marketplace/releases/download/skills-latest/angular-component.tar.gz
+ - id: 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.
+ category: development
+ githubUrl: https://github.com/Kilo-Org/kilo-marketplace/tree/main/skills/angular-di
+ rawUrl: https://raw.githubusercontent.com/Kilo-Org/kilo-marketplace/main/skills/angular-di/SKILL.md
+ content: https://github.com/Kilo-Org/kilo-marketplace/releases/download/skills-latest/angular-di.tar.gz
+ - id: 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.
+ category: development
+ githubUrl: https://github.com/Kilo-Org/kilo-marketplace/tree/main/skills/angular-directives
+ rawUrl: https://raw.githubusercontent.com/Kilo-Org/kilo-marketplace/main/skills/angular-directives/SKILL.md
+ content: https://github.com/Kilo-Org/kilo-marketplace/releases/download/skills-latest/angular-directives.tar.gz
+ - id: 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.
+ category: development
+ githubUrl: https://github.com/Kilo-Org/kilo-marketplace/tree/main/skills/angular-forms
+ rawUrl: https://raw.githubusercontent.com/Kilo-Org/kilo-marketplace/main/skills/angular-forms/SKILL.md
+ content: https://github.com/Kilo-Org/kilo-marketplace/releases/download/skills-latest/angular-forms.tar.gz
+ - id: 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.
+ category: development
+ githubUrl: https://github.com/Kilo-Org/kilo-marketplace/tree/main/skills/angular-http
+ rawUrl: https://raw.githubusercontent.com/Kilo-Org/kilo-marketplace/main/skills/angular-http/SKILL.md
+ content: https://github.com/Kilo-Org/kilo-marketplace/releases/download/skills-latest/angular-http.tar.gz
+ - id: 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.
+ category: development
+ githubUrl: https://github.com/Kilo-Org/kilo-marketplace/tree/main/skills/angular-routing
+ rawUrl: https://raw.githubusercontent.com/Kilo-Org/kilo-marketplace/main/skills/angular-routing/SKILL.md
+ content: https://github.com/Kilo-Org/kilo-marketplace/releases/download/skills-latest/angular-routing.tar.gz
+ - id: 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.
+ category: development
+ githubUrl: https://github.com/Kilo-Org/kilo-marketplace/tree/main/skills/angular-signals
+ rawUrl: https://raw.githubusercontent.com/Kilo-Org/kilo-marketplace/main/skills/angular-signals/SKILL.md
+ content: https://github.com/Kilo-Org/kilo-marketplace/releases/download/skills-latest/angular-signals.tar.gz
+ - id: 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.
+ category: development
+ githubUrl: https://github.com/Kilo-Org/kilo-marketplace/tree/main/skills/angular-ssr
+ rawUrl: https://raw.githubusercontent.com/Kilo-Org/kilo-marketplace/main/skills/angular-ssr/SKILL.md
+ content: https://github.com/Kilo-Org/kilo-marketplace/releases/download/skills-latest/angular-ssr.tar.gz
+ - id: angular-testing
+ description: >-
+ Write unit and integration tests for Angular v21+ applications using Vitest or Jasmine with TestBed, component
+ harnesses, 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.
+ category: development
+ githubUrl: https://github.com/Kilo-Org/kilo-marketplace/tree/main/skills/angular-testing
+ rawUrl: https://raw.githubusercontent.com/Kilo-Org/kilo-marketplace/main/skills/angular-testing/SKILL.md
+ content: https://github.com/Kilo-Org/kilo-marketplace/releases/download/skills-latest/angular-testing.tar.gz
+ - id: 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.
+ category: development
+ githubUrl: https://github.com/Kilo-Org/kilo-marketplace/tree/main/skills/angular-tooling
+ rawUrl: https://raw.githubusercontent.com/Kilo-Org/kilo-marketplace/main/skills/angular-tooling/SKILL.md
+ content: https://github.com/Kilo-Org/kilo-marketplace/releases/download/skills-latest/angular-tooling.tar.gz
+ - id: frontend-design
+ description: >-
+ Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user
+ asks to build web components, pages, artifacts, posters, or applications (examples include websites, landing
+ pages, dashboards, React components, HTML/CSS layouts, or when styling/beautifying any web UI). Generates
+ creative, polished code and UI design that avoids generic AI aesthetics.
+ category: development
+ githubUrl: https://github.com/Kilo-Org/kilo-marketplace/tree/main/skills/frontend-design
+ rawUrl: https://raw.githubusercontent.com/Kilo-Org/kilo-marketplace/main/skills/frontend-design/SKILL.md
+ content: https://github.com/Kilo-Org/kilo-marketplace/releases/download/skills-latest/frontend-design.tar.gz
+ - id: vercel-composition-patterns
+ description: >-
+ React composition patterns that scale. Use when refactoring components with boolean prop proliferation, building
+ flexible component libraries, or designing reusable APIs. Triggers on tasks involving compound components,
+ render props, context providers, or component architecture. Includes React 19 API changes.
+ category: development
+ githubUrl: https://github.com/Kilo-Org/kilo-marketplace/tree/main/skills/vercel-composition-patterns
+ rawUrl: https://raw.githubusercontent.com/Kilo-Org/kilo-marketplace/main/skills/vercel-composition-patterns/SKILL.md
+ content: https://github.com/Kilo-Org/kilo-marketplace/releases/download/skills-latest/vercel-composition-patterns.tar.gz
- id: changelog-generator
description: >-
Automatically creates user-facing changelogs from git commits by analyzing commit history, categorizing changes,
diff --git a/skills/vercel-composition-patterns/AGENTS.md b/skills/vercel-composition-patterns/AGENTS.md
new file mode 100644
index 0000000..d962ec2
--- /dev/null
+++ b/skills/vercel-composition-patterns/AGENTS.md
@@ -0,0 +1,946 @@
+# React Composition Patterns
+
+**Version 1.0.0**
+Engineering
+January 2026
+
+> **Note:**
+> This document is mainly for agents and LLMs to follow when maintaining,
+> generating, or refactoring React codebases using composition. Humans
+> may also find it useful, but guidance here is optimized for automation
+> and consistency by AI-assisted workflows.
+
+---
+
+## Abstract
+
+Composition patterns for building flexible, maintainable React components. Avoid boolean prop proliferation by using compound components, lifting state, and composing internals. These patterns make codebases easier for both humans and AI agents to work with as they scale.
+
+---
+
+## Table of Contents
+
+1. [Component Architecture](#1-component-architecture) — **HIGH**
+ - 1.1 [Avoid Boolean Prop Proliferation](#11-avoid-boolean-prop-proliferation)
+ - 1.2 [Use Compound Components](#12-use-compound-components)
+2. [State Management](#2-state-management) — **MEDIUM**
+ - 2.1 [Decouple State Management from UI](#21-decouple-state-management-from-ui)
+ - 2.2 [Define Generic Context Interfaces for Dependency Injection](#22-define-generic-context-interfaces-for-dependency-injection)
+ - 2.3 [Lift State into Provider Components](#23-lift-state-into-provider-components)
+3. [Implementation Patterns](#3-implementation-patterns) — **MEDIUM**
+ - 3.1 [Create Explicit Component Variants](#31-create-explicit-component-variants)
+ - 3.2 [Prefer Composing Children Over Render Props](#32-prefer-composing-children-over-render-props)
+4. [React 19 APIs](#4-react-19-apis) — **MEDIUM**
+ - 4.1 [React 19 API Changes](#41-react-19-api-changes)
+
+---
+
+## 1. Component Architecture
+
+**Impact: HIGH**
+
+Fundamental patterns for structuring components to avoid prop
+proliferation and enable flexible composition.
+
+### 1.1 Avoid Boolean Prop Proliferation
+
+**Impact: CRITICAL (prevents unmaintainable component variants)**
+
+Don't add boolean props like `isThread`, `isEditing`, `isDMThread` to customize
+
+component behavior. Each boolean doubles possible states and creates
+
+unmaintainable conditional logic. Use composition instead.
+
+**Incorrect: boolean props create exponential complexity**
+
+```tsx
+function Composer({
+ onSubmit,
+ isThread,
+ channelId,
+ isDMThread,
+ dmId,
+ isEditing,
+ isForwarding,
+}: Props) {
+ return (
+
+ )
+}
+```
+
+**Correct: composition eliminates conditionals**
+
+```tsx
+// Channel composer
+function ChannelComposer() {
+ return (
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+// Thread composer - adds "also send to channel" field
+function ThreadComposer({ channelId }: { channelId: string }) {
+ return (
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+// Edit composer - different footer actions
+function EditComposer() {
+ return (
+
+
+
+
+
+
+
+
+
+ )
+}
+```
+
+Each variant is explicit about what it renders. We can share internals without
+
+sharing a single monolithic parent.
+
+### 1.2 Use Compound Components
+
+**Impact: HIGH (enables flexible composition without prop drilling)**
+
+Structure complex components as compound components with a shared context. Each
+
+subcomponent accesses shared state via context, not props. Consumers compose the
+
+pieces they need.
+
+**Incorrect: monolithic component with render props**
+
+```tsx
+function Composer({
+ renderHeader,
+ renderFooter,
+ renderActions,
+ showAttachments,
+ showFormatting,
+ showEmojis,
+}: Props) {
+ return (
+
+ )
+}
+```
+
+**Correct: compound components with shared context**
+
+```tsx
+const ComposerContext = createContext(null)
+
+function ComposerProvider({ children, state, actions, meta }: ProviderProps) {
+ return (
+
+ {children}
+
+ )
+}
+
+function ComposerFrame({ children }: { children: React.ReactNode }) {
+ return
+}
+
+function ComposerInput() {
+ const {
+ state,
+ actions: { update },
+ meta: { inputRef },
+ } = use(ComposerContext)
+ return (
+ update((s) => ({ ...s, input: text }))}
+ />
+ )
+}
+
+function ComposerSubmit() {
+ const {
+ actions: { submit },
+ } = use(ComposerContext)
+ return Send
+}
+
+// Export as compound component
+const Composer = {
+ Provider: ComposerProvider,
+ Frame: ComposerFrame,
+ Input: ComposerInput,
+ Submit: ComposerSubmit,
+ Header: ComposerHeader,
+ Footer: ComposerFooter,
+ Attachments: ComposerAttachments,
+ Formatting: ComposerFormatting,
+ Emojis: ComposerEmojis,
+}
+```
+
+**Usage:**
+
+```tsx
+
+
+
+
+
+
+
+
+
+
+```
+
+Consumers explicitly compose exactly what they need. No hidden conditionals. And the state, actions and meta are dependency-injected by a parent provider, allowing multiple usages of the same component structure.
+
+---
+
+## 2. State Management
+
+**Impact: MEDIUM**
+
+Patterns for lifting state and managing shared context across
+composed components.
+
+### 2.1 Decouple State Management from UI
+
+**Impact: MEDIUM (enables swapping state implementations without changing UI)**
+
+The provider component should be the only place that knows how state is managed.
+
+UI components consume the context interface—they don't know if state comes from
+
+useState, Zustand, or a server sync.
+
+**Incorrect: UI coupled to state implementation**
+
+```tsx
+function ChannelComposer({ channelId }: { channelId: string }) {
+ // UI component knows about global state implementation
+ const state = useGlobalChannelState(channelId)
+ const { submit, updateInput } = useChannelSync(channelId)
+
+ return (
+
+ sync.updateInput(text)}
+ />
+ sync.submit()} />
+
+ )
+}
+```
+
+**Correct: state management isolated in provider**
+
+```tsx
+// Provider handles all state management details
+function ChannelProvider({
+ channelId,
+ children,
+}: {
+ channelId: string
+ children: React.ReactNode
+}) {
+ const { state, update, submit } = useGlobalChannel(channelId)
+ const inputRef = useRef(null)
+
+ return (
+
+ {children}
+
+ )
+}
+
+// UI component only knows about the context interface
+function ChannelComposer() {
+ return (
+
+
+
+
+
+
+
+ )
+}
+
+// Usage
+function Channel({ channelId }: { channelId: string }) {
+ return (
+
+
+
+ )
+}
+```
+
+**Different providers, same UI:**
+
+```tsx
+// Local state for ephemeral forms
+function ForwardMessageProvider({ children }) {
+ const [state, setState] = useState(initialState)
+ const forwardMessage = useForwardMessage()
+
+ return (
+
+ {children}
+
+ )
+}
+
+// Global synced state for channels
+function ChannelProvider({ channelId, children }) {
+ const { state, update, submit } = useGlobalChannel(channelId)
+
+ return (
+
+ {children}
+
+ )
+}
+```
+
+The same `Composer.Input` component works with both providers because it only
+
+depends on the context interface, not the implementation.
+
+### 2.2 Define Generic Context Interfaces for Dependency Injection
+
+**Impact: HIGH (enables dependency-injectable state across use-cases)**
+
+Define a **generic interface** for your component context with three parts:
+
+`state`, `actions`, and `meta`. This interface is a contract that any provider
+
+can implement—enabling the same UI components to work with completely different
+
+state implementations.
+
+**Core principle:** Lift state, compose internals, make state
+
+dependency-injectable.
+
+**Incorrect: UI coupled to specific state implementation**
+
+```tsx
+function ComposerInput() {
+ // Tightly coupled to a specific hook
+ const { input, setInput } = useChannelComposerState()
+ return
+}
+```
+
+**Correct: generic interface enables dependency injection**
+
+```tsx
+// Define a GENERIC interface that any provider can implement
+interface ComposerState {
+ input: string
+ attachments: Attachment[]
+ isSubmitting: boolean
+}
+
+interface ComposerActions {
+ update: (updater: (state: ComposerState) => ComposerState) => void
+ submit: () => void
+}
+
+interface ComposerMeta {
+ inputRef: React.RefObject
+}
+
+interface ComposerContextValue {
+ state: ComposerState
+ actions: ComposerActions
+ meta: ComposerMeta
+}
+
+const ComposerContext = createContext(null)
+```
+
+**UI components consume the interface, not the implementation:**
+
+```tsx
+function ComposerInput() {
+ const {
+ state,
+ actions: { update },
+ meta,
+ } = use(ComposerContext)
+
+ // This component works with ANY provider that implements the interface
+ return (
+ update((s) => ({ ...s, input: text }))}
+ />
+ )
+}
+```
+
+**Different providers implement the same interface:**
+
+```tsx
+// Provider A: Local state for ephemeral forms
+function ForwardMessageProvider({ children }: { children: React.ReactNode }) {
+ const [state, setState] = useState(initialState)
+ const inputRef = useRef(null)
+ const submit = useForwardMessage()
+
+ return (
+
+ {children}
+
+ )
+}
+
+// Provider B: Global synced state for channels
+function ChannelProvider({ channelId, children }: Props) {
+ const { state, update, submit } = useGlobalChannel(channelId)
+ const inputRef = useRef(null)
+
+ return (
+
+ {children}
+
+ )
+}
+```
+
+**The same composed UI works with both:**
+
+```tsx
+// Works with ForwardMessageProvider (local state)
+
+
+
+
+
+
+
+// Works with ChannelProvider (global synced state)
+
+
+
+
+
+
+```
+
+**Custom UI outside the component can access state and actions:**
+
+```tsx
+function ForwardMessageDialog() {
+ return (
+
+
+ {/* The composer UI */}
+
+
+
+
+
+
+
+
+ {/* Custom UI OUTSIDE the composer, but INSIDE the provider */}
+
+
+ {/* Actions at the bottom of the dialog */}
+
+
+
+
+
+
+ )
+}
+
+// This button lives OUTSIDE Composer.Frame but can still submit based on its context!
+function ForwardButton() {
+ const {
+ actions: { submit },
+ } = use(ComposerContext)
+ return Forward
+}
+
+// This preview lives OUTSIDE Composer.Frame but can read composer's state!
+function MessagePreview() {
+ const { state } = use(ComposerContext)
+ return
+}
+```
+
+The provider boundary is what matters—not the visual nesting. Components that
+
+need shared state don't have to be inside the `Composer.Frame`. They just need
+
+to be within the provider.
+
+The `ForwardButton` and `MessagePreview` are not visually inside the composer
+
+box, but they can still access its state and actions. This is the power of
+
+lifting state into providers.
+
+The UI is reusable bits you compose together. The state is dependency-injected
+
+by the provider. Swap the provider, keep the UI.
+
+### 2.3 Lift State into Provider Components
+
+**Impact: HIGH (enables state sharing outside component boundaries)**
+
+Move state management into dedicated provider components. This allows sibling
+
+components outside the main UI to access and modify state without prop drilling
+
+or awkward refs.
+
+**Incorrect: state trapped inside component**
+
+```tsx
+function ForwardMessageComposer() {
+ const [state, setState] = useState(initialState)
+ const forwardMessage = useForwardMessage()
+
+ return (
+
+
+
+
+ )
+}
+
+// Problem: How does this button access composer state?
+function ForwardMessageDialog() {
+ return (
+
+
+ {/* Needs composer state */}
+
+
+ {/* Needs to call submit */}
+
+
+ )
+}
+```
+
+**Incorrect: useEffect to sync state up**
+
+```tsx
+function ForwardMessageDialog() {
+ const [input, setInput] = useState('')
+ return (
+
+
+
+
+ )
+}
+
+function ForwardMessageComposer({ onInputChange }) {
+ const [state, setState] = useState(initialState)
+ useEffect(() => {
+ onInputChange(state.input) // Sync on every change 😬
+ }, [state.input])
+}
+```
+
+**Incorrect: reading state from ref on submit**
+
+```tsx
+function ForwardMessageDialog() {
+ const stateRef = useRef(null)
+ return (
+
+
+ submit(stateRef.current)} />
+
+ )
+}
+```
+
+**Correct: state lifted to provider**
+
+```tsx
+function ForwardMessageProvider({ children }: { children: React.ReactNode }) {
+ const [state, setState] = useState(initialState)
+ const forwardMessage = useForwardMessage()
+ const inputRef = useRef(null)
+
+ return (
+
+ {children}
+
+ )
+}
+
+function ForwardMessageDialog() {
+ return (
+
+
+
+ {/* Custom components can access state and actions */}
+
+
+ {/* Custom components can access state and actions */}
+
+
+
+ )
+}
+
+function ForwardButton() {
+ const { actions } = use(Composer.Context)
+ return Forward
+}
+```
+
+The ForwardButton lives outside the Composer.Frame but still has access to the
+
+submit action because it's within the provider. Even though it's a one-off
+
+component, it can still access the composer's state and actions from outside the
+
+UI itself.
+
+**Key insight:** Components that need shared state don't have to be visually
+
+nested inside each other—they just need to be within the same provider.
+
+---
+
+## 3. Implementation Patterns
+
+**Impact: MEDIUM**
+
+Specific techniques for implementing compound components and
+context providers.
+
+### 3.1 Create Explicit Component Variants
+
+**Impact: MEDIUM (self-documenting code, no hidden conditionals)**
+
+Instead of one component with many boolean props, create explicit variant
+
+components. Each variant composes the pieces it needs. The code documents
+
+itself.
+
+**Incorrect: one component, many modes**
+
+```tsx
+// What does this component actually render?
+
+```
+
+**Correct: explicit variants**
+
+```tsx
+// Immediately clear what this renders
+
+
+// Or
+
+
+// Or
+
+```
+
+Each implementation is unique, explicit and self-contained. Yet they can each
+
+use shared parts.
+
+**Implementation:**
+
+```tsx
+function ThreadComposer({ channelId }: { channelId: string }) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+function EditMessageComposer({ messageId }: { messageId: string }) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+function ForwardMessageComposer({ messageId }: { messageId: string }) {
+ return (
+
+
+
+
+
+
+
+
+
+
+ )
+}
+```
+
+Each variant is explicit about:
+
+- What provider/state it uses
+
+- What UI elements it includes
+
+- What actions are available
+
+No boolean prop combinations to reason about. No impossible states.
+
+### 3.2 Prefer Composing Children Over Render Props
+
+**Impact: MEDIUM (cleaner composition, better readability)**
+
+Use `children` for composition instead of `renderX` props. Children are more
+
+readable, compose naturally, and don't require understanding callback
+
+signatures.
+
+**Incorrect: render props**
+
+```tsx
+function Composer({
+ renderHeader,
+ renderFooter,
+ renderActions,
+}: {
+ renderHeader?: () => React.ReactNode
+ renderFooter?: () => React.ReactNode
+ renderActions?: () => React.ReactNode
+}) {
+ return (
+
+ )
+}
+
+// Usage is awkward and inflexible
+return (
+ }
+ renderFooter={() => (
+ <>
+
+
+ >
+ )}
+ renderActions={() => }
+ />
+)
+```
+
+**Correct: compound components with children**
+
+```tsx
+function ComposerFrame({ children }: { children: React.ReactNode }) {
+ return
+}
+
+function ComposerFooter({ children }: { children: React.ReactNode }) {
+ return
+}
+
+// Usage is flexible
+return (
+
+
+
+
+
+
+
+
+
+)
+```
+
+**When render props are appropriate:**
+
+```tsx
+// Render props work well when you need to pass data back
+ }
+/>
+```
+
+Use render props when the parent needs to provide data or state to the child.
+
+Use children when composing static structure.
+
+---
+
+## 4. React 19 APIs
+
+**Impact: MEDIUM**
+
+React 19+ only. Don't use `forwardRef`; use `use()` instead of `useContext()`.
+
+### 4.1 React 19 API Changes
+
+**Impact: MEDIUM (cleaner component definitions and context usage)**
+
+> **⚠️ React 19+ only.** Skip this if you're on React 18 or earlier.
+
+In React 19, `ref` is now a regular prop (no `forwardRef` wrapper needed), and `use()` replaces `useContext()`.
+
+**Incorrect: forwardRef in React 19**
+
+```tsx
+const ComposerInput = forwardRef((props, ref) => {
+ return
+})
+```
+
+**Correct: ref as a regular prop**
+
+```tsx
+function ComposerInput({ ref, ...props }: Props & { ref?: React.Ref }) {
+ return
+}
+```
+
+**Incorrect: useContext in React 19**
+
+```tsx
+const value = useContext(MyContext)
+```
+
+**Correct: use instead of useContext**
+
+```tsx
+const value = use(MyContext)
+```
+
+`use()` can also be called conditionally, unlike `useContext()`.
+
+---
+
+## References
+
+1. [https://react.dev](https://react.dev)
+2. [https://react.dev/learn/passing-data-deeply-with-context](https://react.dev/learn/passing-data-deeply-with-context)
+3. [https://react.dev/reference/react/use](https://react.dev/reference/react/use)
diff --git a/skills/vercel-composition-patterns/LICENSE b/skills/vercel-composition-patterns/LICENSE
new file mode 100644
index 0000000..1bba46f
--- /dev/null
+++ b/skills/vercel-composition-patterns/LICENSE
@@ -0,0 +1,7 @@
+Copyright © 2026 Vercel
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/skills/vercel-composition-patterns/SKILL.md b/skills/vercel-composition-patterns/SKILL.md
new file mode 100644
index 0000000..0afe42c
--- /dev/null
+++ b/skills/vercel-composition-patterns/SKILL.md
@@ -0,0 +1,93 @@
+---
+name: vercel-composition-patterns
+description:
+ React composition patterns that scale. Use when refactoring components with
+ boolean prop proliferation, building flexible component libraries, or
+ designing reusable APIs. Triggers on tasks involving compound components,
+ render props, context providers, or component architecture. Includes React 19
+ API changes.
+license: MIT
+metadata:
+ category: development
+ author: vercel
+ version: '1.0.0'
+ source:
+ repository: https://github.com/vercel-labs/agent-skills
+ path: skills/composition-patterns
+---
+
+# React Composition Patterns
+
+Composition patterns for building flexible, maintainable React components. Avoid
+boolean prop proliferation by using compound components, lifting state, and
+composing internals. These patterns make codebases easier for both humans and AI
+agents to work with as they scale.
+
+## When to Apply
+
+Reference these guidelines when:
+
+- Refactoring components with many boolean props
+- Building reusable component libraries
+- Designing flexible component APIs
+- Reviewing component architecture
+- Working with compound components or context providers
+
+## Rule Categories by Priority
+
+| Priority | Category | Impact | Prefix |
+| -------- | ----------------------- | ------ | --------------- |
+| 1 | Component Architecture | HIGH | `architecture-` |
+| 2 | State Management | MEDIUM | `state-` |
+| 3 | Implementation Patterns | MEDIUM | `patterns-` |
+| 4 | React 19 APIs | MEDIUM | `react19-` |
+
+## Quick Reference
+
+### 1. Component Architecture (HIGH)
+
+- `architecture-avoid-boolean-props` - Don't add boolean props to customize
+ behavior; use composition
+- `architecture-compound-components` - Structure complex components with shared
+ context
+
+### 2. State Management (MEDIUM)
+
+- `state-decouple-implementation` - Provider is the only place that knows how
+ state is managed
+- `state-context-interface` - Define generic interface with state, actions, meta
+ for dependency injection
+- `state-lift-state` - Move state into provider components for sibling access
+
+### 3. Implementation Patterns (MEDIUM)
+
+- `patterns-explicit-variants` - Create explicit variant components instead of
+ boolean modes
+- `patterns-children-over-render-props` - Use children for composition instead
+ of renderX props
+
+### 4. React 19 APIs (MEDIUM)
+
+> **⚠️ React 19+ only.** Skip this section if using React 18 or earlier.
+
+- `react19-no-forwardref` - Don't use `forwardRef`; use `use()` instead of `useContext()`
+
+## How to Use
+
+Read individual rule files for detailed explanations and code examples:
+
+```
+rules/architecture-avoid-boolean-props.md
+rules/state-context-interface.md
+```
+
+Each rule file contains:
+
+- Brief explanation of why it matters
+- Incorrect code example with explanation
+- Correct code example with explanation
+- Additional context and references
+
+## Full Compiled Document
+
+For the complete guide with all rules expanded: `AGENTS.md`
diff --git a/skills/vercel-composition-patterns/rules/architecture-avoid-boolean-props.md b/skills/vercel-composition-patterns/rules/architecture-avoid-boolean-props.md
new file mode 100644
index 0000000..bd02bab
--- /dev/null
+++ b/skills/vercel-composition-patterns/rules/architecture-avoid-boolean-props.md
@@ -0,0 +1,100 @@
+---
+title: Avoid Boolean Prop Proliferation
+impact: CRITICAL
+impactDescription: prevents unmaintainable component variants
+tags: composition, props, architecture
+---
+
+## Avoid Boolean Prop Proliferation
+
+Don't add boolean props like `isThread`, `isEditing`, `isDMThread` to customize
+component behavior. Each boolean doubles possible states and creates
+unmaintainable conditional logic. Use composition instead.
+
+**Incorrect (boolean props create exponential complexity):**
+
+```tsx
+function Composer({
+ onSubmit,
+ isThread,
+ channelId,
+ isDMThread,
+ dmId,
+ isEditing,
+ isForwarding,
+}: Props) {
+ return (
+
+ )
+}
+```
+
+**Correct (composition eliminates conditionals):**
+
+```tsx
+// Channel composer
+function ChannelComposer() {
+ return (
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+// Thread composer - adds "also send to channel" field
+function ThreadComposer({ channelId }: { channelId: string }) {
+ return (
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+// Edit composer - different footer actions
+function EditComposer() {
+ return (
+
+
+
+
+
+
+
+
+
+ )
+}
+```
+
+Each variant is explicit about what it renders. We can share internals without
+sharing a single monolithic parent.
diff --git a/skills/vercel-composition-patterns/rules/architecture-compound-components.md b/skills/vercel-composition-patterns/rules/architecture-compound-components.md
new file mode 100644
index 0000000..a66b93d
--- /dev/null
+++ b/skills/vercel-composition-patterns/rules/architecture-compound-components.md
@@ -0,0 +1,112 @@
+---
+title: Use Compound Components
+impact: HIGH
+impactDescription: enables flexible composition without prop drilling
+tags: composition, compound-components, architecture
+---
+
+## Use Compound Components
+
+Structure complex components as compound components with a shared context. Each
+subcomponent accesses shared state via context, not props. Consumers compose the
+pieces they need.
+
+**Incorrect (monolithic component with render props):**
+
+```tsx
+function Composer({
+ renderHeader,
+ renderFooter,
+ renderActions,
+ showAttachments,
+ showFormatting,
+ showEmojis,
+}: Props) {
+ return (
+
+ )
+}
+```
+
+**Correct (compound components with shared context):**
+
+```tsx
+const ComposerContext = createContext(null)
+
+function ComposerProvider({ children, state, actions, meta }: ProviderProps) {
+ return (
+
+ {children}
+
+ )
+}
+
+function ComposerFrame({ children }: { children: React.ReactNode }) {
+ return
+}
+
+function ComposerInput() {
+ const {
+ state,
+ actions: { update },
+ meta: { inputRef },
+ } = use(ComposerContext)
+ return (
+ update((s) => ({ ...s, input: text }))}
+ />
+ )
+}
+
+function ComposerSubmit() {
+ const {
+ actions: { submit },
+ } = use(ComposerContext)
+ return Send
+}
+
+// Export as compound component
+const Composer = {
+ Provider: ComposerProvider,
+ Frame: ComposerFrame,
+ Input: ComposerInput,
+ Submit: ComposerSubmit,
+ Header: ComposerHeader,
+ Footer: ComposerFooter,
+ Attachments: ComposerAttachments,
+ Formatting: ComposerFormatting,
+ Emojis: ComposerEmojis,
+}
+```
+
+**Usage:**
+
+```tsx
+
+
+
+
+
+
+
+
+
+
+```
+
+Consumers explicitly compose exactly what they need. No hidden conditionals. And the state, actions and meta are dependency-injected by a parent provider, allowing multiple usages of the same component structure.
diff --git a/skills/vercel-composition-patterns/rules/patterns-children-over-render-props.md b/skills/vercel-composition-patterns/rules/patterns-children-over-render-props.md
new file mode 100644
index 0000000..61d905a
--- /dev/null
+++ b/skills/vercel-composition-patterns/rules/patterns-children-over-render-props.md
@@ -0,0 +1,87 @@
+---
+title: Prefer Composing Children Over Render Props
+impact: MEDIUM
+impactDescription: cleaner composition, better readability
+tags: composition, children, render-props
+---
+
+## Prefer Children Over Render Props
+
+Use `children` for composition instead of `renderX` props. Children are more
+readable, compose naturally, and don't require understanding callback
+signatures.
+
+**Incorrect (render props):**
+
+```tsx
+function Composer({
+ renderHeader,
+ renderFooter,
+ renderActions,
+}: {
+ renderHeader?: () => React.ReactNode
+ renderFooter?: () => React.ReactNode
+ renderActions?: () => React.ReactNode
+}) {
+ return (
+
+ )
+}
+
+// Usage is awkward and inflexible
+return (
+ }
+ renderFooter={() => (
+ <>
+
+
+ >
+ )}
+ renderActions={() => }
+ />
+)
+```
+
+**Correct (compound components with children):**
+
+```tsx
+function ComposerFrame({ children }: { children: React.ReactNode }) {
+ return
+}
+
+function ComposerFooter({ children }: { children: React.ReactNode }) {
+ return
+}
+
+// Usage is flexible
+return (
+
+
+
+
+
+
+
+
+
+)
+```
+
+**When render props are appropriate:**
+
+```tsx
+// Render props work well when you need to pass data back
+ }
+/>
+```
+
+Use render props when the parent needs to provide data or state to the child.
+Use children when composing static structure.
diff --git a/skills/vercel-composition-patterns/rules/patterns-explicit-variants.md b/skills/vercel-composition-patterns/rules/patterns-explicit-variants.md
new file mode 100644
index 0000000..7024997
--- /dev/null
+++ b/skills/vercel-composition-patterns/rules/patterns-explicit-variants.md
@@ -0,0 +1,100 @@
+---
+title: Create Explicit Component Variants
+impact: MEDIUM
+impactDescription: self-documenting code, no hidden conditionals
+tags: composition, variants, architecture
+---
+
+## Create Explicit Component Variants
+
+Instead of one component with many boolean props, create explicit variant
+components. Each variant composes the pieces it needs. The code documents
+itself.
+
+**Incorrect (one component, many modes):**
+
+```tsx
+// What does this component actually render?
+
+```
+
+**Correct (explicit variants):**
+
+```tsx
+// Immediately clear what this renders
+
+
+// Or
+
+
+// Or
+
+```
+
+Each implementation is unique, explicit and self-contained. Yet they can each
+use shared parts.
+
+**Implementation:**
+
+```tsx
+function ThreadComposer({ channelId }: { channelId: string }) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+function EditMessageComposer({ messageId }: { messageId: string }) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+function ForwardMessageComposer({ messageId }: { messageId: string }) {
+ return (
+
+
+
+
+
+
+
+
+
+
+ )
+}
+```
+
+Each variant is explicit about:
+
+- What provider/state it uses
+- What UI elements it includes
+- What actions are available
+
+No boolean prop combinations to reason about. No impossible states.
diff --git a/skills/vercel-composition-patterns/rules/react19-no-forwardref.md b/skills/vercel-composition-patterns/rules/react19-no-forwardref.md
new file mode 100644
index 0000000..956c05d
--- /dev/null
+++ b/skills/vercel-composition-patterns/rules/react19-no-forwardref.md
@@ -0,0 +1,42 @@
+---
+title: React 19 API Changes
+impact: MEDIUM
+impactDescription: cleaner component definitions and context usage
+tags: react19, refs, context, hooks
+---
+
+## React 19 API Changes
+
+> **⚠️ React 19+ only.** Skip this if you're on React 18 or earlier.
+
+In React 19, `ref` is now a regular prop (no `forwardRef` wrapper needed), and `use()` replaces `useContext()`.
+
+**Incorrect (forwardRef in React 19):**
+
+```tsx
+const ComposerInput = forwardRef((props, ref) => {
+ return
+})
+```
+
+**Correct (ref as a regular prop):**
+
+```tsx
+function ComposerInput({ ref, ...props }: Props & { ref?: React.Ref }) {
+ return
+}
+```
+
+**Incorrect (useContext in React 19):**
+
+```tsx
+const value = useContext(MyContext)
+```
+
+**Correct (use instead of useContext):**
+
+```tsx
+const value = use(MyContext)
+```
+
+`use()` can also be called conditionally, unlike `useContext()`.
diff --git a/skills/vercel-composition-patterns/rules/state-context-interface.md b/skills/vercel-composition-patterns/rules/state-context-interface.md
new file mode 100644
index 0000000..7157de2
--- /dev/null
+++ b/skills/vercel-composition-patterns/rules/state-context-interface.md
@@ -0,0 +1,191 @@
+---
+title: Define Generic Context Interfaces for Dependency Injection
+impact: HIGH
+impactDescription: enables dependency-injectable state across use-cases
+tags: composition, context, state, typescript, dependency-injection
+---
+
+## Define Generic Context Interfaces for Dependency Injection
+
+Define a **generic interface** for your component context with three parts:
+`state`, `actions`, and `meta`. This interface is a contract that any provider
+can implement—enabling the same UI components to work with completely different
+state implementations.
+
+**Core principle:** Lift state, compose internals, make state
+dependency-injectable.
+
+**Incorrect (UI coupled to specific state implementation):**
+
+```tsx
+function ComposerInput() {
+ // Tightly coupled to a specific hook
+ const { input, setInput } = useChannelComposerState()
+ return
+}
+```
+
+**Correct (generic interface enables dependency injection):**
+
+```tsx
+// Define a GENERIC interface that any provider can implement
+interface ComposerState {
+ input: string
+ attachments: Attachment[]
+ isSubmitting: boolean
+}
+
+interface ComposerActions {
+ update: (updater: (state: ComposerState) => ComposerState) => void
+ submit: () => void
+}
+
+interface ComposerMeta {
+ inputRef: React.RefObject
+}
+
+interface ComposerContextValue {
+ state: ComposerState
+ actions: ComposerActions
+ meta: ComposerMeta
+}
+
+const ComposerContext = createContext(null)
+```
+
+**UI components consume the interface, not the implementation:**
+
+```tsx
+function ComposerInput() {
+ const {
+ state,
+ actions: { update },
+ meta,
+ } = use(ComposerContext)
+
+ // This component works with ANY provider that implements the interface
+ return (
+