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 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 | Description |
49
- | -------------------------- | -------------------------------------------------------- |
50
- | `bunx claude-toolkit init` | Scaffold config file and generate `.claude/` |
51
- | `bunx claude-toolkit sync` | Regenerate `.claude/` from config (after toolkit update) |
52
- | `bunx claude-toolkit help` | Show available commands |
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 native runtime, Capgo OTA live updates, channels |
112
+ | `capacitor` | Capacitor 8 runtime, Capgo OTA, channels; webview UI & native feel |
112
113
 
113
114
  ## Core Features (always included)
114
115
 
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-toolkit",
3
- "version": "0.1.25",
3
+ "version": "0.9.0",
4
4
  "description": "Reusable Claude Code configuration toolkit with stack-specific connectors",
5
5
  "type": "module",
6
6
  "bin": {
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: (projectDir: string, pkg: PackageJson | null) => DetectedStack | null;
36
+ detect: (ctx: DetectContext) => DetectedStack | null;
19
37
  }
20
38
 
21
- function loadPackageJson(projectDir: string): PackageJson | null {
22
- const pkgPath = join(projectDir, "package.json");
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
- function hasDep(pkg: PackageJson | null, name: string): boolean {
32
- if (!pkg) return false;
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 fileExists(projectDir: string, ...segments: string[]): boolean {
37
- return existsSync(join(projectDir, ...segments));
124
+ function pkgLabel(rel: string): string {
125
+ return rel === "." ? "root package.json" : `${rel}/package.json`;
38
126
  }
39
127
 
40
- function rootConfigExists(projectDir: string, prefix: string): string | null {
41
- const glob = new Bun.Glob(`${prefix}.*`);
42
- for (const match of glob.scanSync({ cwd: projectDir, onlyFiles: true })) {
43
- return match;
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: (_dir, pkg) =>
52
- hasDep(pkg, "solid-js")
53
- ? { name: "solidjs", reason: "found solid-js in dependencies" }
54
- : null,
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: (dir, pkg) => {
59
- if (hasDep(pkg, "vite")) return { name: "vite", reason: "found vite in dependencies" };
60
- const viteConfig = rootConfigExists(dir, "vite.config");
61
- if (viteConfig) return { name: "vite", reason: `found ${viteConfig}` };
62
- const vitestConfig = rootConfigExists(dir, "vitest.config");
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: (_dir, pkg) =>
70
- hasDep(pkg, "@vanilla-extract/css")
71
- ? { name: "vanilla-extract", reason: "found @vanilla-extract/css in dependencies" }
72
- : null,
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: (dir) =>
77
- fileExists(dir, "Cargo.toml") ? { name: "rust-wasm", reason: "found Cargo.toml" } : null,
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: (dir) => {
82
- if (fileExists(dir, "buf.yaml")) return { name: "protobuf", reason: "found buf.yaml" };
83
- if (fileExists(dir, "buf.gen.yaml"))
84
- return { name: "protobuf", reason: "found buf.gen.yaml" };
85
- const glob = new Bun.Glob("**/*.proto");
86
- for (const _match of glob.scanSync({ cwd: dir, onlyFiles: true })) {
87
- return { name: "protobuf", reason: "found .proto files" };
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: (dir) => {
95
- if (fileExists(dir, "wrangler.toml"))
96
- return { name: "cloudflare", reason: "found wrangler.toml" };
97
- if (fileExists(dir, "wrangler.jsonc"))
98
- return { name: "cloudflare", reason: "found wrangler.jsonc" };
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: (_dir, pkg) =>
105
- hasDep(pkg, "typesafe-i18n")
106
- ? { name: "i18n-typesafe", reason: "found typesafe-i18n in dependencies" }
107
- : null,
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: (dir, pkg) => {
112
- if (hasDep(pkg, "@playwright/test"))
113
- return { name: "playwright", reason: "found @playwright/test in dependencies" };
114
- const config = rootConfigExists(dir, "playwright.config");
115
- if (config) return { name: "playwright", reason: `found ${config}` };
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: (dir, pkg) => {
122
- if (hasDep(pkg, "storybook"))
123
- return { name: "storybook", reason: "found storybook in dependencies" };
124
- if (fileExists(dir, ".storybook"))
125
- return { name: "storybook", reason: "found .storybook/ directory" };
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: (dir, pkg) => {
132
- if (hasDep(pkg, "@capgo/capacitor-updater"))
133
- return { name: "capacitor", reason: "found @capgo/capacitor-updater in dependencies" };
134
- if (hasDep(pkg, "@capacitor/core"))
135
- return { name: "capacitor", reason: "found @capacitor/core in dependencies" };
136
- const config = rootConfigExists(dir, "capacitor.config");
137
- if (config) return { name: "capacitor", reason: `found ${config}` };
138
- return null;
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 pkg = loadPackageJson(projectDir);
278
+ const ctx = buildContext(projectDir);
146
279
  const detected: DetectedStack[] = [];
147
280
  for (const detector of DETECTORS) {
148
- const result = detector.detect(projectDir, pkg);
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
  }