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.
@@ -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.1.27",
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(projectDir: string, config: ClaudeToolkitConfig): Promise<void> {
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
- await scaffoldConfigs(projectDir, resolved);
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
- console.log(
105
- `Generated .claude/ with ${resolved.stacks.length} stack(s) and ${resolved.skills.length} core skills`,
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(projectDir: string, resolved: ResolvedConfig): Promise<void> {
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).
@@ -19,7 +19,8 @@
19
19
  "(?:translat).*(?:key|string)"
20
20
  ],
21
21
  "contentPatterns": ["useI18nContext", "LL\\.", "baseLocale", "Locales"]
22
- }
22
+ },
23
+ "relatedSkills": ["ct-solidjs-patterns", "ct-vanilla-extract-patterns"]
23
24
  }
24
25
  }
25
26
  }