claude-toolkit 0.1.25 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -0
- package/README.md +7 -6
- package/core/hooks/skill-eval.js +3 -20
- package/core/skills/ct-testing-patterns/SKILL.md +12 -0
- package/package.json +1 -1
- package/src/detect.ts +204 -71
- package/stacks/capacitor/skills/ct-capacitor-ota/SKILL.md +16 -0
- package/stacks/capacitor/skills/ct-capacitor-ui/SKILL.md +66 -0
- package/stacks/capacitor/stack.json +47 -1
- package/stacks/cloudflare/skills/ct-cloudflare-d1-kv/SKILL.md +86 -0
- package/stacks/i18n-typesafe/skills/ct-i18n-typesafe/SKILL.md +52 -0
- package/stacks/i18n-typesafe/stack.json +2 -1
- package/stacks/playwright/skills/ct-playwright-patterns/SKILL.md +70 -2
- package/stacks/protobuf/skills/ct-protobuf-contracts/SKILL.md +36 -0
- package/stacks/rust-wasm/skills/ct-rust-wasm-patterns/SKILL.md +62 -0
- package/stacks/solidjs/skills/ct-solidjs-patterns/SKILL.md +70 -0
- package/stacks/solidjs/stack.json +2 -1
- package/stacks/storybook/skills/ct-storybook-patterns/SKILL.md +62 -0
- package/stacks/vanilla-extract/skills/ct-vanilla-extract-patterns/SKILL.md +62 -0
- package/stacks/vanilla-extract/stack.json +1 -1
- package/stacks/vite/skills/ct-vite-vitest-patterns/SKILL.md +71 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,47 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.0 (2026-06-08)
|
|
4
|
+
|
|
5
|
+
Re-baselined from 0.1.x to reflect accumulated scope: 10 stack connectors, the full `init`/`update`/`sync` CLI, stack auto-detection with drift and monorepo/workspace support, and a complete skill/command/agent/hook system. Versioning is now conventional-commit-driven from this release onward.
|
|
6
|
+
|
|
7
|
+
- feat: render/runtime-speed guidance across every stack skill, each paired with a security guardrail
|
|
8
|
+
- feat: new `ct-capacitor-ui` skill for webview performance and native feel
|
|
9
|
+
- feat: canonical test-speed rule in core testing skill + cross-stack `relatedSkills` wiring
|
|
10
|
+
- build: conventional-commit-aware versioning (feat→minor, fix/perf→patch, breaking→major)
|
|
11
|
+
- ci: automated npm publish on GitHub release
|
|
12
|
+
|
|
13
|
+
## 0.1.33 (2026-06-07)
|
|
14
|
+
|
|
15
|
+
- chore: apply biome formatting to skill-eval hook
|
|
16
|
+
|
|
17
|
+
## 0.1.32 (2026-06-07)
|
|
18
|
+
|
|
19
|
+
- feat: add ct-capacitor-ui skill for webview performance and native feel
|
|
20
|
+
|
|
21
|
+
## 0.1.31 (2026-06-07)
|
|
22
|
+
|
|
23
|
+
- feat: add i18n performance guidance and wire relatedSkills to solidjs/vanilla-extract
|
|
24
|
+
|
|
25
|
+
## 0.1.30 (2026-06-07)
|
|
26
|
+
|
|
27
|
+
- feat: add test-speed guidance to vite, playwright, storybook, and core testing skill
|
|
28
|
+
|
|
29
|
+
## 0.1.29 (2026-06-07)
|
|
30
|
+
|
|
31
|
+
- feat: add performance guidance to cloudflare, rust-wasm, and protobuf skills
|
|
32
|
+
|
|
33
|
+
## 0.1.28 (2026-06-07)
|
|
34
|
+
|
|
35
|
+
- feat: add render-speed guidance to solidjs and vanilla-extract skills
|
|
36
|
+
|
|
37
|
+
## 0.1.27 (2026-06-01)
|
|
38
|
+
|
|
39
|
+
- feat: detect stacks across workspace packages and monorepo subdirectories
|
|
40
|
+
|
|
41
|
+
## 0.1.26 (2026-06-01)
|
|
42
|
+
|
|
43
|
+
- docs: document update command in README CLI commands table
|
|
44
|
+
|
|
3
45
|
## 0.1.25 (2026-06-01)
|
|
4
46
|
|
|
5
47
|
- feat: add update command to sync detected stacks into existing config
|
package/README.md
CHANGED
|
@@ -45,11 +45,12 @@ export default defineConfig({
|
|
|
45
45
|
|
|
46
46
|
## CLI Commands
|
|
47
47
|
|
|
48
|
-
| Command
|
|
49
|
-
|
|
|
50
|
-
| `bunx claude-toolkit init`
|
|
51
|
-
| `bunx claude-toolkit
|
|
52
|
-
| `bunx claude-toolkit
|
|
48
|
+
| Command | Description |
|
|
49
|
+
| ---------------------------- | ---------------------------------------------------- |
|
|
50
|
+
| `bunx claude-toolkit init` | Scaffold config and generate `.claude/` (first run) |
|
|
51
|
+
| `bunx claude-toolkit update` | Add newly detected stacks to config, then regenerate |
|
|
52
|
+
| `bunx claude-toolkit sync` | Regenerate `.claude/` from the current config |
|
|
53
|
+
| `bunx claude-toolkit help` | Show available commands |
|
|
53
54
|
|
|
54
55
|
## Stack Auto-Detection
|
|
55
56
|
|
|
@@ -108,7 +109,7 @@ This keeps your config aligned as your project evolves — `init` for first-time
|
|
|
108
109
|
| `i18n-typesafe` | typesafe-i18n internationalization |
|
|
109
110
|
| `playwright` | Playwright E2E testing, Page Objects, fixtures, CI/CD |
|
|
110
111
|
| `storybook` | Storybook interaction testing, CSF 3, visual regression |
|
|
111
|
-
| `capacitor` | Capacitor 8
|
|
112
|
+
| `capacitor` | Capacitor 8 runtime, Capgo OTA, channels; webview UI & native feel |
|
|
112
113
|
|
|
113
114
|
## Core Features (always included)
|
|
114
115
|
|
package/core/hooks/skill-eval.js
CHANGED
|
@@ -129,14 +129,7 @@ function matchDirectoryMapping(filePath, mappings) {
|
|
|
129
129
|
/**
|
|
130
130
|
* Evaluate a single skill against the prompt and context
|
|
131
131
|
*/
|
|
132
|
-
function evaluateSkill(
|
|
133
|
-
skillName,
|
|
134
|
-
skill,
|
|
135
|
-
prompt,
|
|
136
|
-
promptLower,
|
|
137
|
-
filePaths,
|
|
138
|
-
rules,
|
|
139
|
-
) {
|
|
132
|
+
function evaluateSkill(skillName, skill, prompt, promptLower, filePaths, rules) {
|
|
140
133
|
const { triggers = {}, excludePatterns = [], priority = 5 } = skill;
|
|
141
134
|
const scoring = rules.scoring;
|
|
142
135
|
|
|
@@ -207,10 +200,7 @@ function evaluateSkill(
|
|
|
207
200
|
// 6. Check directory mappings
|
|
208
201
|
if (rules.directoryMappings && filePaths.length > 0) {
|
|
209
202
|
for (const filePath of filePaths) {
|
|
210
|
-
const mappedSkill = matchDirectoryMapping(
|
|
211
|
-
filePath,
|
|
212
|
-
rules.directoryMappings,
|
|
213
|
-
);
|
|
203
|
+
const mappedSkill = matchDirectoryMapping(filePath, rules.directoryMappings);
|
|
214
204
|
if (mappedSkill === skillName) {
|
|
215
205
|
score += scoring.directoryMatch;
|
|
216
206
|
reasons.push(`directory mapping`);
|
|
@@ -279,14 +269,7 @@ function evaluate(prompt) {
|
|
|
279
269
|
|
|
280
270
|
const matches = [];
|
|
281
271
|
for (const [name, skill] of Object.entries(skills)) {
|
|
282
|
-
const match = evaluateSkill(
|
|
283
|
-
name,
|
|
284
|
-
skill,
|
|
285
|
-
prompt,
|
|
286
|
-
promptLower,
|
|
287
|
-
filePaths,
|
|
288
|
-
rules,
|
|
289
|
-
);
|
|
272
|
+
const match = evaluateSkill(name, skill, prompt, promptLower, filePaths, rules);
|
|
290
273
|
if (match && match.score >= config.minConfidenceScore) {
|
|
291
274
|
matches.push(match);
|
|
292
275
|
}
|
|
@@ -107,6 +107,18 @@ Bun supports `describe`, `it`/`test`, `expect`, lifecycle hooks, and snapshot te
|
|
|
107
107
|
|
|
108
108
|
See 3-Layer Testing Strategy above. Many unit tests (fast, focused) > some interaction tests (component sandbox) > few E2E tests (full flow). Aim for 70/20/10 distribution.
|
|
109
109
|
|
|
110
|
+
## Test Speed
|
|
111
|
+
|
|
112
|
+
Three levers recur across every runner (Vitest, Playwright, Storybook browser mode). Stated once here; each stack skill carries the framework-specific syntax.
|
|
113
|
+
|
|
114
|
+
- **Parallelism sized to the runner.** Enable file-level parallelism (Vitest `fileParallelism`, Playwright `fullyParallel`), but cap workers to the runner's *real* vCPUs (`"50%"` or a measured count). GitHub standard runners are 2 vCPU (private) / 4 (public); oversubscribing thrashes.
|
|
115
|
+
- **Shard across CI jobs, then merge the blob reports.** Sharding splits at the *file* level (`--shard=i/n`), so wall-clock is gated by the slowest shard -- balance file sizes. Always emit a blob report per shard (`--reporter=blob`) and merge it (`merge-reports` / `--merge-reports`) in a dependent job, or results and coverage are fragmented and incomplete.
|
|
116
|
+
- **Skip isolation where state is clean.** Per-file environment isolation is the safe default but the biggest run-speed cost. Disable it (Vitest `isolate: false`, `pool: 'threads'`) only for side-effect-free logic suites; keep it where tests mutate globals/env/timers.
|
|
117
|
+
|
|
118
|
+
Profile before tuning, and never `sleep` to mask timing -- use the framework's auto-waiting (see Shared Principles).
|
|
119
|
+
|
|
120
|
+
Per-runner config: `ct-vite-vitest-patterns`, `ct-playwright-patterns`, `ct-storybook-patterns`.
|
|
121
|
+
|
|
110
122
|
## Anti-Patterns
|
|
111
123
|
|
|
112
124
|
1. **Testing implementation** -- Asserting internal state or call counts breaks on refactors.
|
package/package.json
CHANGED
package/src/detect.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import type { StackName } from "./types.js";
|
|
4
4
|
|
|
@@ -11,15 +11,51 @@ export interface DetectedStack {
|
|
|
11
11
|
interface PackageJson {
|
|
12
12
|
dependencies?: Record<string, string>;
|
|
13
13
|
devDependencies?: Record<string, string>;
|
|
14
|
+
workspaces?: string[] | { packages?: string[] };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A directory to inspect for stack markers, with its project-relative label */
|
|
18
|
+
interface ScanRoot {
|
|
19
|
+
/** Absolute path */
|
|
20
|
+
dir: string;
|
|
21
|
+
/** Project-relative path ("." for the project root) */
|
|
22
|
+
rel: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Pre-resolved view of the project: every package and directory worth scanning */
|
|
26
|
+
interface DetectContext {
|
|
27
|
+
projectDir: string;
|
|
28
|
+
/** Package.json files found at the root and across workspaces/subdirs */
|
|
29
|
+
packages: { rel: string; pkg: PackageJson }[];
|
|
30
|
+
/** Directories to check for marker files (root + subdirs + workspace packages) */
|
|
31
|
+
scanRoots: ScanRoot[];
|
|
14
32
|
}
|
|
15
33
|
|
|
16
34
|
interface StackDetector {
|
|
17
35
|
name: StackName;
|
|
18
|
-
detect: (
|
|
36
|
+
detect: (ctx: DetectContext) => DetectedStack | null;
|
|
19
37
|
}
|
|
20
38
|
|
|
21
|
-
|
|
22
|
-
|
|
39
|
+
/** Directories never worth scanning into */
|
|
40
|
+
const IGNORE_DIRS = new Set([
|
|
41
|
+
"node_modules",
|
|
42
|
+
"dist",
|
|
43
|
+
"build",
|
|
44
|
+
"out",
|
|
45
|
+
"target",
|
|
46
|
+
"coverage",
|
|
47
|
+
".git",
|
|
48
|
+
".wrangler",
|
|
49
|
+
".next",
|
|
50
|
+
".cache",
|
|
51
|
+
".vercel",
|
|
52
|
+
".turbo",
|
|
53
|
+
"playwright-report",
|
|
54
|
+
"test-results",
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
function readPackageJson(dir: string): PackageJson | null {
|
|
58
|
+
const pkgPath = join(dir, "package.json");
|
|
23
59
|
if (!existsSync(pkgPath)) return null;
|
|
24
60
|
try {
|
|
25
61
|
return JSON.parse(readFileSync(pkgPath, "utf-8")) as PackageJson;
|
|
@@ -28,19 +64,112 @@ function loadPackageJson(projectDir: string): PackageJson | null {
|
|
|
28
64
|
}
|
|
29
65
|
}
|
|
30
66
|
|
|
31
|
-
|
|
32
|
-
|
|
67
|
+
/** Immediate, non-ignored subdirectory names of a directory */
|
|
68
|
+
function listSubdirs(dir: string): string[] {
|
|
69
|
+
try {
|
|
70
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
71
|
+
.filter((e) => e.isDirectory() && !IGNORE_DIRS.has(e.name) && !e.name.startsWith("."))
|
|
72
|
+
.map((e) => e.name);
|
|
73
|
+
} catch {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Resolve workspace package directories (project-relative) from the root package.json */
|
|
79
|
+
function resolveWorkspaceDirs(projectDir: string, rootPkg: PackageJson | null): string[] {
|
|
80
|
+
const ws = rootPkg?.workspaces;
|
|
81
|
+
const patterns = Array.isArray(ws) ? ws : (ws?.packages ?? []);
|
|
82
|
+
const dirs: string[] = [];
|
|
83
|
+
for (const pattern of patterns) {
|
|
84
|
+
if (pattern.includes("*")) {
|
|
85
|
+
// Resolve a glob like "packages/*" or "apps/*" to concrete package dirs
|
|
86
|
+
const glob = new Bun.Glob(`${pattern}/package.json`);
|
|
87
|
+
for (const match of glob.scanSync({ cwd: projectDir, onlyFiles: true })) {
|
|
88
|
+
dirs.push(match.replace(/[/\\]package\.json$/, "").replace(/\\/g, "/"));
|
|
89
|
+
}
|
|
90
|
+
} else {
|
|
91
|
+
dirs.push(pattern.replace(/\\/g, "/"));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return dirs;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Build the set of directories and package.json files worth inspecting */
|
|
98
|
+
function buildContext(projectDir: string): DetectContext {
|
|
99
|
+
const rootPkg = readPackageJson(projectDir);
|
|
100
|
+
|
|
101
|
+
// Root + immediate subdirs + workspace packages (deduped)
|
|
102
|
+
const rels = new Set<string>(["."]);
|
|
103
|
+
for (const name of listSubdirs(projectDir)) rels.add(name);
|
|
104
|
+
for (const dir of resolveWorkspaceDirs(projectDir, rootPkg)) rels.add(dir);
|
|
105
|
+
|
|
106
|
+
const scanRoots: ScanRoot[] = [...rels].map((rel) => ({
|
|
107
|
+
rel,
|
|
108
|
+
dir: rel === "." ? projectDir : join(projectDir, rel),
|
|
109
|
+
}));
|
|
110
|
+
|
|
111
|
+
const packages: { rel: string; pkg: PackageJson }[] = [];
|
|
112
|
+
for (const { rel, dir } of scanRoots) {
|
|
113
|
+
const pkg = rel === "." ? rootPkg : readPackageJson(dir);
|
|
114
|
+
if (pkg) packages.push({ rel, pkg });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return { projectDir, packages, scanRoots };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function pkgHasDep(pkg: PackageJson, name: string): boolean {
|
|
33
121
|
return name in (pkg.dependencies ?? {}) || name in (pkg.devDependencies ?? {});
|
|
34
122
|
}
|
|
35
123
|
|
|
36
|
-
function
|
|
37
|
-
return
|
|
124
|
+
function pkgLabel(rel: string): string {
|
|
125
|
+
return rel === "." ? "root package.json" : `${rel}/package.json`;
|
|
38
126
|
}
|
|
39
127
|
|
|
40
|
-
function
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
128
|
+
function relPath(rel: string, name: string): string {
|
|
129
|
+
return rel === "." ? name : `${rel}/${name}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** First package (project-relative dir) declaring `name`, or null */
|
|
133
|
+
function findDep(ctx: DetectContext, name: string): string | null {
|
|
134
|
+
for (const { rel, pkg } of ctx.packages) {
|
|
135
|
+
if (pkgHasDep(pkg, name)) return rel;
|
|
136
|
+
}
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** First scan root containing a file/dir named `name` (top-level only), as a project-relative path */
|
|
141
|
+
function findFile(ctx: DetectContext, name: string): string | null {
|
|
142
|
+
for (const { rel, dir } of ctx.scanRoots) {
|
|
143
|
+
if (existsSync(join(dir, name))) return relPath(rel, name);
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** First scan root containing a `${prefix}.*` config file (top-level only) */
|
|
149
|
+
function findConfig(ctx: DetectContext, prefix: string): string | null {
|
|
150
|
+
for (const { rel, dir } of ctx.scanRoots) {
|
|
151
|
+
const glob = new Bun.Glob(`${prefix}.*`);
|
|
152
|
+
for (const match of glob.scanSync({ cwd: dir, onlyFiles: true })) {
|
|
153
|
+
return relPath(rel, match);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Look for `*.proto` files directly in scan roots or under a conventional `proto/` dir */
|
|
160
|
+
function findProtoFiles(ctx: DetectContext): string | null {
|
|
161
|
+
for (const { rel, dir } of ctx.scanRoots) {
|
|
162
|
+
const direct = new Bun.Glob("*.proto");
|
|
163
|
+
for (const match of direct.scanSync({ cwd: dir, onlyFiles: true })) {
|
|
164
|
+
return relPath(rel, match);
|
|
165
|
+
}
|
|
166
|
+
const protoDir = join(dir, "proto");
|
|
167
|
+
if (existsSync(protoDir)) {
|
|
168
|
+
const nested = new Bun.Glob("**/*.proto");
|
|
169
|
+
for (const match of nested.scanSync({ cwd: protoDir, onlyFiles: true })) {
|
|
170
|
+
return relPath(rel, `proto/${match}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
44
173
|
}
|
|
45
174
|
return null;
|
|
46
175
|
}
|
|
@@ -48,104 +177,108 @@ function rootConfigExists(projectDir: string, prefix: string): string | null {
|
|
|
48
177
|
const DETECTORS: StackDetector[] = [
|
|
49
178
|
{
|
|
50
179
|
name: "solidjs",
|
|
51
|
-
detect: (
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
180
|
+
detect: (ctx) => {
|
|
181
|
+
const at = findDep(ctx, "solid-js");
|
|
182
|
+
return at ? { name: "solidjs", reason: `found solid-js in ${pkgLabel(at)}` } : null;
|
|
183
|
+
},
|
|
55
184
|
},
|
|
56
185
|
{
|
|
57
186
|
name: "vite",
|
|
58
|
-
detect: (
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
if (vitestConfig) return { name: "vite", reason: `found ${vitestConfig}` };
|
|
64
|
-
return null;
|
|
187
|
+
detect: (ctx) => {
|
|
188
|
+
const dep = findDep(ctx, "vite");
|
|
189
|
+
if (dep) return { name: "vite", reason: `found vite in ${pkgLabel(dep)}` };
|
|
190
|
+
const config = findConfig(ctx, "vite.config") ?? findConfig(ctx, "vitest.config");
|
|
191
|
+
return config ? { name: "vite", reason: `found ${config}` } : null;
|
|
65
192
|
},
|
|
66
193
|
},
|
|
67
194
|
{
|
|
68
195
|
name: "vanilla-extract",
|
|
69
|
-
detect: (
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
:
|
|
196
|
+
detect: (ctx) => {
|
|
197
|
+
const at = findDep(ctx, "@vanilla-extract/css");
|
|
198
|
+
return at
|
|
199
|
+
? { name: "vanilla-extract", reason: `found @vanilla-extract/css in ${pkgLabel(at)}` }
|
|
200
|
+
: null;
|
|
201
|
+
},
|
|
73
202
|
},
|
|
74
203
|
{
|
|
75
204
|
name: "rust-wasm",
|
|
76
|
-
detect: (
|
|
77
|
-
|
|
205
|
+
detect: (ctx) => {
|
|
206
|
+
const f = findFile(ctx, "Cargo.toml");
|
|
207
|
+
return f ? { name: "rust-wasm", reason: `found ${f}` } : null;
|
|
208
|
+
},
|
|
78
209
|
},
|
|
79
210
|
{
|
|
80
211
|
name: "protobuf",
|
|
81
|
-
detect: (
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
89
|
-
return null;
|
|
212
|
+
detect: (ctx) => {
|
|
213
|
+
const buf =
|
|
214
|
+
findFile(ctx, "buf.yaml") ??
|
|
215
|
+
findFile(ctx, "buf.gen.yaml") ??
|
|
216
|
+
findFile(ctx, "buf.work.yaml");
|
|
217
|
+
if (buf) return { name: "protobuf", reason: `found ${buf}` };
|
|
218
|
+
const proto = findProtoFiles(ctx);
|
|
219
|
+
return proto ? { name: "protobuf", reason: `found ${proto}` } : null;
|
|
90
220
|
},
|
|
91
221
|
},
|
|
92
222
|
{
|
|
93
223
|
name: "cloudflare",
|
|
94
|
-
detect: (
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
return null;
|
|
224
|
+
detect: (ctx) => {
|
|
225
|
+
const f =
|
|
226
|
+
findFile(ctx, "wrangler.toml") ??
|
|
227
|
+
findFile(ctx, "wrangler.jsonc") ??
|
|
228
|
+
findFile(ctx, "wrangler.json");
|
|
229
|
+
return f ? { name: "cloudflare", reason: `found ${f}` } : null;
|
|
100
230
|
},
|
|
101
231
|
},
|
|
102
232
|
{
|
|
103
233
|
name: "i18n-typesafe",
|
|
104
|
-
detect: (
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
234
|
+
detect: (ctx) => {
|
|
235
|
+
const at = findDep(ctx, "typesafe-i18n");
|
|
236
|
+
if (at) return { name: "i18n-typesafe", reason: `found typesafe-i18n in ${pkgLabel(at)}` };
|
|
237
|
+
const f = findFile(ctx, ".typesafe-i18n.json");
|
|
238
|
+
return f ? { name: "i18n-typesafe", reason: `found ${f}` } : null;
|
|
239
|
+
},
|
|
108
240
|
},
|
|
109
241
|
{
|
|
110
242
|
name: "playwright",
|
|
111
|
-
detect: (
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const config =
|
|
115
|
-
|
|
116
|
-
return null;
|
|
243
|
+
detect: (ctx) => {
|
|
244
|
+
const dep = findDep(ctx, "@playwright/test");
|
|
245
|
+
if (dep) return { name: "playwright", reason: `found @playwright/test in ${pkgLabel(dep)}` };
|
|
246
|
+
const config = findConfig(ctx, "playwright.config");
|
|
247
|
+
return config ? { name: "playwright", reason: `found ${config}` } : null;
|
|
117
248
|
},
|
|
118
249
|
},
|
|
119
250
|
{
|
|
120
251
|
name: "storybook",
|
|
121
|
-
detect: (
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
return null;
|
|
252
|
+
detect: (ctx) => {
|
|
253
|
+
const dep = findDep(ctx, "storybook");
|
|
254
|
+
if (dep) return { name: "storybook", reason: `found storybook in ${pkgLabel(dep)}` };
|
|
255
|
+
const dir = findFile(ctx, ".storybook");
|
|
256
|
+
return dir ? { name: "storybook", reason: `found ${dir}/ directory` } : null;
|
|
127
257
|
},
|
|
128
258
|
},
|
|
129
259
|
{
|
|
130
260
|
name: "capacitor",
|
|
131
|
-
detect: (
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
261
|
+
detect: (ctx) => {
|
|
262
|
+
const capgo = findDep(ctx, "@capgo/capacitor-updater");
|
|
263
|
+
if (capgo)
|
|
264
|
+
return {
|
|
265
|
+
name: "capacitor",
|
|
266
|
+
reason: `found @capgo/capacitor-updater in ${pkgLabel(capgo)}`,
|
|
267
|
+
};
|
|
268
|
+
const core = findDep(ctx, "@capacitor/core");
|
|
269
|
+
if (core) return { name: "capacitor", reason: `found @capacitor/core in ${pkgLabel(core)}` };
|
|
270
|
+
const config = findConfig(ctx, "capacitor.config");
|
|
271
|
+
return config ? { name: "capacitor", reason: `found ${config}` } : null;
|
|
139
272
|
},
|
|
140
273
|
},
|
|
141
274
|
];
|
|
142
275
|
|
|
143
|
-
/** Scan a project directory and detect which stacks are present */
|
|
276
|
+
/** Scan a project directory (root + subdirs + workspace packages) and detect which stacks are present */
|
|
144
277
|
export function detectStacks(projectDir: string): DetectedStack[] {
|
|
145
|
-
const
|
|
278
|
+
const ctx = buildContext(projectDir);
|
|
146
279
|
const detected: DetectedStack[] = [];
|
|
147
280
|
for (const detector of DETECTORS) {
|
|
148
|
-
const result = detector.detect(
|
|
281
|
+
const result = detector.detect(ctx);
|
|
149
282
|
if (result) detected.push(result);
|
|
150
283
|
}
|
|
151
284
|
return detected;
|
|
@@ -39,6 +39,21 @@ CapacitorUpdater.notifyAppReady();
|
|
|
39
39
|
|
|
40
40
|
This is the safety net that makes OTA reversible. A bundle that white-screens never sticks.
|
|
41
41
|
|
|
42
|
+
### Boot ordering: splash vs notifyAppReady
|
|
43
|
+
|
|
44
|
+
A freshly-applied OTA bundle re-runs your bootstrap. Hide the splash *after* the first real view paints, or users see a white webview between hide and first render. Requires `launchAutoHide: false` (otherwise the OS hides the splash on its own schedule and you can't sequence it).
|
|
45
|
+
|
|
46
|
+
```typescript
|
|
47
|
+
// capacitor.config.ts → plugins.SplashScreen.launchAutoHide: false
|
|
48
|
+
import { SplashScreen } from "@capacitor/splash-screen";
|
|
49
|
+
|
|
50
|
+
CapacitorUpdater.notifyAppReady(); // fire-and-forget — never gate first paint on it
|
|
51
|
+
await firstMeaningfulView(); // await your app's first real paint
|
|
52
|
+
await SplashScreen.hide(); // only now reveal the webview
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`notifyAppReady()` returns a Promise but the 10s `appReadyTimeout` is generous — call it early and move on; do not `await` it before painting. (Webview rendering performance and native feel live in `ct-capacitor-ui`.)
|
|
56
|
+
|
|
42
57
|
## Configuration
|
|
43
58
|
|
|
44
59
|
```typescript
|
|
@@ -176,3 +191,4 @@ OTA of JS/HTML/CSS is allowed: **Apple** developer agreement §3.3.2 (since iOS
|
|
|
176
191
|
- `ct-vite-vitest-patterns` — the build that produces `webDir`
|
|
177
192
|
- `ct-playwright-patterns` — E2E-verify a bundle before you upload it
|
|
178
193
|
- `ct-typescript-conventions` — typing `capacitor.config.ts` and the updater API
|
|
194
|
+
- `ct-capacitor-ui` — webview UI performance & native feel (safe areas, touch targets, compositor-only animation)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ct-capacitor-ui
|
|
3
|
+
description: Capacitor webview UI performance and native feel — safe areas, touch targets, tap feedback, compositor-only animation, and on-device profiling
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Capacitor Webview UI & Native Feel
|
|
7
|
+
|
|
8
|
+
> _Verified against Capacitor 8 (iOS 15 floor; `content-visibility` on iOS 18+) (2026-06)._
|
|
9
|
+
|
|
10
|
+
The webview *is* the runtime. A Capacitor app lives or dies on whether it feels native — safe-area-aware, instant to tap, smooth to scroll — on a low-end phone, not on your desktop. This skill is the UI/runtime side; OTA delivery lives in `ct-capacitor-ota`.
|
|
11
|
+
|
|
12
|
+
## Performance & Native Feel
|
|
13
|
+
|
|
14
|
+
The webview *is* the runtime — budget like a low-end Android phone, not desktop Chrome. Profile on a real cheap device or throttled emulation (4x CPU, "Slow 4G"); effects that are free on a desktop GPU jank in WebView.
|
|
15
|
+
|
|
16
|
+
```html
|
|
17
|
+
<!-- index.html — required for env() safe-area tokens to resolve to non-zero -->
|
|
18
|
+
<meta name="viewport"
|
|
19
|
+
content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`width=device-width` (above) already removes the legacy ~300ms tap delay on modern iOS/Android WebViews. The block below is about *tap feel*, not latency:
|
|
23
|
+
|
|
24
|
+
```css
|
|
25
|
+
/* No grey tap-flash; suppress double-tap-to-zoom pause on controls */
|
|
26
|
+
* { -webkit-tap-highlight-color: transparent; }
|
|
27
|
+
button, a, [role="button"], input, label { touch-action: manipulation; }
|
|
28
|
+
|
|
29
|
+
/* If you kill the default highlight, give back your OWN press feedback —
|
|
30
|
+
otherwise buttons feel dead. */
|
|
31
|
+
button:active, [role="button"]:active { opacity: 0.7; }
|
|
32
|
+
:focus-visible { outline: 2px solid; outline-offset: 2px; }
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
```css
|
|
36
|
+
/* Respect notch / home indicator — paint edge-to-edge, pad with env() */
|
|
37
|
+
.app-header { padding-top: env(safe-area-inset-top); }
|
|
38
|
+
.app-footer { padding-bottom: env(safe-area-inset-bottom); }
|
|
39
|
+
|
|
40
|
+
/* Native scroll feel */
|
|
41
|
+
:where(html, body) { overscroll-behavior: none; } /* no rubber-band on the shell */
|
|
42
|
+
.scroll-region { overflow-y: auto; overscroll-behavior: contain; }
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
| Concern | Do | Why |
|
|
46
|
+
| ------------- | -------------------------------------------------------------- | ------------------------------------------------ |
|
|
47
|
+
| Touch targets | min 44x44px (iOS) / 48dp (Android) | HIG / Material minimum; fewer mis-taps |
|
|
48
|
+
| Animation | animate `transform` / `opacity` only | compositor-only; skips layout & paint |
|
|
49
|
+
| Long lists | `content-visibility: auto` + `contain-intrinsic-size` | skips offscreen layout on Android & iOS 18+ WebView; older iOS ignores it (harmless). Need it everywhere → use a JS virtualizer |
|
|
50
|
+
| Scroll jank | avoid `box-shadow` / `filter` / `backdrop-filter` on scrolled nodes | repaint per frame on low-end GPUs |
|
|
51
|
+
|
|
52
|
+
Keep the webview locked down regardless of perf work: tight `server.allowNavigation`, no loading remote origins into the shell, and web debugging off in production builds.
|
|
53
|
+
|
|
54
|
+
## Anti-Patterns
|
|
55
|
+
|
|
56
|
+
1. **Profiling only in desktop Chrome** — desktop GPU hides jank that cripples low-end Android WebView. Test on a real cheap device or throttled emulation.
|
|
57
|
+
2. **Omitting `viewport-fit=cover`** — `env(safe-area-inset-*)` resolves to 0; content slides under the notch / home indicator and looks like a wrapped website.
|
|
58
|
+
3. **Killing `-webkit-tap-highlight-color` without a replacement press state** — buttons feel dead (no touch acknowledgement). Remove the grey flash *and* add your own `:active` / `:focus-visible` feedback. (Note: the legacy 300ms delay is already gone on modern WebViews via `width=device-width`; `touch-action: manipulation` only suppresses the double-tap-zoom pause.)
|
|
59
|
+
4. **Animating `top` / `left` / `width` or shadows on scroll** — forces layout/paint each frame; use `transform` / `opacity`.
|
|
60
|
+
|
|
61
|
+
## See Also
|
|
62
|
+
|
|
63
|
+
- `ct-capacitor-ota` — OTA delivery, channels, `notifyAppReady`, and the splash/boot-ordering note.
|
|
64
|
+
- `ct-solidjs-patterns` — `lazy()` + `<Suspense>` screen splitting protects webview cold-start.
|
|
65
|
+
- `ct-vanilla-extract-patterns` — author the safe-area / logical-property styles; animate `transform`/`opacity` only.
|
|
66
|
+
- `ct-i18n-typesafe` — defer locale loading so first paint isn't blocked.
|
|
@@ -57,7 +57,53 @@
|
|
|
57
57
|
"relatedSkills": [
|
|
58
58
|
"ct-vite-vitest-patterns",
|
|
59
59
|
"ct-playwright-patterns",
|
|
60
|
-
"ct-typescript-conventions"
|
|
60
|
+
"ct-typescript-conventions",
|
|
61
|
+
"ct-capacitor-ui"
|
|
62
|
+
]
|
|
63
|
+
},
|
|
64
|
+
"ct-capacitor-ui": {
|
|
65
|
+
"description": "Capacitor webview UI performance and native feel: safe areas, touch targets, tap feedback, compositor-only animation, on-device profiling",
|
|
66
|
+
"priority": 7,
|
|
67
|
+
"triggers": {
|
|
68
|
+
"keywords": [
|
|
69
|
+
"safe area",
|
|
70
|
+
"safe-area",
|
|
71
|
+
"viewport",
|
|
72
|
+
"touch target",
|
|
73
|
+
"tap highlight",
|
|
74
|
+
"native feel",
|
|
75
|
+
"splash",
|
|
76
|
+
"overscroll",
|
|
77
|
+
"notch",
|
|
78
|
+
"home indicator",
|
|
79
|
+
"webview"
|
|
80
|
+
],
|
|
81
|
+
"keywordPatterns": [
|
|
82
|
+
"\\bsafe-area\\b",
|
|
83
|
+
"\\bviewport-fit\\b",
|
|
84
|
+
"\\btouch-action\\b",
|
|
85
|
+
"\\boverscroll\\b"
|
|
86
|
+
],
|
|
87
|
+
"pathPatterns": ["**/index.html"],
|
|
88
|
+
"intentPatterns": [
|
|
89
|
+
"(?:safe area|notch|home indicator)",
|
|
90
|
+
"(?:native feel|touch target|tap feedback)",
|
|
91
|
+
"(?:scroll|animation).*(?:jank|smooth|perf)"
|
|
92
|
+
],
|
|
93
|
+
"contentPatterns": [
|
|
94
|
+
"safe-area-inset",
|
|
95
|
+
"viewport-fit",
|
|
96
|
+
"touch-action",
|
|
97
|
+
"-webkit-tap-highlight-color",
|
|
98
|
+
"overscroll-behavior",
|
|
99
|
+
"content-visibility"
|
|
100
|
+
]
|
|
101
|
+
},
|
|
102
|
+
"relatedSkills": [
|
|
103
|
+
"ct-capacitor-ota",
|
|
104
|
+
"ct-solidjs-patterns",
|
|
105
|
+
"ct-vanilla-extract-patterns",
|
|
106
|
+
"ct-i18n-typesafe"
|
|
61
107
|
]
|
|
62
108
|
}
|
|
63
109
|
}
|