claude-toolkit 0.1.27 → 0.10.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 +44 -0
- package/README.md +28 -33
- package/bin/cli.ts +198 -162
- package/bin/postinstall.mjs +69 -0
- package/core/hooks/skill-eval.js +3 -20
- package/core/skills/ct-testing-patterns/SKILL.md +12 -0
- package/docs/README.md +4 -0
- package/package.json +2 -1
- package/src/generator.ts +68 -11
- package/src/utils.ts +8 -15
- 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/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/docs/README.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
Complete reference for all skills, commands, and agents provided by claude-toolkit.
|
|
4
4
|
|
|
5
|
+
## Setup
|
|
6
|
+
|
|
7
|
+
Run `bunx claude-toolkit` in your project — on the first run it creates `claude-toolkit.config.ts` (pre-filled from stack detection) and generates `.claude/`. Run it again anytime to regenerate; add `--update` to pull newly-detected stacks into the config. `.claude/` also regenerates automatically on install when the toolkit version changes. See the [README](../README.md#cli-commands) for the full CLI.
|
|
8
|
+
|
|
5
9
|
## Core Skills
|
|
6
10
|
|
|
7
11
|
Skills that are always included regardless of stack configuration.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-toolkit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Reusable Claude Code configuration toolkit with stack-specific connectors",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"typecheck": "tsc --noEmit",
|
|
26
26
|
"lint": "biome check --write",
|
|
27
27
|
"lint:check": "biome check",
|
|
28
|
+
"postinstall": "bun bin/postinstall.mjs || node bin/postinstall.mjs || exit 0",
|
|
28
29
|
"prepare": "npx husky"
|
|
29
30
|
},
|
|
30
31
|
"lint-staged": {
|
package/src/generator.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { copyFile as fsCopyFile } from "node:fs/promises";
|
|
1
|
+
import { copyFile as fsCopyFile, readdir } from "node:fs/promises";
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
3
|
import type { ClaudeToolkitConfig, ResolvedConfig, StackPack } from "./types.js";
|
|
4
|
-
import { copyDir, exists, readJson, writeFileEnsureDir } from "./utils.js";
|
|
4
|
+
import { copyDir, exists, readJson, removePath, writeFileEnsureDir } from "./utils.js";
|
|
5
5
|
|
|
6
6
|
const TOOLKIT_ROOT = resolve(import.meta.dirname, "..");
|
|
7
7
|
|
|
@@ -48,11 +48,52 @@ async function resolveConfig(config: ClaudeToolkitConfig): Promise<ResolvedConfi
|
|
|
48
48
|
return { config, skills, directoryMappings: allMappings, hooks, stacks };
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/** Remove only `ct-` prefixed entries in a directory (preserves user-authored files). */
|
|
52
|
+
async function removeCtPrefixed(dir: string): Promise<void> {
|
|
53
|
+
if (!exists(dir)) return;
|
|
54
|
+
const entries = await readdir(dir);
|
|
55
|
+
await Promise.all(
|
|
56
|
+
entries.filter((e) => e.startsWith("ct-")).map((e) => removePath(join(dir, e))),
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Remove toolkit-generated artifacts from .claude/ before a rebuild, so a stack
|
|
62
|
+
* removed from the config doesn't leave an orphaned skill behind.
|
|
63
|
+
*
|
|
64
|
+
* Scoped to what the toolkit owns: `ct-` prefixed skills/agents, the generated
|
|
65
|
+
* skills index, the `ct/` command namespace, and the toolkit's own hook files.
|
|
66
|
+
* `.claude/skills`, `/agents`, and `/hooks` are shared Claude Code namespaces, so
|
|
67
|
+
* any user-authored files there — and the user files at the .claude root
|
|
68
|
+
* (settings.local.json, user-team-info.json, tasks/) — are preserved.
|
|
69
|
+
* (settings.json and .gitignore are single generated files, overwritten by generate.)
|
|
70
|
+
*/
|
|
71
|
+
async function removeGenerated(claudeDir: string): Promise<void> {
|
|
72
|
+
const coreHooks = join(TOOLKIT_ROOT, "core", "hooks");
|
|
73
|
+
const hookFiles = exists(coreHooks) ? await readdir(coreHooks) : [];
|
|
74
|
+
await Promise.all([
|
|
75
|
+
removeCtPrefixed(join(claudeDir, "skills")),
|
|
76
|
+
removeCtPrefixed(join(claudeDir, "agents")),
|
|
77
|
+
removePath(join(claudeDir, "skills", "README.md")),
|
|
78
|
+
removePath(join(claudeDir, "commands", "ct")),
|
|
79
|
+
// Toolkit hook files (copied from core/hooks) + the generated skill-rules.json.
|
|
80
|
+
...hookFiles.map((f) => removePath(join(claudeDir, "hooks", f))),
|
|
81
|
+
removePath(join(claudeDir, "hooks", "skill-rules.json")),
|
|
82
|
+
]);
|
|
83
|
+
}
|
|
84
|
+
|
|
51
85
|
/** Generate the .claude/ directory from resolved config */
|
|
52
|
-
export async function generate(
|
|
86
|
+
export async function generate(
|
|
87
|
+
projectDir: string,
|
|
88
|
+
config: ClaudeToolkitConfig,
|
|
89
|
+
options: { quiet?: boolean; scaffold?: boolean } = {},
|
|
90
|
+
): Promise<void> {
|
|
53
91
|
const resolved = await resolveConfig(config);
|
|
54
92
|
const claudeDir = join(projectDir, ".claude");
|
|
55
93
|
|
|
94
|
+
// 0. Clean previously-generated output so removed stacks don't leave stale skills.
|
|
95
|
+
await removeGenerated(claudeDir);
|
|
96
|
+
|
|
56
97
|
// 1. Copy core hooks
|
|
57
98
|
await copyDir(join(TOOLKIT_ROOT, "core", "hooks"), join(claudeDir, "hooks"));
|
|
58
99
|
|
|
@@ -92,18 +133,30 @@ export async function generate(projectDir: string, config: ClaudeToolkitConfig):
|
|
|
92
133
|
"# Task-specific context",
|
|
93
134
|
"tasks/",
|
|
94
135
|
"",
|
|
136
|
+
"# Toolkit regeneration marker",
|
|
137
|
+
".toolkit-version",
|
|
138
|
+
"",
|
|
95
139
|
].join("\n"),
|
|
96
140
|
);
|
|
97
141
|
|
|
98
|
-
// 9. Scaffold base configs
|
|
99
|
-
|
|
142
|
+
// 9. Scaffold base configs into the project root (committed files). Skipped for
|
|
143
|
+
// automatic/postinstall regeneration so an install never writes committed files.
|
|
144
|
+
if (options.scaffold !== false) {
|
|
145
|
+
await scaffoldConfigs(projectDir, resolved, options.quiet);
|
|
146
|
+
}
|
|
100
147
|
|
|
101
148
|
// 10. Generate skills README
|
|
102
149
|
await generateSkillsReadme(claudeDir, resolved);
|
|
103
150
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
);
|
|
151
|
+
// Record the toolkit version that produced this output — drives auto-regen on update.
|
|
152
|
+
const { version } = await readJson<{ version: string }>(join(TOOLKIT_ROOT, "package.json"));
|
|
153
|
+
await writeFileEnsureDir(join(claudeDir, ".toolkit-version"), `${version}\n`);
|
|
154
|
+
|
|
155
|
+
if (!options.quiet) {
|
|
156
|
+
console.log(
|
|
157
|
+
`Generated .claude/ with ${resolved.stacks.length} stack(s) and ${resolved.skills.length} core skills`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
107
160
|
}
|
|
108
161
|
|
|
109
162
|
/** Generate skill-rules.json from resolved config */
|
|
@@ -271,7 +324,11 @@ async function generateSettings(claudeDir: string, resolved: ResolvedConfig): Pr
|
|
|
271
324
|
}
|
|
272
325
|
|
|
273
326
|
/** Scaffold base config files (biome.json, tsconfig.json) into the project */
|
|
274
|
-
async function scaffoldConfigs(
|
|
327
|
+
async function scaffoldConfigs(
|
|
328
|
+
projectDir: string,
|
|
329
|
+
resolved: ResolvedConfig,
|
|
330
|
+
quiet = false,
|
|
331
|
+
): Promise<void> {
|
|
275
332
|
if (resolved.config.scaffoldConfigs === false) return;
|
|
276
333
|
|
|
277
334
|
const configsDir = join(TOOLKIT_ROOT, "templates", "configs");
|
|
@@ -283,10 +340,10 @@ async function scaffoldConfigs(projectDir: string, resolved: ResolvedConfig): Pr
|
|
|
283
340
|
for (const { src, dest } of configs) {
|
|
284
341
|
const destPath = join(projectDir, dest);
|
|
285
342
|
if (exists(destPath)) {
|
|
286
|
-
console.log(` Skipped ${dest} (already exists)`);
|
|
343
|
+
if (!quiet) console.log(` Skipped ${dest} (already exists)`);
|
|
287
344
|
} else {
|
|
288
345
|
await fsCopyFile(join(configsDir, src), destPath);
|
|
289
|
-
console.log(` Scaffolded ${dest}`);
|
|
346
|
+
if (!quiet) console.log(` Scaffolded ${dest}`);
|
|
290
347
|
}
|
|
291
348
|
}
|
|
292
349
|
}
|
package/src/utils.ts
CHANGED
|
@@ -1,11 +1,5 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
-
import {
|
|
3
|
-
copyFile,
|
|
4
|
-
mkdir,
|
|
5
|
-
readdir,
|
|
6
|
-
readFile,
|
|
7
|
-
writeFile,
|
|
8
|
-
} from "node:fs/promises";
|
|
2
|
+
import { copyFile, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
9
3
|
import { dirname, join } from "node:path";
|
|
10
4
|
|
|
11
5
|
/** Recursively copy a directory */
|
|
@@ -24,11 +18,13 @@ export async function copyDir(src: string, dest: string): Promise<void> {
|
|
|
24
18
|
}
|
|
25
19
|
}
|
|
26
20
|
|
|
21
|
+
/** Remove a file or directory recursively. No-op if the path doesn't exist. */
|
|
22
|
+
export async function removePath(path: string): Promise<void> {
|
|
23
|
+
await rm(path, { recursive: true, force: true });
|
|
24
|
+
}
|
|
25
|
+
|
|
27
26
|
/** Write a file, creating parent directories as needed */
|
|
28
|
-
export async function writeFileEnsureDir(
|
|
29
|
-
filePath: string,
|
|
30
|
-
content: string,
|
|
31
|
-
): Promise<void> {
|
|
27
|
+
export async function writeFileEnsureDir(filePath: string, content: string): Promise<void> {
|
|
32
28
|
await mkdir(dirname(filePath), { recursive: true });
|
|
33
29
|
await writeFile(filePath, content, "utf-8");
|
|
34
30
|
}
|
|
@@ -45,9 +41,6 @@ export function exists(filePath: string): boolean {
|
|
|
45
41
|
}
|
|
46
42
|
|
|
47
43
|
/** Simple template replacement: {{key}} → value */
|
|
48
|
-
export function renderTemplate(
|
|
49
|
-
template: string,
|
|
50
|
-
vars: Record<string, string>,
|
|
51
|
-
): string {
|
|
44
|
+
export function renderTemplate(template: string, vars: Record<string, string>): string {
|
|
52
45
|
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? "");
|
|
53
46
|
}
|
|
@@ -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
|
}
|
|
@@ -96,6 +96,83 @@ Key points:
|
|
|
96
96
|
- Always use Hyperdrive for external database connections from Workers.
|
|
97
97
|
- Connection pool size is configurable per Hyperdrive config.
|
|
98
98
|
|
|
99
|
+
## Performance
|
|
100
|
+
|
|
101
|
+
> _Verified against Cloudflare Workers / D1 / KV (2026-06)._
|
|
102
|
+
|
|
103
|
+
Latency on Workers is dominated by **network round trips**, not CPU. Every `await` on D1/KV/`fetch` is a hop -- collapse and parallelize them.
|
|
104
|
+
|
|
105
|
+
### Don't serialize independent round trips
|
|
106
|
+
|
|
107
|
+
```rust
|
|
108
|
+
// BAD: ~2x latency -- two independent reads run back to back
|
|
109
|
+
let user = get_user(&db, id).await?;
|
|
110
|
+
let prefs = get_prefs(&kv, id).await?;
|
|
111
|
+
|
|
112
|
+
// GOOD: one round-trip wall-time -- run concurrently
|
|
113
|
+
let (user, prefs) = futures::try_join!(get_user(&db, id), get_prefs(&kv, id))?;
|
|
114
|
+
```
|
|
115
|
+
```typescript
|
|
116
|
+
// TS equivalent
|
|
117
|
+
const [user, prefs] = await Promise.all([getUser(db, id), getPrefs(kv, id)]);
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
I/O overlaps in the single-threaded isolate, so wall-time approaches the slowest call. (A Worker can have only ~6 connections waiting on response headers at once -- so fan-out is no substitute for collapsing N reads into one query.)
|
|
121
|
+
|
|
122
|
+
Kill N+1: never loop per-row queries. Use one `IN (...)`/JOIN, or `db.batch()` of selects.
|
|
123
|
+
|
|
124
|
+
```rust
|
|
125
|
+
// BAD: 1 + N round trips
|
|
126
|
+
for id in ids { rows.push(db.prepare("SELECT * FROM u WHERE id=?1").bind(&[id.into()])?.first(None).await?); }
|
|
127
|
+
// GOOD: 1 round trip
|
|
128
|
+
let placeholders = ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
|
|
129
|
+
let users = db.prepare(&format!("SELECT * FROM u WHERE id IN ({placeholders})"))
|
|
130
|
+
.bind(&binds)?.all().await?.results::<User>()?; // .all() -> D1Result; .results() -> typed rows
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Verify the index is actually used
|
|
134
|
+
|
|
135
|
+
An index exists != the planner uses it. Functions on columns, leading-`%` LIKE, and TEXT/INT mismatches force a SCAN.
|
|
136
|
+
|
|
137
|
+
```sql
|
|
138
|
+
EXPLAIN QUERY PLAN SELECT * FROM users WHERE email = ?1;
|
|
139
|
+
-- want: SEARCH users USING INDEX idx_users_email (NOT: SCAN users)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### Cache API for full responses (not just KV)
|
|
143
|
+
|
|
144
|
+
`caches.default` is data-center-local (contents don't replicate across colos): sub-ms in-colo, cheaper than a KV read, no eventual-consistency lag. Use it for whole GET responses (only GET is cacheable); use KV read-through for sub-response values. Fill the cache off the response path with `waitUntil`.
|
|
145
|
+
|
|
146
|
+
```typescript
|
|
147
|
+
const cache = caches.default;
|
|
148
|
+
let res = await cache.match(request);
|
|
149
|
+
if (!res) {
|
|
150
|
+
res = await render(request); // Cache-Control sets TTL
|
|
151
|
+
ctx.waitUntil(cache.put(request, res.clone())); // non-blocking write
|
|
152
|
+
}
|
|
153
|
+
return res;
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
`cache.put` rejects non-GET, `206`, `Vary: *`, and `Set-Cookie` responses (strip the cookie or set `Cache-Control: private=Set-Cookie` first).
|
|
157
|
+
|
|
158
|
+
Same rule for cache/KV writes and invalidation: `ctx.waitUntil(kv.put(...))` so writes never add to TTFB.
|
|
159
|
+
|
|
160
|
+
### Stream instead of buffering
|
|
161
|
+
|
|
162
|
+
Buffering the full body adds total generation time to TTFB. Return a `ReadableStream` (or pipe an upstream body) so bytes flush as produced.
|
|
163
|
+
|
|
164
|
+
### Smart Placement for origin/Hyperdrive-heavy Workers
|
|
165
|
+
|
|
166
|
+
Smart Placement moves the isolate closer to your **back-end** (origin APIs, or an external Postgres/MySQL behind Hyperdrive) when latency is dominated by multiple **sequential** calls to it:
|
|
167
|
+
|
|
168
|
+
```toml
|
|
169
|
+
# wrangler.toml
|
|
170
|
+
[placement]
|
|
171
|
+
mode = "smart"
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
It does **not** help D1 or KV: D1 routing is governed by the primary-instance location + read replication (use the Sessions API to read from a nearby replica), and KV is already served from the data center the Worker runs in. Leave Smart Placement off for single-hop, D1/KV-only, or static-heavy Workers.
|
|
175
|
+
|
|
99
176
|
## Anti-Patterns
|
|
100
177
|
|
|
101
178
|
1. **Unbounded queries** -- Always LIMIT. D1 has 5MB response cap.
|
|
@@ -104,3 +181,12 @@ Key points:
|
|
|
104
181
|
4. **Skipping batch** -- Each D1 call is a network round trip.
|
|
105
182
|
5. **KV without TTL** -- Stale data persists indefinitely.
|
|
106
183
|
6. **Untested migrations** -- Always `--local` before `--remote`.
|
|
184
|
+
7. **Sequential awaits on independent reads** -- each is a round trip; use `try_join!`/`Promise.all`.
|
|
185
|
+
8. **Per-row query loops (N+1)** -- batch into one `IN (...)`/JOIN or `db.batch()`.
|
|
186
|
+
9. **Awaiting cache/KV writes before responding** -- use `ctx.waitUntil()` so puts don't add to TTFB.
|
|
187
|
+
10. **Assuming the index is used** -- check `EXPLAIN QUERY PLAN` for `USING INDEX`, not `SCAN`.
|
|
188
|
+
11. **Smart Placement for D1/KV latency** -- it targets origins/Hyperdrive, not D1/KV; use D1 read replication (Sessions API) instead.
|
|
189
|
+
|
|
190
|
+
## See Also
|
|
191
|
+
|
|
192
|
+
- `ct-rust-wasm-patterns` — the same D1 round-trip / `IN`-with-bound-params batching, from the Rust handler side.
|
|
@@ -54,6 +54,49 @@ Types are inferred from base locale: `{name:string}`, `{count:number}`. TypeScri
|
|
|
54
54
|
|
|
55
55
|
Organize by feature (common, auth, events), one level deep. Avoid deep nesting -- it makes keys verbose.
|
|
56
56
|
|
|
57
|
+
## Performance
|
|
58
|
+
|
|
59
|
+
> _Verified against typesafe-i18n + SolidJS (2026-06)._
|
|
60
|
+
|
|
61
|
+
Ship one locale, not all. typesafe-i18n generates both loaders -- pick the async one.
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
// CORRECT: per-locale dynamic import -> separate chunk, only active locale fetched
|
|
65
|
+
import { loadLocaleAsync } from './i18n/i18n-util.async';
|
|
66
|
+
await loadLocaleAsync(detectLocale()); // e.g. 'en' loads the i18n/en chunk only
|
|
67
|
+
|
|
68
|
+
// WRONG: i18n-util.sync static-imports every dictionary at module top -> all in this bundle
|
|
69
|
+
import { loadLocale } from './i18n/i18n-util.sync';
|
|
70
|
+
loadLocale('en'); // loadAllLocales() is worse
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Build formatters once per locale; never construct Intl objects in a component or list row. (Measured: constructing `Intl.NumberFormat` ~20x slower than reusing one instance.)
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
// src/i18n/formatters.ts -- constructed once via loadFormatters(locale)
|
|
77
|
+
export const initFormatters: FormattersInitializer<Locales, Formatters> = (locale) => ({
|
|
78
|
+
// Intl.* is expensive to construct; reuse the instance across all calls
|
|
79
|
+
currency: (v: number) => new Intl.NumberFormat(locale, { style: 'currency', currency: 'EUR' }).format(v),
|
|
80
|
+
});
|
|
81
|
+
// usage in en/index.ts: price: 'Total: {amount:number|currency}' -- typed param, not new Intl.* at the call site
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Don't block first paint on the locale fetch. In Solid, `<Suspense>` only suspends on a tracked resource read -- a bare `await loadLocaleAsync()` will NOT trip the boundary. Drive it off a resource (or use the Solid adapter's `<TypesafeI18n>`, which withholds children until the locale loads):
|
|
85
|
+
|
|
86
|
+
```tsx
|
|
87
|
+
import { createResource, Suspense } from 'solid-js';
|
|
88
|
+
// loaded() is read inside the boundary, so Suspense shows the shell until the chunk arrives
|
|
89
|
+
const [loaded] = createResource(detectLocale, loadLocaleAsync);
|
|
90
|
+
<Suspense fallback={<AppShell />}>{loaded.state === 'ready' && props.children}</Suspense>
|
|
91
|
+
// index.html: a modulepreload link for /assets/i18n-en-*.js overlaps with the main bundle fetch
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
RTL: drive the html dir attribute from the locale; use CSS logical properties so layout flips with zero extra CSS or JS.
|
|
95
|
+
|
|
96
|
+
```css
|
|
97
|
+
.card { margin-inline-start: 1rem; padding-inline: 1rem; inset-inline-start: 0; } /* not margin-left */
|
|
98
|
+
```
|
|
99
|
+
|
|
57
100
|
## Anti-Patterns
|
|
58
101
|
|
|
59
102
|
1. **Hardcoded strings** -- Every user-visible string goes through `LL()`.
|
|
@@ -62,3 +105,12 @@ Organize by feature (common, auth, events), one level deep. Avoid deep nesting -
|
|
|
62
105
|
4. **Dynamic key access** -- `LL()[dynamicKey]()` bypasses type safety. Use conditional rendering.
|
|
63
106
|
5. **Forgetting loadLocale** -- Must call before rendering or get runtime errors.
|
|
64
107
|
6. **Over-splitting namespaces** -- Group by feature, not by component.
|
|
108
|
+
7. **Sync `loadLocale` / `loadAllLocales` in app code** -- `i18n-util.sync` static-imports every dictionary into the bundle that pulls it in. Use `loadLocaleAsync` so each locale is its own chunk.
|
|
109
|
+
8. **Constructing `Intl.NumberFormat`/`DateTimeFormat` per render** -- ~20x costlier than reuse; build once in `formatters.ts` and reference via `{v:number|formatter}`.
|
|
110
|
+
9. **Awaiting `loadLocaleAsync` without a Suspense-tracked resource** -- Blanks the screen until the JSON loads, and a bare `await` never trips Solid's `<Suspense>`. Gate via `createResource` (or `<TypesafeI18n>`), show a shell fallback, and modulepreload the active locale.
|
|
111
|
+
10. **Physical CSS for layout (`margin-left`, `left`)** -- Breaks RTL and needs per-dir overrides. Use logical properties (`margin-inline-start`, `inset-inline-start`).
|
|
112
|
+
|
|
113
|
+
## See Also
|
|
114
|
+
|
|
115
|
+
- `ct-solidjs-patterns` — the `createResource`-tracked `<Suspense>` boundary (a bare `await` never suspends in Solid) and `lazy()` cold-start splitting.
|
|
116
|
+
- `ct-vanilla-extract-patterns` — RTL = `dir` from locale (here) + logical properties authored in `.css.ts` (there).
|