claude-toolkit 0.1.27 → 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,39 @@
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
+
3
37
  ## 0.1.27 (2026-06-01)
4
38
 
5
39
  - feat: detect stacks across workspace packages and monorepo subdirectories
package/README.md CHANGED
@@ -109,7 +109,7 @@ This keeps your config aligned as your project evolves — `init` for first-time
109
109
  | `i18n-typesafe` | typesafe-i18n internationalization |
110
110
  | `playwright` | Playwright E2E testing, Page Objects, fixtures, CI/CD |
111
111
  | `storybook` | Storybook interaction testing, CSF 3, visual regression |
112
- | `capacitor` | Capacitor 8 native runtime, Capgo OTA live updates, channels |
112
+ | `capacitor` | Capacitor 8 runtime, Capgo OTA, channels; webview UI & native feel |
113
113
 
114
114
  ## Core Features (always included)
115
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.27",
3
+ "version": "0.9.0",
4
4
  "description": "Reusable Claude Code configuration toolkit with stack-specific connectors",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
  }
@@ -107,7 +107,7 @@ await page.route("**/analytics/**", (route) => route.abort());
107
107
 
108
108
  Mock ~80% of API calls for speed; keep ~20% hitting real endpoints. Always call `route.continue()`, `route.fulfill()`, or `route.abort()`. Use `page.unrouteAll()` in cleanup.
109
109
 
110
- ## WebSocket Mocking (v1.53+)
110
+ ## WebSocket Mocking (v1.48+)
111
111
 
112
112
  ```typescript
113
113
  await page.routeWebSocket("wss://example.com/ws", (ws) => {
@@ -127,7 +127,7 @@ test("WCAG 2.1 AA", async ({ page }) => {
127
127
  });
128
128
  ```
129
129
 
130
- Aria snapshots (v1.52+): `await expect(nav).toMatchAriaSnapshot(...)`.
130
+ Aria snapshots (v1.49+): `await expect(nav).toMatchAriaSnapshot(...)`.
131
131
 
132
132
  ## Parallelization
133
133
 
@@ -137,6 +137,65 @@ Aria snapshots (v1.52+): `await expect(nav).toMatchAriaSnapshot(...)`.
137
137
 
138
138
  Each worker gets its own `BrowserContext` (isolated cookies/storage). Use `mode: "serial"` sparingly.
139
139
 
140
+ ## Test Speed
141
+
142
+ > _Verified against Playwright 1.5x (2026-06)._
143
+
144
+ E2E wall-clock is dominated by browser launches and UI setup. Three levers: maximize parallelism, seed state over HTTP, fan out across CI.
145
+
146
+ ```typescript
147
+ // playwright.config.ts
148
+ export default defineConfig({
149
+ fullyParallel: true, // parallelize within files, not just across them
150
+ workers: process.env.CI ? "50%" : undefined, // track the runner's real vCPUs; undefined = 50% cores locally
151
+ webServer: {
152
+ command: "npm run preview", // prebuilt/preview server, not a dev build
153
+ url: "http://localhost:4173",
154
+ reuseExistingServer: !process.env.CI, // reuse on local reruns; CI always boots fresh
155
+ timeout: 120_000,
156
+ },
157
+ });
158
+ ```
159
+
160
+ > Don't hardcode `workers` to a number larger than the runner. GitHub standard runners are 2 vCPU (private) / 4 (public); oversubscribing thrashes. `"50%"` (or a count you've matched to the runner) is portable.
161
+
162
+ **Seed state over HTTP, not the UI.** Driving the browser to create fixtures/auth is the costliest per-test work. Do it once in the `setup` project via the `request` fixture (no browser, no UI clicks), then reuse `storageState` — same project-dependency pattern this stack already uses for UI login:
163
+
164
+ ```typescript
165
+ // e2e/auth.setup.ts (consumed via dependencies: ["setup"] + storageState in config)
166
+ import { test as setup } from "@playwright/test";
167
+
168
+ setup("authenticate via API", async ({ request }) => {
169
+ await request.post("/api/login", {
170
+ data: { email: process.env.E2E_EMAIL, password: process.env.E2E_PASSWORD },
171
+ });
172
+ await request.storageState({ path: "e2e/.auth/user.json" });
173
+ });
174
+ ```
175
+
176
+ The `request` fixture is a full HTTP client (it wraps `apiRequest.newContext()` under the hood) and carries cookies set by the response — no manual context to dispose. Keep credentials in env/secrets, never literals, and keep `e2e/.auth/` gitignored (it holds live session tokens).
177
+
178
+ **Shard across CI runners** (blob reporter + `merge-reports`, v1.37+):
179
+
180
+ ```yaml
181
+ strategy:
182
+ matrix: { shard: [1, 2, 3, 4] }
183
+ steps:
184
+ - run: npx playwright test --shard=${{ matrix.shard }}/4
185
+ # reporter: process.env.CI ? "blob" : "html" in config
186
+ # upload blob-report/, then a dependent job:
187
+ - run: npx playwright merge-reports --reporter=html ./all-blob-reports
188
+ ```
189
+
190
+ Shards stay balanced only when `fullyParallel: true` lets Playwright split at the test level; otherwise file-level granularity leaves wall-clock gated by the slowest shard.
191
+
192
+ | Lever | Effect |
193
+ |---|---|
194
+ | `fullyParallel: true` + `workers` | Near-linear speedup up to vCPU count |
195
+ | `request` fixture seeding | Cuts per-test setup from seconds to ms |
196
+ | `--shard=i/n` across runners | ~total/n with `fullyParallel`; uneven (gated by slowest shard) without it |
197
+ | `trace: "on-first-retry"` | No trace cost on the passing path |
198
+
140
199
  ## CI/CD
141
200
 
142
201
  - Use official Playwright Docker image (`mcr.microsoft.com/playwright:v1.58.0-noble`)
@@ -166,3 +225,12 @@ Each worker gets its own `BrowserContext` (isolated cookies/storage). Use `mode:
166
225
  6. **Using Playwright for unit tests** -- Use Vitest for pure logic.
167
226
  7. **Committing raw codegen output** -- Always refactor into Page Objects.
168
227
  8. **Not cleaning up route handlers** -- Use `page.unrouteAll()`.
228
+ 9. **Logging in / seeding data through the UI per test** -- Use the `request` fixture (or `context.request`) to seed state over HTTP, save it to `storageState`, reuse via project `dependencies`.
229
+ 10. **Hardcoding `workers` above the runner's vCPUs on CI** -- Defaults to 50% of *logical* cores; throttled/oversubscribed runners thrash. Use `"50%"` or pin to the runner's real vCPU count.
230
+ 11. **`--shard` without the blob reporter + `merge-reports`** -- Produces fragmented/duplicate reports instead of one merged HTML.
231
+ 12. **Booting a dev server (HMR/rebuild) as the `webServer` target** -- Point CI at a prebuilt preview build.
232
+
233
+ ## See Also
234
+
235
+ - `ct-testing-patterns` — the canonical cross-runner test-speed rule (parallelism sized to the runner, file-level `--shard` + blob/merge-reports).
236
+ - `ct-vite-vitest-patterns` — shares the CI sharding/worker budget; don't oversubscribe a runner running both.
@@ -68,6 +68,34 @@ prost_build::Config::new()
68
68
  - Adding fields and enum values is safe.
69
69
  - Run `buf breaking --against .git#branch=main` before merging.
70
70
 
71
+ ## Performance
72
+
73
+ > _Verified against proto3 / prost / protoc-gen-ts (2026-06)._
74
+
75
+ Wire format = field tag + value. Tag size depends only on the field NUMBER (`tag = (number << 3) | wire_type`, varint-encoded), so number placement is a perf decision, not just style.
76
+
77
+ | Field number | Tag bytes |
78
+ |---|---|
79
+ | 1-15 | 1 |
80
+ | 16-2047 | 2 |
81
+ | 2048+ | 3 |
82
+
83
+ ```protobuf
84
+ // Hot message decoded in tight loops -- keep it small, flat, low numbers.
85
+ message Tick {
86
+ int64 ts = 1; // 1-byte tag
87
+ double price = 2;
88
+ repeated int32 levels = 3; // packed automatically in proto3 (one tag for the whole run)
89
+ string debug_note = 16; // rarely set -> 2-byte tag, fine up here
90
+ }
91
+ ```
92
+
93
+ - **Packed repeated scalars are free wins.** `repeated int32/int64/float/double/bool/enum` are packed by default in proto3 (single tag + length, not a tag per element). `repeated string/bytes/message` are NOT packed -- prefer packed scalar arrays over `repeated SomeWrapper` in hot paths.
94
+ - **`bytes`, not `string`, for opaque data.** `string` is UTF-8-validated on every decode (prost runs `str::from_utf8` eagerly; protoc-gen-ts runs `TextDecoder` with `fatal`). Use `bytes` for hashes, tokens, IDs, and binary blobs to skip the scan; keep `string` for human-readable text.
95
+ - **Keep hot messages flat.** Each nested sub-message is a separate length-delimited decode + allocation in prost. Inline frequent fields; reserve nesting for cold/rare data.
96
+ - **Decode once.** prost decodes the whole message eagerly and allocates as it goes -- there are no lazy fields (proto3 `[lazy=true]` is not implemented by prost or protoc-gen-ts). Decode a payload one time and pass the struct around; never re-parse the same bytes. (The one zero-copy lever: generate `bytes` fields as `bytes::Bytes` via `prost_build`'s `.bytes(...)` to borrow from the input buffer instead of copying.)
97
+ - **Route without full decode.** Cheapest dispatch/filter is a transport header carried outside the message body. If you must read one field from the payload, neither prost nor protoc-gen-ts exposes a partial-decode API -- you decode the whole message -- so put any field you might hand-scan for at number 1-15 to keep that scan short.
98
+
71
99
  ## Anti-Patterns
72
100
 
73
101
  1. **Reusing field numbers** -- Causes data corruption. Always reserve removed numbers.
@@ -76,3 +104,11 @@ prost_build::Config::new()
76
104
  4. **Proto2 syntax** -- Always proto3 for new projects.
77
105
  5. **Skipping buf lint** -- Inconsistent naming compounds. Lint early.
78
106
  6. **Large messages** -- Not for multi-MB payloads. Use streaming/chunking.
107
+ 7. **`string` for binary/opaque data** -- Forces UTF-8 validation on every decode. Use `bytes`.
108
+ 8. **`repeated` wrapper messages for scalar arrays** -- Loses proto3 packing. Use `repeated int32/double/...` directly in hot paths.
109
+ 9. **Re-decoding the same payload** -- No lazy fields; every Decode allocates (the only zero-copy path is `bytes` fields generated as `bytes::Bytes`). Decode once, reuse the struct.
110
+ 10. **High field numbers on hot fields** -- Numbers >=16 cost 2+ tag bytes per occurrence. Keep frequent fields at 1-15.
111
+
112
+ ## See Also
113
+
114
+ - `ct-rust-wasm-patterns` — the decode-once consumer side; generate `bytes` fields as `bytes::Bytes` to borrow from the input buffer.
@@ -66,6 +66,60 @@ pub struct User {
66
66
 
67
67
  Parse body: `let body: CreateUserRequest = req.json().await?;`
68
68
 
69
+ ## Performance
70
+
71
+ > _Verified against worker-rs / wasm32-unknown-unknown (2026-06)._
72
+
73
+ Two costs dominate on Workers: **.wasm size** (eats the startup CPU budget, the 10MB bundle limit, and worst-case cold starts) and the **JS<->WASM boundary** (large Request/Response bodies and serde payloads get *copied* across — not the cost of small calls, the cost of big bytes).
74
+
75
+ ### Binary size — the release profile does the heavy lifting
76
+
77
+ Most size comes from the Rust build, not `wasm-opt`. In `Cargo.toml`:
78
+
79
+ ```toml
80
+ [profile.release]
81
+ opt-level = "z" # "z" = smallest; try "s" — sometimes smaller AND faster, always measure
82
+ lto = true # cross-crate inlining + dead-code elimination
83
+ codegen-units = 1 # better optimization, slower build
84
+ strip = true # drop symbols
85
+ ```
86
+
87
+ Don't bother with `panic = "abort"` for size: `wasm32-unknown-unknown` already defaults to abort, so there are no unwind tables to drop. Modern `worker-build` recovers from panics automatically — a panicking request returns 500 and the instance reinitializes, no config needed. Keep `console_error_panic_hook` regardless — it surfaces the panic message in your logs.
88
+
89
+ `worker-build` runs `wasm-opt` automatically before upload (a balanced `-O`, not `-Oz`) — no manual step. For size-aggressive optimization, opt into `-Oz` via the `wasm-opt` key in your Cargo package metadata.
90
+
91
+ Targets: <1MB compressed is fine; smaller = leaner startup. Use `cargo bloat --release` to find the heavy deps before trimming.
92
+
93
+ ### Boundary — pass bulk data, don't re-copy it; and cut DB round-trips
94
+
95
+ The boundary cost that bites is copying *large* payloads across JS<->WASM. Deserialize the body once (`req.json().await?`), not field-by-field, and don't shuttle the same buffer back and forth.
96
+
97
+ Separately, cut **D1 round-trips** — these are network hops, not boundary copies, and they dominate latency. One call beats N:
98
+
99
+ ```rust
100
+ // SLOW: one D1 round-trip per id
101
+ for id in &ids {
102
+ db.prepare("SELECT * FROM users WHERE id=?1").bind(&[id.into()])?.first::<User>(None).await?;
103
+ }
104
+
105
+ // FAST: expand placeholders, bind every value, one round-trip
106
+ let ph = (1..=ids.len()).map(|i| format!("?{i}")).collect::<Vec<_>>().join(",");
107
+ let binds = ids.iter().map(|id| id.into()).collect::<Vec<_>>();
108
+ let users = db.prepare(format!("SELECT * FROM users WHERE id IN ({ph})"))
109
+ .bind(&binds)?.all().await?.results::<User>()?;
110
+ ```
111
+
112
+ For many independent statements in one hop, use `db.batch(vec![...])` (statements run sequentially in a single call).
113
+
114
+ Hoist binding lookups out of loops — good hygiene, cheap to do:
115
+
116
+ ```rust
117
+ let cfg = ctx.env.var("CONFIG")?.to_string(); // once, not per iteration
118
+ for row in &rows { /* use cfg */ }
119
+ ```
120
+
121
+ WASM wins on CPU-bound bulk work (parsing, crypto, number crunching) kept *inside* Rust. For trivial routing/string work the marshalling overhead can exceed the win — keep those paths thin.
122
+
69
123
  ## Anti-Patterns
70
124
 
71
125
  1. **Panics in production** -- Always `Result`, never `unwrap()`/`expect()`.
@@ -74,3 +128,11 @@ Parse body: `let body: CreateUserRequest = req.json().await?;`
74
128
  4. **Buffering large files** -- 128MB memory limit. Stream, don't buffer.
75
129
  5. **Raw SQL concatenation** -- Always `bind()` prepared statements.
76
130
  6. **Missing CORS** -- Handle OPTIONS preflight for API workers.
131
+ 7. **Cargo-culting `panic="abort"`** -- It's already the default on `wasm32-unknown-unknown` (no size win). Set the real size knobs (`opt-level="z"`, `lto=true`, `codegen-units=1`, `strip=true`); `worker-build` already auto-recovers from panics.
132
+ 8. **Chatty D1 calls** -- Per-row queries each cost a network round-trip. Collapse into one statement (expanded `IN` with bound params) or `db.batch(...)`.
133
+ 9. **WASM for trivial work** -- Boundary marshalling of small payloads can exceed the compute. Use WASM for bulk CPU work, not tiny string ops; keep large buffers from re-crossing.
134
+
135
+ ## See Also
136
+
137
+ - `ct-cloudflare-d1-kv` — same D1 round-trip batching (`IN` + bound params, `db.batch()`), documented from the binding side.
138
+ - `ct-protobuf-contracts` — decode once / `bytes` over `string`; generate `bytes` fields as `bytes::Bytes` for zero-copy.
@@ -56,6 +56,65 @@ setState("user", "settings", "theme", "light");
56
56
  setState("items", items => [...items, newItem]);
57
57
  ```
58
58
 
59
+ ## Performance
60
+
61
+ > _Verified against SolidJS 1.x (2026-06); 2.0 changes batching semantics._
62
+
63
+ Solid is fast by default: no VDOM, no re-render cycle. Wins come from list keying, splitting bundles, and keeping the reactive graph narrow.
64
+
65
+ ### `<For>` vs `<Index>`
66
+
67
+ | Use | When | Keyed by |
68
+ |-----|------|----------|
69
+ | `<For>` | array of objects; add/remove/reorder matters | item reference |
70
+ | `<Index>` | primitives, or fixed slots where only the value changes (inputs, cells) | position |
71
+
72
+ ```tsx
73
+ <For each={users()}>{(u) => <Row user={u} />}</For> // identity matters
74
+ <Index each={scores()}>{(s, i) => <Cell value={s()} />}</Index> // s is a SIGNAL: s(), i is a number
75
+ ```
76
+
77
+ Wrong choice tears down and rebuilds rows on every change. Note the swap: `<For>` gives `(item, index())`, `<Index>` gives `(item(), index)`.
78
+
79
+ ### Code-split for cold-start
80
+
81
+ Split at route/screen boundaries so the first screen isn't blocked by the whole app (critical under Capacitor).
82
+
83
+ ```tsx
84
+ const Settings = lazy(() => import("./screens/Settings"));
85
+ <Suspense fallback={<Spinner />}><Settings /></Suspense> // createResource suspends here too
86
+ ```
87
+
88
+ ### Hot paths: `batch` + `untrack`
89
+
90
+ Only writes inside a reactive computation (effect/memo) auto-batch. Writes from event handlers, timers, async callbacks, and resolved promises do NOT — wrap multiple writes in `batch()` (Solid 1.x; 2.0 will auto-batch everything).
91
+
92
+ ```tsx
93
+ const onSave = () => batch(() => { setX(1); setY(2); }); // event handler: NOT auto-batched -> batch()
94
+ setTimeout(() => batch(() => { setX(1); setY(2); }), 0); // async/timer: one update, not two
95
+ createEffect(() => { log(a()); untrack(() => b()); }); // subscribe to a, read b without subscribing
96
+ ```
97
+
98
+ ### Keep state granular
99
+
100
+ ```tsx
101
+ // Whole-object signal: every reader re-runs on ANY field change (new ref = notify)
102
+ const [user, setUser] = createSignal({ name, age });
103
+ // Store: path update notifies only that path's dependents
104
+ const [user, setUser] = createStore({ name, age });
105
+ setUser("age", 31); // name readers untouched
106
+ ```
107
+
108
+ Keep signals local; lift only what's truly shared.
109
+
110
+ ### Theme via attribute, not signal
111
+
112
+ Driving theme through a signal+context re-evaluates every consumer on toggle. Use a data-attribute + CSS vars — zero reactive work:
113
+
114
+ ```tsx
115
+ document.documentElement.dataset.theme = "dark"; // CSS: [data-theme="dark"] { --bg: #111 }
116
+ ```
117
+
59
118
  ## Anti-Patterns
60
119
 
61
120
  1. **Destructuring props** -- Breaks reactivity. Always `props.x`.
@@ -64,3 +123,14 @@ setState("items", items => [...items, newItem]);
64
123
  4. **Manual keys in For** -- `<For>` is keyed by reference automatically.
65
124
  5. **Signals outside reactive context** -- `count()` at top level captures once. Wrap in JSX or effects.
66
125
  6. **Forgetting to call signals** -- `count` is a getter, `count()` is the value.
126
+ 7. **`<Index>` for object lists / `<For>` for primitives** -- Mismatched keying rebuilds rows. Objects→`<For>`, primitives/slots→`<Index>`.
127
+ 8. **Theme/global toggles as signal+context** -- Re-evaluates all consumers. Use a `data-` attribute + CSS vars.
128
+ 9. **One signal holding a whole object** -- Any field change re-runs every reader. Use `createStore` for path-granular updates.
129
+ 10. **Eager-importing every screen** -- Blocks cold-start. `lazy()` + `<Suspense>` at route/screen boundaries.
130
+ 11. **Assuming event handlers auto-batch** -- They don't (Solid 1.x). Multiple writes in a handler/timer/async callback run dependents per-write; wrap them in `batch()`.
131
+
132
+ ## See Also
133
+
134
+ - `ct-vanilla-extract-patterns` — theme via `data-theme`/class swap (not signals); logical properties for RTL.
135
+ - `ct-i18n-typesafe` — defer locale loading behind a `createResource`-tracked `<Suspense>` (a bare `await` never trips Solid's boundary).
136
+ - `ct-capacitor-ui` — `lazy()` + `<Suspense>` screen splitting is the main lever for mobile cold-start.
@@ -51,7 +51,8 @@
51
51
  "ct-testing-patterns",
52
52
  "ct-vite-vitest-patterns",
53
53
  "ct-storybook-patterns",
54
- "ct-playwright-patterns"
54
+ "ct-playwright-patterns",
55
+ "ct-i18n-typesafe"
55
56
  ]
56
57
  }
57
58
  }
@@ -156,6 +156,58 @@ test("renders primary", () => {
156
156
  });
157
157
  ```
158
158
 
159
+ ## Performance
160
+
161
+ > _Verified against Storybook 8/9/10 + Vitest browser mode (2026-06)._
162
+
163
+ Browser-mode story tests are the slowest part of CI. Optimize for parallelism, lazy work, and caching.
164
+
165
+ ### Shard the Vitest run across CI jobs
166
+
167
+ ```bash
168
+ # CI matrix: 4 jobs, each runs 1/4 of the story FILES (sharding is file-level)
169
+ vitest run --project=storybook --shard=1/4 --reporter=blob # job 1
170
+ vitest run --project=storybook --shard=2/4 --reporter=blob # job 2 ... etc.
171
+ # final job merges results + coverage from all shards:
172
+ vitest --merge-reports --reporter=junit --coverage
173
+ ```
174
+
175
+ - `--project=storybook` skips the jsdom `unit` project when you only need stories.
176
+ - Browser tests already run files in parallel (`fileParallelism` defaults on). Don't disable it.
177
+ - `--reporter=blob` per shard + `--merge-reports` is required, or coverage is per-shard and incomplete.
178
+
179
+ ### Don't confuse `instances` with parallelism
180
+
181
+ `browser.instances` runs the **same** test files across different browsers/setups -- each extra instance re-runs the whole suite. It is not a way to add CPU parallelism.
182
+
183
+ ```typescript
184
+ browser: { enabled: true, headless: true, provider: playwright(),
185
+ instances: [{ browser: "chromium" }] }, // keep ONE for speed
186
+ // Add instances only to cover more browsers (each re-runs everything).
187
+ // Scale CPU via fileParallelism (within a job) + --shard (across CI jobs).
188
+ ```
189
+
190
+ ### Dev startup is already lazy
191
+
192
+ On-demand story loading is automatic in modern Storybook (Vite builder, code-split: a story's code loads only when rendered). There is no flag to enable -- it's simply the default in Storybook 8/9/10 (the old `storyStoreV7` flag is gone).
193
+
194
+ - Webpack's `lazyCompilation`/`fsCache` do **not** apply to the Vite builder; ignore that advice for this stack.
195
+ - Don't defeat code-splitting with eager glob imports of all stories in custom config.
196
+
197
+ ### Keep stories cheap
198
+
199
+ - Scope expensive setup **per-story**, not in `preview.ts` globals -- global decorators/loaders run for *every* story x every interaction test.
200
+ - `mswLoader` and theme providers as globals are fine if light; move heavy data setup to `parameters` on the stories that need it.
201
+ - Gate axe-core with `parameters.a11y.test` (`"error" | "todo" | "off"`), settable project -> component -> story (most specific wins). `test: "error"` globally runs the full ruleset on every story; downgrade or `"off"` where not needed, or run a11y in its own shard. (`tags` only include/exclude stories from a run -- they don't toggle a11y.)
202
+
203
+ ### Cache the Vite dep-prebundle between CI runs
204
+
205
+ ```yaml
206
+ # Cache node_modules/.vite (Vite's default cacheDir), keyed on the lockfile.
207
+ # This is the esbuild dependency pre-bundling cache used by dev / the browser-test run.
208
+ # Note: it does NOT speed up `storybook build` (vite build ignores it).
209
+ ```
210
+
159
211
  ## Anti-Patterns
160
212
 
161
213
  1. **Destructuring SolidJS props in stories** -- Pass props via `args`; use `createJSXDecorator` for decorators.
@@ -164,3 +216,13 @@ test("renders primary", () => {
164
216
  4. **Skipping error/edge-case stories** -- Always include loading, error, empty, boundary states.
165
217
  5. **Using `@storybook/test-runner`** -- Use `@storybook/addon-vitest` instead.
166
218
  6. **Registering `sb.mock` in story files** -- Register in `.storybook/preview.ts` only.
219
+ 7. **Running the full story suite in one CI job** -- Use `vitest --shard=N/M` across matrix jobs (with `--reporter=blob` + `--merge-reports`).
220
+ 8. **Duplicating same-browser `instances` to "go faster"** -- Each instance re-runs the whole suite. Scale via `fileParallelism` + `--shard`; add instances only for more browsers.
221
+ 9. **Heavy decorators/loaders in `preview.ts`** -- Global setup runs per-story; scope expensive providers/data per-story.
222
+ 10. **`a11y: { test: "error" }` globally with no scoping** -- Axe runs the full ruleset per story; downgrade per story via `parameters.a11y.test` or use a dedicated a11y shard.
223
+ 11. **Discarding the Vite cache between CI runs** -- Persist `node_modules/.vite` keyed on the lockfile (dev / browser-test speedup; not `storybook build`).
224
+
225
+ ## See Also
226
+
227
+ - `ct-testing-patterns` — the canonical cross-runner test-speed rule (file-level `--shard` + blob/merge-reports, parallelism).
228
+ - `ct-vite-vitest-patterns` — owns the deep Vitest sharding/`--project`/cache config the browser-mode run inherits.
@@ -67,6 +67,58 @@ const props = defineProperties({
67
67
  export const sprinkles = createSprinkles(props);
68
68
  ```
69
69
 
70
+ ## Performance
71
+
72
+ > _Verified against vanilla-extract (2026-06)._
73
+
74
+ Zero-runtime is the point: all classes/vars are extracted at build, the browser does the styling. Keep it that way — the wins below are about *bytes shipped* and *theme-swap cost*, not JS.
75
+
76
+ ### Theme swap = one attribute write, zero re-render
77
+
78
+ `createTheme` returns a **class that only sets CSS custom properties**. Swapping themes is one DOM write; the cascade repaints — no component re-render, no JS restyle.
79
+
80
+ ```ts
81
+ // createTheme(tokens) -> [class, contract]; createTheme(contract, tokens) -> class
82
+ export const [themeClass, vars] = createTheme({ color: { bg: '#fff', text: '#111' } });
83
+ export const dark = createTheme(vars, { color: { bg: '#111', text: '#eee' } }); // same contract, new values
84
+
85
+ // runtime: flip the class on a root element — that's the entire cost
86
+ root.className = isDark ? dark : themeClass; // no setState, no re-render
87
+ ```
88
+
89
+ Prefer the attribute path if you'd rather not own the class: register the theme on a selector at build time, then the runtime cost is a single `data-*` write.
90
+
91
+ ```ts
92
+ // build time (.css.ts): bind a theme to a selector
93
+ createGlobalTheme('[data-theme="dark"]', vars, { color: { bg: '#111', text: '#eee' } });
94
+ // runtime: one attribute write, cascade does the rest
95
+ document.documentElement.dataset.theme = 'dark';
96
+ ```
97
+
98
+ `assignVars` is the *build-time* way to assign a whole contract in one shot inside a `style()`/`globalStyle()` `vars` block — handy for declaring a theme on a selector — not a runtime knob. Never put theme values in component state; let the CSS-variable cascade do it.
99
+
100
+ ### Sprinkles dedup — CSS grows with declarations, not components
101
+
102
+ Each property-value(-condition) emits **one class, once**. 500 components calling `sprinkles({ padding: 'md' })` share a single class. Stylesheet size is bounded by `properties x values x conditions`, not usage. (Note: `style({ padding: vars.space.md })` does *not* dedupe across call sites — route high-frequency props through sprinkles to get the shared class.)
103
+
104
+ ```ts
105
+ // good: high-frequency layout/spacing -> sprinkles (deduped)
106
+ <div class={sprinkles({ display: 'flex', paddingInline: 'lg' })} />
107
+ // reserve style() for genuine one-offs
108
+ ```
109
+
110
+ Keep condition sets lean: every condition multiplies class count per property. A property with 6 values across 3 breakpoints = 18 classes.
111
+
112
+ ### Logical properties (also your RTL story)
113
+
114
+ Author `paddingInline` / `marginInline` / `insetInlineStart` instead of `left`/`right`. One stylesheet flips for RTL via `dir="rtl"` — no duplicate LTR/RTL CSS, no runtime mirroring lib.
115
+
116
+ | physical | logical |
117
+ | ------------------- | -------------------- |
118
+ | `paddingLeft/Right` | `paddingInline` |
119
+ | `marginTop/Bottom` | `marginBlock` |
120
+ | `left` / `right` | `insetInlineStart/End` |
121
+
70
122
  ## Anti-Patterns
71
123
 
72
124
  1. **Inline styles** -- Use `style()` or `sprinkles()`.
@@ -74,3 +126,13 @@ export const sprinkles = createSprinkles(props);
74
126
  3. **Overusing globalStyle** -- Prefer scoped `style()`. Reserve global for resets only.
75
127
  4. **Non-`.css.ts` files** -- Build plugin only processes `*.css.ts`.
76
128
  5. **Raw CSS variable strings** -- Use the `vars` object for type-safe references.
129
+ 6. **Theme in component state** — Re-renders the tree on toggle. Swap a class/`data-theme` on the root; the CSS-var cascade handles the rest with zero re-render.
130
+ 7. **Bespoke `style()` for common spacing/layout** — Defeats sprinkles dedup (`style()` doesn't merge identical declarations across call sites). Route high-frequency props through `sprinkles()`; one shared class instead of N.
131
+ 8. **Physical left/right** — Forces a second RTL stylesheet. Use logical `*Inline`/`*Block` props; one sheet flips with `dir="rtl"`.
132
+ 9. **Wide conditions x properties matrix** — Class count = properties x values x conditions. Trim breakpoints and property lists before they explode the CSS.
133
+
134
+ ## See Also
135
+
136
+ - `ct-solidjs-patterns` — keep theme out of the reactive graph; swap a `data-theme` attribute, not a signal.
137
+ - `ct-i18n-typesafe` — RTL = `dir` from locale (i18n) + logical properties (here).
138
+ - `ct-capacitor-ui` — mobile webview: animate `transform`/`opacity` only; avoid `box-shadow`/`filter` on scrolled nodes.
@@ -34,7 +34,7 @@
34
34
  "createTheme\\("
35
35
  ]
36
36
  },
37
- "relatedSkills": ["ct-solidjs-patterns"]
37
+ "relatedSkills": ["ct-solidjs-patterns", "ct-i18n-typesafe"]
38
38
  }
39
39
  }
40
40
  }
@@ -470,6 +470,67 @@ The compat layer auto-converts old keys. New code should use the new keys.
470
470
 
471
471
  ---
472
472
 
473
+ ## Performance
474
+
475
+ > _Verified against Vite 8 / Rolldown / Vitest 4 (2026-06)._
476
+
477
+ ### Dev Cold-Start & HMR: optimizeDeps
478
+
479
+ Vite pre-bundles bare-import deps on first run, cached in `node_modules/.vite`. A missing entry triggers a mid-session "new dependency optimized, reloading" full reload -- the #1 HMR killer.
480
+
481
+ ```typescript
482
+ optimizeDeps: {
483
+ include: ['lodash-es', 'pkg/cjs-only'], // force-bundle CJS / plugin-injected / lazy deps not auto-discovered
484
+ exclude: ['small-pure-esm-lib'], // only small pure-ESM pkgs, or deps that break when bundled
485
+ }
486
+ ```
487
+
488
+ Do **not** `exclude` a large multi-file ESM dep (e.g. `lodash-es` ships 600+ modules) -- unbundled it floods the browser with parallel requests and slows page loads; keep it pre-bundled. Never `exclude` a CJS dep. If an excluded ESM dep has a nested CJS dep, add that nested dep to `include`.
489
+
490
+ `vite --force` (or `rm -rf node_modules/.vite`) busts a stale cache. Pair with `server.warmup.clientFiles` (already covered).
491
+
492
+ ### Bundle Size: measure, then split
493
+
494
+ ```typescript
495
+ import { visualizer } from 'rollup-plugin-visualizer' // ^7 for Vite 8 / Rolldown
496
+ process.env.ANALYZE && visualizer({ gzipSize: true, brotliSize: true }) // ANALYZE=1 vite build
497
+ ```
498
+
499
+ Read the gzip/brotli column to find oversized chunks. Route-level dynamic `import()` is the primary code-split. Use manual chunks only to isolate stable vendor deps. In Vite 8 / Rolldown the `manualChunks` object form is removed and the function form is deprecated -- use `rolldownOptions.output.codeSplitting` (the old `advancedChunks` key is a deprecated alias):
500
+
501
+ ```typescript
502
+ build: { rolldownOptions: { output: {
503
+ codeSplitting: { groups: [{ name: 'vendor', test: /node_modules/ }] },
504
+ } } }
505
+ ```
506
+
507
+ ### build.target & CSS
508
+
509
+ Don't lower `target: 'baseline-widely-available'` without cause. A low target (`es2015`) makes Oxc down-level async/await, optional chaining, and spread into verbose helpers -- bigger bundles, slower transforms. Match real browser support.
510
+
511
+ ```typescript
512
+ css: { transformer: 'lightningcss' }, // experimental: faster + smaller than the postcss default
513
+ ```
514
+
515
+ `build.cssMinify` already defaults to `'lightningcss'` in Vite 8 -- no action needed for minification; the opt-in above is only for the CSS *transformer*.
516
+
517
+ ### Vitest Run Speed: pool & isolate
518
+
519
+ `isolate: true` (default) builds a fresh environment per test file -- safe but slow. Disabling it is often the single biggest speedup for pure-logic suites.
520
+
521
+ | Goal | Setting |
522
+ |---|---|
523
+ | Safe default (global mutation, `process.env`, fake timers) | `pool: 'forks'`, `isolate: true` |
524
+ | Fastest, pure side-effect-free Node logic | `pool: 'threads'`, `isolate: false` |
525
+ | Fast jsdom (leaks globals -- verify) | `pool: 'vmThreads'` (isolation can't be disabled) |
526
+ | Debug a flaky/order-dependent test | `--no-file-parallelism` or `poolOptions.threads.singleThread` |
527
+
528
+ ```typescript
529
+ test: { pool: 'threads', isolate: false } // big speedup for logic suites; leaks global state between files
530
+ ```
531
+
532
+ > `pool: 'forks'` and `isolate: true` are the Vitest defaults. Gains from `threads` show mostly in larger suites; `forks` stays safer for native-module / segfault-prone tests.
533
+
473
534
  ## Anti-Patterns
474
535
 
475
536
  ### Vite
@@ -479,6 +540,10 @@ The compat layer auto-converts old keys. New code should use the new keys.
479
540
  3. **Duplicating resolve config for tests** -- Vitest inherits Vite's aliases and plugins. Don't redeclare.
480
541
  4. **Using `rollupOptions` in Vite 8** -- Works via compat but generates warnings. Use `rolldownOptions`.
481
542
  5. **Not externalizing peer deps in library mode** -- Bundling framework deps causes duplicate instances.
543
+ 6. **Lowering `build.target` by habit** -- Down-levels modern syntax into helper bloat and slows transforms. Set it to your actual browser support.
544
+ 7. **Tuning chunks without measuring** -- Run `ANALYZE=1 vite build` with `visualizer` first. Over-grouping into manual chunks defeats per-route lazy loading and cache granularity.
545
+ 8. **`optimizeDeps.exclude` on large ESM deps** -- Unbundling a multi-file ESM package floods the browser with requests. Exclude only small pure-ESM or bundle-breaking deps.
546
+ 9. **Ignoring the "new dependency optimized, reloading" warning** -- A dep escaped pre-bundle discovery. Add it to `optimizeDeps.include` to stop full reloads.
482
547
 
483
548
  ### Vitest
484
549
 
@@ -490,3 +555,9 @@ The compat layer auto-converts old keys. New code should use the new keys.
490
555
  6. **Snapshot-only testing** -- Snapshots catch regressions but don't verify correctness. Add explicit assertions.
491
556
  7. **Global jsdom environment** -- Use `test.projects` to run DOM tests in jsdom and logic tests in node.
492
557
  8. **Using `vi.spyOn` in browser mode** -- Use `vi.mock('./module', { spy: true })` instead.
558
+ 9. **Leaving `isolate: true` on pure-logic suites** -- A large run-speed cost. Set `isolate: false` + `pool: 'threads'` for side-effect-free tests; keep `forks` + `isolate: true` where global/env mutation needs isolation.
559
+
560
+ ## See Also
561
+
562
+ - `ct-testing-patterns` — the canonical cross-runner test-speed rule (parallelism, file-level `--shard` + blob/merge-reports, isolate trade-off).
563
+ - `ct-solidjs-patterns` — `lazy()` route/screen code-splitting relies on Vite's dynamic-import chunking.