From 3ff08f9e35944603d547ac62fd6bd68171ce53b4 Mon Sep 17 00:00:00 2001 From: Frederic Cabassut Date: Fri, 6 Mar 2026 14:11:36 +0100 Subject: [PATCH] chore: add agent skills for Svelte 5, SvelteKit, TypeScript, and Drizzle Install project-level skills via skills.sh CLI: - svelte5-best-practices (runes, snippets, events, migration) - sveltekit-structure (routing, layouts, error boundaries) - sveltekit-data-flow (load functions, form actions, serialization) - typescript-best-practices (type-first development, discriminated unions) - drizzle-migrations (SQLite schema, query patterns) Co-Authored-By: Claude Opus 4.6 --- .agents/skills/drizzle-migrations/SKILL.md | 423 +++++++++++++++++ .../skills/svelte5-best-practices/SKILL.md | 81 ++++ .../references/events.md | 353 ++++++++++++++ .../references/migration.md | 339 ++++++++++++++ .../references/performance.md | 443 ++++++++++++++++++ .../references/runes.md | 394 ++++++++++++++++ .../references/snippets.md | 291 ++++++++++++ .../references/sveltekit.md | 401 ++++++++++++++++ .../references/typescript.md | 305 ++++++++++++ .agents/skills/sveltekit-data-flow/README.md | 17 + .agents/skills/sveltekit-data-flow/SKILL.md | 79 ++++ .../references/client-auth-invalidation.md | 125 +++++ .../references/error-redirect-handling.md | 407 ++++++++++++++++ .../references/form-actions.md | 316 +++++++++++++ .../references/load-functions.md | 292 ++++++++++++ .../references/serialization.md | 317 +++++++++++++ .agents/skills/sveltekit-structure/README.md | 17 + .agents/skills/sveltekit-structure/SKILL.md | 79 ++++ .../references/error-handling.md | 274 +++++++++++ .../references/file-naming.md | 69 +++ .../references/layout-patterns.md | 387 +++++++++++++++ .../references/ssr-hydration.md | 92 ++++ .../references/svelte-boundary.md | 150 ++++++ .../skills/typescript-best-practices/SKILL.md | 270 +++++++++++ .gitignore | 3 + skills-lock.json | 30 ++ 26 files changed, 5954 insertions(+) create mode 100644 .agents/skills/drizzle-migrations/SKILL.md create mode 100644 .agents/skills/svelte5-best-practices/SKILL.md create mode 100644 .agents/skills/svelte5-best-practices/references/events.md create mode 100644 .agents/skills/svelte5-best-practices/references/migration.md create mode 100644 .agents/skills/svelte5-best-practices/references/performance.md create mode 100644 .agents/skills/svelte5-best-practices/references/runes.md create mode 100644 .agents/skills/svelte5-best-practices/references/snippets.md create mode 100644 .agents/skills/svelte5-best-practices/references/sveltekit.md create mode 100644 .agents/skills/svelte5-best-practices/references/typescript.md create mode 100644 .agents/skills/sveltekit-data-flow/README.md create mode 100644 .agents/skills/sveltekit-data-flow/SKILL.md create mode 100644 .agents/skills/sveltekit-data-flow/references/client-auth-invalidation.md create mode 100644 .agents/skills/sveltekit-data-flow/references/error-redirect-handling.md create mode 100644 .agents/skills/sveltekit-data-flow/references/form-actions.md create mode 100644 .agents/skills/sveltekit-data-flow/references/load-functions.md create mode 100644 .agents/skills/sveltekit-data-flow/references/serialization.md create mode 100644 .agents/skills/sveltekit-structure/README.md create mode 100644 .agents/skills/sveltekit-structure/SKILL.md create mode 100644 .agents/skills/sveltekit-structure/references/error-handling.md create mode 100644 .agents/skills/sveltekit-structure/references/file-naming.md create mode 100644 .agents/skills/sveltekit-structure/references/layout-patterns.md create mode 100644 .agents/skills/sveltekit-structure/references/ssr-hydration.md create mode 100644 .agents/skills/sveltekit-structure/references/svelte-boundary.md create mode 100644 .agents/skills/typescript-best-practices/SKILL.md create mode 100644 skills-lock.json diff --git a/.agents/skills/drizzle-migrations/SKILL.md b/.agents/skills/drizzle-migrations/SKILL.md new file mode 100644 index 0000000..d638384 --- /dev/null +++ b/.agents/skills/drizzle-migrations/SKILL.md @@ -0,0 +1,423 @@ +--- +name: drizzle-migrations +description: Drizzle ORM schema management and SQLite migrations — adding tables, modifying columns, creating indexes, generating and running migrations, Drizzle query patterns. NOT for Prisma, TypeORM, + Sequelize, or raw SQL migration tools. +allowed-tools: Read,Write,Edit,Bash(npm:*,npx:*) +metadata: + category: Data & Analytics + tags: + - database + - drizzle + - migrations + pairs-with: + - skill: database-design-patterns + reason: Schema migration implementation follows database design pattern decisions + - skill: supabase-admin + reason: Drizzle migrations run against Supabase PostgreSQL databases with RLS considerations + - skill: fullstack-debugger + reason: Migration failures and schema mismatches are common fullstack debugging scenarios +--- + +# Drizzle ORM Migrations + +This skill helps you manage database schema changes using Drizzle ORM with SQLite. + +## When to Use + +✅ **USE this skill for:** +- Adding new tables or modifying existing columns +- Generating and running database migrations +- Drizzle-specific query patterns and relations +- SQLite schema best practices with Drizzle +- Setting up Drizzle configuration + +❌ **DO NOT use for:** +- Supabase/PostgreSQL → use `supabase-admin` skill +- Raw SQL without Drizzle → use standard SQL resources +- Prisma ORM → different syntax and patterns +- General database design theory → use database architecture resources + +## Project Setup + +**Configuration**: `drizzle.config.ts` +```typescript +import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + schema: './src/db/schema.ts', + out: './drizzle', + dialect: 'sqlite', + dbCredentials: { + url: './data/app.db', + }, +}); +``` + +**Commands**: +```bash +npm run db:generate # Generate migration files +npm run db:push # Push schema directly (dev only) +npm run db:studio # Open Drizzle Studio GUI +``` + +## Schema Definition + +Location: `src/db/schema.ts` + +### Table Definition + +```typescript +import { sqliteTable, text, integer, real, blob } from 'drizzle-orm/sqlite-core'; +import { relations } from 'drizzle-orm'; + +// Basic table +export const users = sqliteTable('users', { + id: text('id').primaryKey(), + email: text('email').notNull().unique(), + username: text('username').notNull(), + passwordHash: text('password_hash'), + createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`), + updatedAt: text('updated_at'), +}); + +// Table with foreign key +export const checkIns = sqliteTable('check_ins', { + id: text('id').primaryKey(), + userId: text('user_id').notNull().references(() => users.id, { + onDelete: 'cascade', + }), + mood: integer('mood').notNull(), + cravingLevel: integer('craving_level').notNull(), + sleepHours: real('sleep_hours'), + notes: text('notes'), + createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`), +}); + +// Table with composite index +export const auditLog = sqliteTable('audit_log', { + id: text('id').primaryKey(), + userId: text('user_id').notNull(), + action: text('action').notNull(), + targetType: text('target_type'), + targetId: text('target_id'), + details: text('details'), // JSON string + createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`), +}, (table) => ({ + userActionIdx: index('idx_audit_user_action').on(table.userId, table.action), + createdAtIdx: index('idx_audit_created').on(table.createdAt), +})); +``` + +### Relations + +```typescript +export const usersRelations = relations(users, ({ many }) => ({ + checkIns: many(checkIns), + sessions: many(sessions), + journalEntries: many(journalEntries), +})); + +export const checkInsRelations = relations(checkIns, ({ one }) => ({ + user: one(users, { + fields: [checkIns.userId], + references: [users.id], + }), +})); +``` + +## Column Types + +### SQLite Types in Drizzle + +```typescript +import { + sqliteTable, + text, // TEXT - strings, JSON, dates + integer, // INTEGER - numbers, booleans (0/1) + real, // REAL - floating point + blob, // BLOB - binary data +} from 'drizzle-orm/sqlite-core'; + +const examples = sqliteTable('examples', { + // Strings + name: text('name').notNull(), + description: text('description'), + + // Numbers + count: integer('count').notNull().default(0), + rating: real('rating'), + + // Booleans (stored as 0/1) + isActive: integer('is_active', { mode: 'boolean' }).default(true), + + // Dates (stored as ISO strings) + createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`), + expiresAt: text('expires_at'), + + // JSON (stored as TEXT) + metadata: text('metadata', { mode: 'json' }), + + // Enums (stored as TEXT) + status: text('status', { enum: ['pending', 'active', 'archived'] }), +}); +``` + +## Migration Strategies + +### Strategy 1: Push (Development Only) + +```bash +npm run db:push +``` + +- Directly applies schema changes +- Fast for development +- **Never use in production** + +### Strategy 2: Generate & Migrate (Production) + +```bash +# 1. Generate migration file +npm run db:generate + +# 2. Review generated SQL in /drizzle folder + +# 3. Apply migration (in code or manually) +``` + +### Applying Migrations in Code + +```typescript +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { migrate } from 'drizzle-orm/better-sqlite3/migrator'; +import Database from 'better-sqlite3'; + +const sqlite = new Database('./data/app.db'); +const db = drizzle(sqlite); + +// Run migrations +migrate(db, { migrationsFolder: './drizzle' }); +``` + +## Common Schema Changes + +### Adding a New Table + +```typescript +// 1. Add to schema.ts +export const newFeature = sqliteTable('new_feature', { + id: text('id').primaryKey(), + userId: text('user_id').notNull().references(() => users.id), + name: text('name').notNull(), + createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`), +}); + +// 2. Add relations +export const newFeatureRelations = relations(newFeature, ({ one }) => ({ + user: one(users, { + fields: [newFeature.userId], + references: [users.id], + }), +})); + +// 3. Generate migration +// npm run db:generate +``` + +### Adding a Column + +```typescript +// In schema.ts, add the new column +export const users = sqliteTable('users', { + // existing columns... + newColumn: text('new_column'), // Add this +}); + +// Generate migration +// npm run db:generate +``` + +### Adding an Index + +```typescript +export const messages = sqliteTable('messages', { + id: text('id').primaryKey(), + conversationId: text('conversation_id').notNull(), + createdAt: text('created_at').notNull(), +}, (table) => ({ + // Add index + convCreatedIdx: index('idx_messages_conv_created') + .on(table.conversationId, table.createdAt), +})); +``` + +### Renaming (Requires Manual SQL) + +SQLite doesn't support direct column renames in older versions. For complex changes: + +```sql +-- drizzle/XXXX_rename_column.sql +-- Manual migration for column rename + +-- 1. Create new table with desired schema +CREATE TABLE users_new ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, -- renamed from username + created_at TEXT NOT NULL +); + +-- 2. Copy data +INSERT INTO users_new SELECT id, email, username, created_at FROM users; + +-- 3. Drop old table +DROP TABLE users; + +-- 4. Rename new table +ALTER TABLE users_new RENAME TO users; +``` + +## Query Patterns + +### Basic Queries + +```typescript +import { db } from '@/db'; +import { eq, and, or, desc, asc, like, gte, lte } from 'drizzle-orm'; +import { users, checkIns } from '@/db/schema'; + +// Select all +const allUsers = await db.select().from(users); + +// Select with conditions +const activeUsers = await db + .select() + .from(users) + .where(eq(users.isActive, true)); + +// Select specific columns +const userEmails = await db + .select({ id: users.id, email: users.email }) + .from(users); + +// Complex where clause +const results = await db + .select() + .from(checkIns) + .where( + and( + eq(checkIns.userId, userId), + gte(checkIns.createdAt, startDate), + lte(checkIns.createdAt, endDate) + ) + ) + .orderBy(desc(checkIns.createdAt)) + .limit(30); +``` + +### Insert + +```typescript +// Single insert +const [newUser] = await db + .insert(users) + .values({ + id: generateId(), + email: 'user@example.com', + username: 'newuser', + }) + .returning(); + +// Bulk insert +await db.insert(checkIns).values([ + { id: '1', userId, mood: 7, cravingLevel: 2 }, + { id: '2', userId, mood: 8, cravingLevel: 1 }, +]); + +// Upsert (insert or update) +await db + .insert(users) + .values({ id: 'user-1', email: 'new@example.com' }) + .onConflictDoUpdate({ + target: users.id, + set: { email: 'new@example.com' }, + }); +``` + +### Update + +```typescript +await db + .update(users) + .set({ username: 'newname', updatedAt: new Date().toISOString() }) + .where(eq(users.id, userId)); +``` + +### Delete + +```typescript +// Always use WHERE clause! +await db + .delete(checkIns) + .where(eq(checkIns.id, checkInId)); + +// Delete with multiple conditions +await db + .delete(sessions) + .where( + and( + eq(sessions.userId, userId), + lte(sessions.expiresAt, new Date().toISOString()) + ) + ); +``` + +### Joins + +```typescript +const userWithCheckIns = await db + .select({ + user: users, + checkIn: checkIns, + }) + .from(users) + .leftJoin(checkIns, eq(users.id, checkIns.userId)) + .where(eq(users.id, userId)); +``` + +### Aggregations + +```typescript +import { count, avg, sum, max, min } from 'drizzle-orm'; + +const stats = await db + .select({ + totalCheckIns: count(), + avgMood: avg(checkIns.mood), + maxStreak: max(checkIns.streak), + }) + .from(checkIns) + .where(eq(checkIns.userId, userId)); +``` + +## Best Practices + +1. **Always use transactions for related changes** +```typescript +await db.transaction(async (tx) => { + await tx.insert(users).values(userData); + await tx.insert(profiles).values(profileData); +}); +``` + +2. **Always include WHERE on DELETE/UPDATE** +3. **Use indexes for frequently queried columns** +4. **Store dates as ISO strings for SQLite** +5. **Use `returning()` to get inserted/updated rows** +6. **Generate migrations, don't push to production** + +## References + +- [Drizzle ORM Docs](https://orm.drizzle.team/docs/overview) +- [Drizzle SQLite](https://orm.drizzle.team/docs/get-started-sqlite) +- [Drizzle Migrations](https://orm.drizzle.team/docs/migrations) diff --git a/.agents/skills/svelte5-best-practices/SKILL.md b/.agents/skills/svelte5-best-practices/SKILL.md new file mode 100644 index 0000000..b504142 --- /dev/null +++ b/.agents/skills/svelte5-best-practices/SKILL.md @@ -0,0 +1,81 @@ +--- +name: svelte5-best-practices +description: "Svelte 5 runes, snippets, SvelteKit patterns, and modern best practices for TypeScript and component development. Use when writing, reviewing, or refactoring Svelte 5 components and SvelteKit applications. Triggers on: Svelte components, runes ($state, $derived, $effect, $props, $bindable, $inspect), snippets ({#snippet}, {@render}), event handling, SvelteKit data loading, form actions, Svelte 4 to Svelte 5 migration, store to rune migration, slots to snippets migration, TypeScript props typing, generic components, SSR state isolation, performance optimization, or component testing." +license: MIT +metadata: + author: ejirocodes + version: '1.0.0' +--- + +# Svelte 5 Best Practices + +## Quick Reference + +| Topic | When to Use | Reference | +|-------|-------------|-----------| +| **Runes** | $state, $derived, $effect, $props, $bindable, $inspect | [runes.md](references/runes.md) | +| **Snippets** | Replacing slots, {#snippet}, {@render} | [snippets.md](references/snippets.md) | +| **Events** | onclick handlers, callback props, context API | [events.md](references/events.md) | +| **TypeScript** | Props typing, generic components | [typescript.md](references/typescript.md) | +| **Migration** | Svelte 4 to 5, stores to runes | [migration.md](references/migration.md) | +| **SvelteKit** | Load functions, form actions, SSR, page typing | [sveltekit.md](references/sveltekit.md) | +| **Performance** | Universal reactivity, avoiding over-reactivity, streaming | [performance.md](references/performance.md) | + +## Essential Patterns + +### Reactive State + +```svelte + +``` + +### Component Props + +```svelte + +``` + +### Snippets (replacing slots) + +```svelte + + +{@render header?.()} +{@render children()} +``` + +### Event Handlers + +```svelte + + +``` + +### Callback Props (replacing createEventDispatcher) + +```svelte + + + +``` + +## Common Mistakes + +1. **Using `let` without `$state`** - Variables are not reactive without `$state()` +2. **Using `$effect` for derived values** - Use `$derived` instead +3. **Using `on:click` syntax** - Use `onclick` in Svelte 5 +4. **Using `createEventDispatcher`** - Use callback props instead +5. **Using ``** - Use snippets with `{@render}` +6. **Forgetting `$bindable()`** - Required for `bind:` to work +7. **Setting module-level state in SSR** - Causes cross-request leaks +8. **Sequential awaits in load functions** - Use `Promise.all` for parallel requests diff --git a/.agents/skills/svelte5-best-practices/references/events.md b/.agents/skills/svelte5-best-practices/references/events.md new file mode 100644 index 0000000..5cf1c9a --- /dev/null +++ b/.agents/skills/svelte5-best-practices/references/events.md @@ -0,0 +1,353 @@ +# Svelte 5 Events Reference + +## Table of Contents +- [Event Handler Syntax](#event-handler-syntax) +- [Callback Props Pattern](#callback-props-pattern) +- [Context API](#context-api) + +--- + +## Event Handler Syntax + +Svelte 5 replaces `on:click` directive syntax with standard HTML attribute syntax `onclick`. + +### Basic Event Handlers + +**Svelte 4:** +```svelte + + +
...
+``` + +**Svelte 5:** +```svelte + + +
...
+``` + +### Event Modifiers Migration + +Event modifiers no longer exist. Use wrapper functions: + +**Svelte 4:** +```svelte +
...
+ +``` + +**Svelte 5:** +```svelte + + +
...
+ +``` + +### Capture, Passive, and NonPassive + +```svelte + +
+ +
+ + +
...
+ + +
e.preventDefault()}>...
+``` + +### Inline Handlers + +```svelte + + name = e.target.value} /> +``` + +### Event Handler Shorthand + +```svelte + + + +``` + +### Spreading Event Handlers + +```svelte + + + +``` + +### Multiple Handlers for Same Event + +In Svelte 5, combine logic into one handler: + +```svelte + + + +``` + +### TypeScript Event Typing + +```svelte + +``` + +--- + +## Callback Props Pattern + +Svelte 5 deprecates `createEventDispatcher` in favor of callback props for component-to-parent communication. + +### Basic Event Pattern + +**Svelte 4:** +```svelte + + + + + + + + + + + + + + + + save()} + oncancel={() => reset()} + onclose={() => visible = false} +/> +``` + +### Typed Callbacks with TypeScript + +```svelte + +``` + +### Forwarding Native Events + +```svelte + + + +``` + +--- + +## Context API + +Context functions must be called synchronously during component initialization. + +### Correct Context Usage + +```svelte + +``` + +### Reactive Context Values + +Pass `$state` objects for reactive context: + +```svelte + + + +{@render children()} + + + + +

Theme: {themeContext.current}

+ +``` + +### Type-Safe Context Pattern + +```ts +// context.ts +import { setContext, getContext } from 'svelte'; + +const THEME_KEY = Symbol('theme'); + +interface ThemeContext { + current: 'light' | 'dark'; + toggle: () => void; +} + +export function setThemeContext(context: ThemeContext) { + setContext(THEME_KEY, context); +} + +export function getThemeContext(): ThemeContext { + const context = getContext(THEME_KEY); + if (!context) throw new Error('Theme context not found'); + return context; +} +``` + +### Avoid Generic Keys + +```svelte + +``` + +### hasContext Check + +```svelte + +``` + +### Context Boundaries + +Context flows down the component tree: + +```svelte + + + + +``` diff --git a/.agents/skills/svelte5-best-practices/references/migration.md b/.agents/skills/svelte5-best-practices/references/migration.md new file mode 100644 index 0000000..7d1d863 --- /dev/null +++ b/.agents/skills/svelte5-best-practices/references/migration.md @@ -0,0 +1,339 @@ +# Svelte 4 to Svelte 5 Migration Reference + +## Table of Contents +- [Migrating Reactive Statements](#migrating-reactive-statements) +- [Migrating Stores to Runes](#migrating-stores-to-runes) + +--- + +## Migrating Reactive Statements + +Svelte 5 replaces `$:` with `$derived` (for values) and `$effect` (for side effects). + +### Computed Values: $: to $derived + +**Svelte 4:** +```svelte + +``` + +**Svelte 5:** +```svelte + +``` + +### Complex Computed Values: $derived.by + +**Svelte 4:** +```svelte + +``` + +**Svelte 5:** +```svelte + +``` + +### Side Effects: $: to $effect + +**Svelte 4:** +```svelte + +``` + +**Svelte 5:** +```svelte + +``` + +### Conditional Side Effects + +**Svelte 4:** +```svelte + +``` + +**Svelte 5:** +```svelte + +``` + +### Props Migration + +**Svelte 4:** +```svelte + +``` + +**Svelte 5:** +```svelte + +``` + +### Effect with Cleanup + +**Svelte 4:** +```svelte + +``` + +**Svelte 5:** +```svelte + +``` + +### Migration Cheat Sheet + +| Svelte 4 Pattern | Svelte 5 Replacement | +|------------------|----------------------| +| `let x = 0` (in component) | `let x = $state(0)` | +| `export let prop` | `let { prop } = $props()` | +| `$: derived = expr` | `let derived = $derived(expr)` | +| `$: { complex }` (value) | `let x = $derived.by(() => { ... })` | +| `$: console.log(x)` | `$effect(() => console.log(x))` | +| `$: if (x) { ... }` | `$effect(() => { if (x) { ... } })` | +| `$: document.title = x` | `$effect(() => { document.title = x })` | + +### Automated Migration + +```bash +npx sv migrate svelte-5 +``` + +--- + +## Migrating Stores to Runes + +Svelte 5 runes can replace most store use cases with simpler, more direct reactive state. + +### Local Component State + +**Svelte 4:** +```svelte + + + +``` + +**Svelte 5:** +```svelte + + + +``` + +### Shared State Across Components + +**Svelte 4 (stores.ts):** +```ts +import { writable } from 'svelte/store'; +export const user = writable(null); +export const theme = writable('light'); +``` + +**Svelte 5 (state.svelte.ts):** +```ts +export const user = $state(null); +export const theme = $state({ current: 'light' as 'light' | 'dark' }); + +export function setTheme(newTheme: 'light' | 'dark') { + theme.current = newTheme; +} +``` + +```svelte + + +

Theme: {theme.current}

+ +``` + +### Derived Store to $derived + +**Svelte 4:** +```ts +import { writable, derived } from 'svelte/store'; +export const items = writable([]); +export const completedCount = derived(items, $items => + $items.filter(i => i.done).length +); +``` + +**Svelte 5:** +```ts +// state.svelte.ts +export const items = $state([]); + +export function getCompletedCount() { + return items.filter(i => i.done).length; +} +``` + +```svelte + +``` + +### Custom Store to Reactive Class + +**Svelte 4:** +```ts +function createCounter() { + const { subscribe, set, update } = writable(0); + return { + subscribe, + increment: () => update(n => n + 1), + decrement: () => update(n => n - 1), + reset: () => set(0) + }; +} +export const counter = createCounter(); +``` + +**Svelte 5:** +```ts +// counter.svelte.ts +class Counter { + value = $state(0); + increment() { this.value++; } + decrement() { this.value--; } + reset() { this.value = 0; } +} + +export const counter = new Counter(); +``` + +```svelte + + +

{counter.value}

+ +``` + +### Async State Pattern + +**Svelte 5:** +```ts +// api.svelte.ts +export const state = $state({ + data: null as Data | null, + loading: false, + error: null as Error | null +}); + +export async function fetchData() { + state.loading = true; + state.error = null; + + try { + const res = await fetch('/api/data'); + state.data = await res.json(); + } catch (e) { + state.error = e as Error; + } finally { + state.loading = false; + } +} +``` + +### When to Still Use Stores + +1. **Library interop** - Libraries that expect Svelte stores +2. **Legacy code** - Gradual migration +3. **Observable patterns** - When you need the subscribe API +4. **Server-side rendering** - Stores have built-in SSR handling + +### Store to Rune Cheat Sheet + +| Store Pattern | Rune Replacement | +|---------------|------------------| +| `writable(value)` | `$state(value)` | +| `$store` (auto-subscribe) | Direct access to `$state` value | +| `store.set(x)` | `state = x` | +| `store.update(fn)` | Direct mutation or reassignment | +| `derived(store, fn)` | `$derived(fn())` in component | +| `readable(value, start)` | `$state` + `$effect` for setup | diff --git a/.agents/skills/svelte5-best-practices/references/performance.md b/.agents/skills/svelte5-best-practices/references/performance.md new file mode 100644 index 0000000..631f2d0 --- /dev/null +++ b/.agents/skills/svelte5-best-practices/references/performance.md @@ -0,0 +1,443 @@ +# Svelte 5 Performance Reference + +## Table of Contents +- [Universal Reactivity](#universal-reactivity) +- [Avoiding Over-Reactivity](#avoiding-over-reactivity) +- [Preventing Load Waterfalls](#preventing-load-waterfalls) +- [Streaming Non-Critical Data](#streaming-non-critical-data) +- [Component Testing](#component-testing) + +--- + +## Universal Reactivity + +Svelte 5 allows runes in `.svelte.js` or `.svelte.ts` files for shared state outside components. + +### Shared Counter State + +```ts +// counter.svelte.ts +export const counter = $state({ count: 0 }); + +export function increment() { counter.count++; } +export function decrement() { counter.count--; } +export function reset() { counter.count = 0; } +``` + +```svelte + + +

Count: {counter.count}

+ +``` + +### Object State with Getters + +```ts +// user.svelte.ts +const state = $state({ firstName: '', lastName: '', email: '' }); + +export const user = { + get firstName() { return state.firstName; }, + set firstName(v: string) { state.firstName = v; }, + get fullName() { return `${state.firstName} ${state.lastName}`; }, + get isValid() { return state.firstName && state.email.includes('@'); } +}; +``` + +### Reactive Class Pattern + +```ts +// todo.svelte.ts +class TodoStore { + items = $state([]); + filter = $state<'all' | 'active' | 'completed'>('all'); + + get filtered() { + switch (this.filter) { + case 'active': return this.items.filter(t => !t.done); + case 'completed': return this.items.filter(t => t.done); + default: return this.items; + } + } + + add(text: string) { + this.items.push({ id: Date.now(), text, done: false }); + } +} + +export const todos = new TodoStore(); +``` + +### Important Notes + +1. **File extension matters**: Must use `.svelte.js` or `.svelte.ts` +2. **No $derived in module scope**: Use getters for derived values +3. **SSR caution**: Avoid initializing browser-only state at module level + +--- + +## Avoiding Over-Reactivity + +Common mistakes with runes can cause unnecessary re-renders, infinite loops, or degraded performance. + +### Anti-Pattern 1: Using $effect to Set Derived Values + +**WRONG:** +```svelte + +``` + +**CORRECT:** +```svelte + +``` + +### Anti-Pattern 2: Circular Dependencies + +**WRONG:** +```svelte + +``` + +**CORRECT: Use separate effects or event handlers** + +### Anti-Pattern 3: Not Using untrack + +**WRONG:** +```svelte + +``` + +**CORRECT:** +```svelte + +``` + +### Anti-Pattern 4: Heavy Computations in $derived + +**WRONG:** +```svelte + +``` + +**CORRECT: Debounce expensive computations** +```svelte + +``` + +### Anti-Pattern 5: Effect for DOM Manipulation + +**WRONG:** +```svelte + +``` + +**CORRECT:** +```svelte +{#if visible}
Content
{/if} + +
Content
+``` + +### Performance Tips + +1. Use `$derived` over `$effect` for computed values +2. Debounce expensive reactive computations +3. Use `untrack` to prevent unnecessary dependencies +4. Group related state into objects +5. Let Svelte handle DOM updates + +--- + +## Preventing Load Waterfalls + +Sequential API calls multiply latency. Use parallel requests and streaming. + +### Anti-Pattern: Waterfall + +**WRONG (3 seconds total):** +```ts +export const load = async ({ fetch }) => { + const user = await fetch('/api/user').then(r => r.json()); // 1s + const posts = await fetch(`/api/users/${user.id}/posts`).then(r => r.json()); // 1s + const comments = await fetch('/api/comments').then(r => r.json()); // 1s + return { user, posts, comments }; +}; +``` + +### Pattern 1: Parallel with Promise.all + +**CORRECT (1 second total):** +```ts +export const load = async ({ fetch }) => { + const [user, posts, comments] = await Promise.all([ + fetch('/api/user').then(r => r.json()), + fetch('/api/posts').then(r => r.json()), + fetch('/api/comments').then(r => r.json()) + ]); + return { user, posts, comments }; +}; +``` + +### Pattern 2: Partial Dependencies + +```ts +export const load = async ({ fetch }) => { + const user = await fetch('/api/user').then(r => r.json()); + + const [posts, followers] = await Promise.all([ + fetch(`/api/users/${user.id}/posts`).then(r => r.json()), + fetch(`/api/users/${user.id}/followers`).then(r => r.json()) + ]); + + return { user, posts, followers }; +}; +``` + +### Pattern 3: Streaming Non-Critical Data + +```ts +export const load = async ({ fetch }) => { + const user = await fetch('/api/user').then(r => r.json()); + + return { + user, + recommendations: fetch('/api/recommendations').then(r => r.json()), + analytics: fetch('/api/analytics').then(r => r.json()) + }; +}; +``` + +### Performance Comparison + +| Pattern | 3 APIs x 1s each | +|---------|------------------| +| Sequential | 3 seconds | +| Parallel (Promise.all) | 1 second | +| Streaming | 0s initial, streams rest | + +--- + +## Streaming Non-Critical Data + +Return promises from load functions to stream non-essential data after initial page render. + +### Basic Streaming + +**WRONG (blocks on slow data):** +```ts +export const load = async ({ fetch }) => { + const user = await fetch('/api/user').then(r => r.json()); // 100ms + const analytics = await fetch('/api/analytics').then(r => r.json()); // 2000ms + return { user, analytics }; // Page blocked for 2.1 seconds +}; +``` + +**CORRECT (stream slow data):** +```ts +export const load = async ({ fetch }) => { + const user = await fetch('/api/user').then(r => r.json()); + const analytics = fetch('/api/analytics').then(r => r.json()); // Don't await + + return { + user, // Available in 100ms + analytics // Streams when ready + }; +}; +``` + +### Component Handling + +```svelte + + +

Welcome, {data.user.name}

+ + +``` + +### When to Stream + +| Data Type | Stream? | Reason | +|-----------|---------|--------| +| User info | No | Critical for layout | +| Main content | No | Users came for this | +| Analytics | Yes | Not user-facing | +| Recommendations | Yes | Supplementary | +| Comments | Maybe | Important but can load later | + +--- + +## Component Testing + +Svelte 5 components with runes require proper Vitest configuration. + +### Option 1: Vitest Browser Mode (Recommended) + +```ts +// vitest.config.ts +import { defineConfig } from 'vitest/config'; +import { svelte } from '@sveltejs/vite-plugin-svelte'; + +export default defineConfig({ + plugins: [svelte()], + test: { + browser: { + enabled: true, + provider: 'playwright', + name: 'chromium' + } + } +}); +``` + +### Option 2: JSDOM with @testing-library/svelte + +```ts +// vitest.config.ts +import { defineConfig } from 'vitest/config'; +import { svelte } from '@sveltejs/vite-plugin-svelte'; + +export default defineConfig({ + plugins: [svelte({ hot: !process.env.VITEST })], + test: { + environment: 'jsdom', + include: ['src/**/*.{test,spec}.{js,ts}'], + globals: true + } +}); +``` + +### Basic Component Test + +```ts +import { render, screen, fireEvent } from '@testing-library/svelte'; +import { expect, test } from 'vitest'; +import Counter from './Counter.svelte'; + +test('increments count when clicked', async () => { + render(Counter); + + const button = screen.getByRole('button'); + expect(button).toHaveTextContent('Count: 0'); + + await fireEvent.click(button); + expect(button).toHaveTextContent('Count: 1'); +}); +``` + +### Testing Props + +```ts +test('renders with custom name', () => { + render(Greeting, { props: { name: 'Alice' } }); + expect(screen.getByText('Hello, Alice!')).toBeInTheDocument(); +}); +``` + +### Testing Callbacks + +```ts +import { vi } from 'vitest'; + +test('calls onclick callback when clicked', async () => { + const handleClick = vi.fn(); + render(Button, { props: { onclick: handleClick } }); + + await fireEvent.click(screen.getByRole('button')); + expect(handleClick).toHaveBeenCalledTimes(1); +}); +``` + +### Testing Async Components + +```ts +import { waitFor } from '@testing-library/svelte'; + +test('loads and displays user data', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ name: 'Alice' }) + }); + + render(UserProfile, { props: { userId: '123' } }); + expect(screen.getByText('Loading...')).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.getByText('Alice')).toBeInTheDocument(); + }); +}); +``` diff --git a/.agents/skills/svelte5-best-practices/references/runes.md b/.agents/skills/svelte5-best-practices/references/runes.md new file mode 100644 index 0000000..2d773f2 --- /dev/null +++ b/.agents/skills/svelte5-best-practices/references/runes.md @@ -0,0 +1,394 @@ +# Svelte 5 Runes Reference + +## Table of Contents +- [$state - Reactive State](#state---reactive-state) +- [$derived - Computed Values](#derived---computed-values) +- [$effect - Side Effects](#effect---side-effects) +- [$props - Component Props](#props---component-props) +- [$bindable - Two-Way Binding](#bindable---two-way-binding) +- [$inspect - Debugging](#inspect---debugging) + +--- + +## $state - Reactive State + +In Svelte 5, plain `let` declarations are no longer automatically reactive. Use `$state()` for reactive state. + +### Basic Usage + +```svelte + + + +``` + +### Object and Array State + +Objects and arrays are deeply reactive by default: + +```svelte + + + + +``` + +### Class State + +```svelte + + + +``` + +### Raw State (Opt-out of Deep Reactivity) + +```svelte + +``` + +--- + +## $derived - Computed Values + +Replaces `$:` reactive statements for computed values. + +### Basic Usage + +```svelte + +``` + +### Complex Derivations with $derived.by + +```svelte + +``` + +### Deriving from Props + +```svelte + +``` + +### $derived vs $effect + +Use `$derived` for computing values, `$effect` for side effects: + +```svelte + +``` + +--- + +## $effect - Side Effects + +Runs code when component mounts and when dependencies change. Requires cleanup for subscriptions. + +### Basic Effect with Cleanup + +```svelte + +``` + +### DOM Event Listeners + +```svelte + +``` + +### External Subscriptions + +```svelte + +``` + +### $effect.pre for Pre-DOM Updates + +```svelte + +``` + +### Untracked Dependencies + +```svelte + +``` + +--- + +## $props - Component Props + +Replaces `export let` for declaring component props. + +### Basic Usage + +```svelte + +``` + +### Rest Props Pattern + +```svelte + + +
+ {@render children?.()} +
+``` + +### Renaming Reserved Words + +```svelte + +``` + +### TypeScript Props + +```svelte + +``` + +### Props with Children + +```svelte + + +
+

{title}

+
{@render children()}
+ {#if footer}
{@render footer()}
{/if} +
+``` + +--- + +## $bindable - Two-Way Binding + +Props must be explicitly marked with `$bindable()` to support `bind:`. + +### Basic Usage + +```svelte + + + + + + + + + +``` + +### Bindable with Default Value + +```svelte + +``` + +### Multiple Bindable Props + +```svelte + +``` + +### TypeScript with Bindable + +```svelte + +``` + +--- + +## $inspect - Debugging + +Development-only debugging that logs values when they change. Stripped in production. + +### Basic Usage + +```svelte + +``` + +### Multiple Values + +```svelte + +``` + +### Custom Logging with $inspect.with + +```svelte + +``` + +### Debugging with Breakpoints + +```svelte + +``` diff --git a/.agents/skills/svelte5-best-practices/references/snippets.md b/.agents/skills/svelte5-best-practices/references/snippets.md new file mode 100644 index 0000000..f5ad7cf --- /dev/null +++ b/.agents/skills/svelte5-best-practices/references/snippets.md @@ -0,0 +1,291 @@ +# Svelte 5 Snippets Reference + +## Table of Contents +- [Replacing Slots with Snippets](#replacing-slots-with-snippets) +- [Rendering with @render](#rendering-with-render) + +--- + +## Replacing Slots with Snippets + +Svelte 5 replaces `` with snippets - a more powerful and type-safe composition primitive. + +### Default Content (children) + +**Svelte 4:** +```svelte +
+``` + +**Svelte 5:** +```svelte + + +
{@render children?.()}
+``` + +### Named Slots to Named Snippets + +**Svelte 4:** +```svelte +
+
+
+ + + +

Title

+

Content

+ Footer +
+``` + +**Svelte 5:** +```svelte + + +
{@render header?.()}
+
{@render children?.()}
+
{@render footer?.()}
+ + + + {#snippet header()}

Title

{/snippet} +

Content

+ {#snippet footer()}Footer{/snippet} +
+``` + +### Slot Props to Snippet Parameters + +**Svelte 4:** +```svelte +
    + {#each items as item} +
  • + {/each} +
+ + + {item.name} + +``` + +**Svelte 5:** +```svelte + + +
    + {#each items as item} +
  • {@render children?.(item)}
  • + {/each} +
+ + + {#snippet children(item)} + {item.name} + {/snippet} + +``` + +### Slot Fallback to Snippet Fallback + +```svelte + + +{#if children} + {@render children()} +{:else} +

Default content

+{/if} +``` + +### Defining Snippets Within Components + +```svelte + + +{#snippet normalItem(item)} +
  • {item.name}
  • +{/snippet} + +{#snippet featuredItem(item)} + +{/snippet} + +
      + {#each items as item} + {#if item.type === 'featured'} + {@render featuredItem(item)} + {:else} + {@render normalItem(item)} + {/if} + {/each} +
    +``` + +### TypeScript Typing + +```svelte + +``` + +--- + +## Rendering with @render + +Snippets must be rendered using `{@render}`. Optional snippets need null-safe calling. + +### Basic Snippet Rendering + +```svelte + + +
    {@render children()}
    +``` + +### Optional Snippets + +Always use optional chaining for snippets that might not be provided: + +```svelte + + +{@render children?.()} +``` + +### Conditional Rendering + +```svelte + + +{#if header} +
    {@render header()}
    +{/if} + +
    {@render children?.()}
    + +{#if footer} +
    {@render footer()}
    +{/if} +``` + +### Rendering with Arguments + +```svelte + + +
      + {#each items as item, index} +
    • {@render itemTemplate(item, index)}
    • + {/each} +
    + + + + {#snippet itemTemplate(item, index)} + {index + 1}. {item.name} + {/snippet} + +``` + +### Rendering Multiple Times + +Snippets can be rendered multiple times: + +```svelte + + + +``` + +### Rendering Dynamic Snippets + +```svelte + + +{@render currentView?.()} +``` + +### TypeScript with @render + +```svelte + + +{@render header?.()} +{@render children()} +{@render row({ id: 1, name: 'Test' })} +``` + +### Passing Snippets to Child Components + +```svelte + + + + + + + + +{#each items as item} + {@render itemRenderer?.(item)} +{/each} +``` diff --git a/.agents/skills/svelte5-best-practices/references/sveltekit.md b/.agents/skills/svelte5-best-practices/references/sveltekit.md new file mode 100644 index 0000000..816b83b --- /dev/null +++ b/.agents/skills/svelte5-best-practices/references/sveltekit.md @@ -0,0 +1,401 @@ +# SvelteKit Patterns Reference + +## Table of Contents +- [Load Function Types](#load-function-types) +- [Page Props Typing](#page-props-typing) +- [Form Actions Error Handling](#form-actions-error-handling) +- [SSR State Isolation](#ssr-state-isolation) + +--- + +## Load Function Types + +SvelteKit has two load function types: universal (`+page.js`) and server-only (`+page.server.ts`). + +### When to Use Each + +| Use +page.server.ts | Use +page.js | +|---------------------|--------------| +| Accessing secrets/credentials | Public APIs only | +| Database connections | Non-serializable data (functions, classes) | +| Server-only APIs | Client-side caching benefits | +| Sensitive business logic | | + +### Security: Secrets in Server Load + +**WRONG (secrets exposed):** +```ts +// +page.js - DANGEROUS: runs in browser! +export const load = async ({ fetch }) => { + const response = await fetch('https://api.stripe.com/charges', { + headers: { 'Authorization': `Bearer ${STRIPE_SECRET_KEY}` } // EXPOSED! + }); + return { charges: await response.json() }; +}; +``` + +**CORRECT:** +```ts +// +page.server.ts - only runs on server +import { STRIPE_SECRET_KEY } from '$env/static/private'; + +export const load = async ({ fetch }) => { + const response = await fetch('https://api.stripe.com/charges', { + headers: { 'Authorization': `Bearer ${STRIPE_SECRET_KEY}` } + }); + return { charges: await response.json() }; +}; +``` + +### Serialization: Non-serializable in Universal Load + +**WRONG (server load can't serialize functions):** +```ts +// +page.server.ts +export const load = async () => { + return { + formatDate: (date: Date) => date.toLocaleDateString(), // ERROR + parser: new DOMParser() // ERROR + }; +}; +``` + +**CORRECT:** +```ts +// +page.js +export const load = async () => { + return { + formatDate: (date: Date) => date.toLocaleDateString(), // OK + parser: typeof window !== 'undefined' ? new DOMParser() : null + }; +}; +``` + +### Database Access + +```ts +// +page.server.ts - CORRECT +import { db } from '$lib/server/database'; + +export const load = async () => { + const users = await db.query('SELECT * FROM users'); + return { users }; +}; +``` + +### Environment Variables + +```ts +// +page.server.ts - Private env vars +import { DATABASE_URL, API_SECRET } from '$env/static/private'; + +// +page.js - Only public env vars +import { PUBLIC_API_URL } from '$env/static/public'; +``` + +--- + +## Page Props Typing + +SvelteKit generates types in `./$types` for full type safety with `$props()`. + +### Basic Page Data Typing + +```svelte + + + +

    {data.title}

    +``` + +### Page Data with Form Actions + +```svelte + + +{#if form?.success} +

    {form.message}

    +{/if} + +{#if form?.error} +

    {form.error}

    +{/if} +``` + +### Layout Data Typing + +```svelte + + + + + +{@render children()} +``` + +### Corresponding Load Function + +```ts +// +page.server.ts +import type { PageServerLoad, Actions } from './$types'; + +export const load: PageServerLoad = async () => { + return { + title: 'My Page', + items: await fetchItems() + }; +}; + +export const actions: Actions = { + default: async ({ request }) => { + return { success: true, message: 'Saved successfully' }; + } +}; +``` + +### Error Page + +```svelte + + + +

    {page.status}: {page.error?.message}

    +``` + +--- + +## Form Actions Error Handling + +SvelteKit distinguishes between validation errors (`fail()`) and unexpected errors (`throw error()`). + +### Use fail() for Validation Errors + +**WRONG:** +```ts +import { error } from '@sveltejs/kit'; + +export const actions = { + default: async ({ request }) => { + if (!email?.toString().includes('@')) { + throw error(400, 'Invalid email'); // Shows error page! + } + } +}; +``` + +**CORRECT:** +```ts +import { fail } from '@sveltejs/kit'; + +export const actions = { + default: async ({ request }) => { + const data = await request.formData(); + const email = data.get('email')?.toString() ?? ''; + + if (!email.includes('@')) { + return fail(400, { + error: 'Invalid email address', + email // Return for repopulation + }); + } + + return { success: true }; + } +}; +``` + +### Use throw error() for Unexpected Errors + +```ts +import { fail, error } from '@sveltejs/kit'; + +export const actions = { + default: async ({ request }) => { + const data = await request.formData(); + + // Validation - use fail() + if (!data.get('title')) { + return fail(400, { error: 'Title is required' }); + } + + try { + await database.save(data); + return { success: true }; + } catch (e) { + // Unexpected error - use throw + throw error(500, 'Unable to save. Please try again later.'); + } + } +}; +``` + +### Structured Validation Responses + +```ts +interface FormErrors { + email?: string; + password?: string; +} + +export const actions = { + register: async ({ request }) => { + const data = await request.formData(); + const errors: FormErrors = {}; + + if (!email) errors.email = 'Email is required'; + if (password.length < 8) errors.password = 'Password must be at least 8 characters'; + + if (Object.keys(errors).length > 0) { + return fail(400, { errors, values: { email } }); + } + + return { success: true }; + } +}; +``` + +### Handle in Component + +```svelte + + +
    + + +
    +``` + +### Error Response Summary + +| Situation | Function | Result | +|-----------|----------|--------| +| Missing field | `fail(400, {...})` | Form state preserved | +| Invalid format | `fail(400, {...})` | Form state preserved | +| Not found | `throw error(404, ...)` | Error page | +| Server crash | `throw error(500, ...)` | Error page | +| Auth required | `throw redirect(303, ...)` | Redirect | + +--- + +## SSR State Isolation + +Shared server state persists across requests, potentially leaking data between users. + +### Dangerous: Module-Level State + +**WRONG:** +```ts +// +page.server.ts +let currentUser = null; // SHARED ACROSS ALL REQUESTS! + +export const load = async ({ locals }) => { + currentUser = locals.user; // User B overwrites User A + return { user: currentUser }; +}; +``` + +**CORRECT:** +```ts +export const load = async ({ locals }) => { + return { user: locals.user }; // Each request gets its own locals +}; +``` + +### Dangerous: Global Stores + +**WRONG:** +```ts +// stores.svelte.ts +export const user = $state(null); // Server-side singleton! +``` + +**CORRECT: Use locals and return data from load** +```ts +// +layout.server.ts +export const load = async ({ locals }) => { + return { user: locals.user }; +}; +``` + +### Safe Patterns + +**Pattern 1: Use locals** +```ts +// hooks.server.ts +export const handle = async ({ event, resolve }) => { + event.locals.user = await authenticate(event); + event.locals.requestId = crypto.randomUUID(); + return resolve(event); +}; + +// +page.server.ts +export const load = async ({ locals }) => { + return { user: locals.user }; +}; +``` + +**Pattern 2: Context for Component Trees** +```svelte + + + +{@render children()} +``` + +**Pattern 3: Client-Only State** +```ts +// stores.svelte.ts +import { browser } from '$app/environment'; + +function createClientStore() { + if (!browser) return { value: null }; + const state = $state({ value: null }); + return state; +} + +export const clientState = createClientStore(); +``` + +### SSR Safety Checklist + +- [ ] No module-level `let` variables that store user data +- [ ] No global `$state` that gets set during SSR +- [ ] No singleton service classes with mutable state +- [ ] All user-specific data flows through `locals` +- [ ] All page data comes from load function returns diff --git a/.agents/skills/svelte5-best-practices/references/typescript.md b/.agents/skills/svelte5-best-practices/references/typescript.md new file mode 100644 index 0000000..d736bb6 --- /dev/null +++ b/.agents/skills/svelte5-best-practices/references/typescript.md @@ -0,0 +1,305 @@ +# Svelte 5 TypeScript Reference + +## Table of Contents +- [Props Typing](#props-typing) +- [Generic Components](#generic-components) + +--- + +## Props Typing + +`$props()` requires specific patterns for TypeScript typing. + +### Basic Props Typing + +**Inline typing:** +```svelte + +``` + +**Interface typing (recommended):** +```svelte + +``` + +### Children and Snippets Typing + +```svelte + +``` + +### Callback Props Typing + +```svelte + +``` + +### Rest Props with HTML Attributes + +```svelte + + + +``` + +### Input Element Props + +```svelte + +``` + +### Union Type Props + +```svelte + + + +``` + +### Discriminated Union Props + +```svelte + + +{#if props.type === 'link'} + {@render props.children()} +{:else} + +{/if} +``` + +--- + +## Generic Components + +Svelte 5 uses the `generics` attribute for type-safe reusable components. + +### Basic Generic Component + +```svelte + + + +
      + {#each items as item, index} +
    • {@render children(item, index)}
    • + {/each} +
    + + + + + + {#snippet children(user, index)} + {index + 1}. {user.name} + {/snippet} + +``` + +### Multiple Type Parameters + +```svelte + + +
    + {#each entries as [key, value]} +
    {@render renderKey(key)}
    +
    {@render renderValue(value)}
    + {/each} +
    +``` + +### Constrained Generics + +```svelte + + +{#each items as item} +
    onselect?.(item)} + > + {@render children(item, selected?.id === item.id)} +
    +{/each} +``` + +### Generic with Default Type + +```svelte + +``` + +### Generic Select Component + +```svelte + + + +``` + +### Generic Async Component + +```svelte + + +{#await promise} + {#if loading}{@render loading()}{:else}

    Loading...

    {/if} +{:then data} + {@render children(data)} +{:catch err} + {#if error}{@render error(err)}{:else}

    Error: {err.message}

    {/if} +{/await} +``` diff --git a/.agents/skills/sveltekit-data-flow/README.md b/.agents/skills/sveltekit-data-flow/README.md new file mode 100644 index 0000000..06512b2 --- /dev/null +++ b/.agents/skills/sveltekit-data-flow/README.md @@ -0,0 +1,17 @@ +# Sveltekit Data Flow + +SvelteKit data flow guidance. Use for load functions, form actions, +and server/client data. Covers +page.server.ts vs +page.ts, +serialization, fail(), redirect(), error(). + +## Structure + +- `SKILL.md` - Main skill instructions +- `references/` - Detailed documentation loaded as needed +- `scripts/` - Executable code for deterministic operations +- `assets/` - Templates, images, or other resources + +## Usage + +This skill is automatically discovered by Claude when relevant to the +task. diff --git a/.agents/skills/sveltekit-data-flow/SKILL.md b/.agents/skills/sveltekit-data-flow/SKILL.md new file mode 100644 index 0000000..b80ace6 --- /dev/null +++ b/.agents/skills/sveltekit-data-flow/SKILL.md @@ -0,0 +1,79 @@ +--- +name: sveltekit-data-flow +# IMPORTANT: Keep description on ONE line for Claude Code compatibility +# prettier-ignore +description: SvelteKit data flow guidance. Use for load functions, form actions, server/client data, and invalidation. Covers +page.server.ts vs +page.ts, serialization, fail(), redirect(), error(), invalidateAll(). +--- + +# SvelteKit Data Flow + +## Quick Start + +**Which file?** Server-only (DB/secrets): `+page.server.ts` | +Universal (runs both): `+page.ts` | API: `+server.ts` + +**Load decision:** Need server resources? → server load | Need client +APIs? → universal load + +**Form actions:** Always `+page.server.ts`. Return `fail()` for +errors, throw `redirect()` to navigate, throw `error()` for failures. + +## Example + +```typescript +// +page.server.ts +import { fail, redirect } from '@sveltejs/kit'; + +export const load = async ({ locals }) => { + const user = await db.users.get(locals.userId); + return { user }; // Must be JSON-serializable +}; + +export const actions = { + default: async ({ request }) => { + const data = await request.formData(); + const email = data.get('email'); + + if (!email) return fail(400, { email, missing: true }); + + await updateEmail(email); + throw redirect(303, '/success'); + }, +}; +``` + +## Reference Files + +- [load-functions.md](references/load-functions.md) - Server vs + universal +- [form-actions.md](references/form-actions.md) - Form handling + patterns +- [serialization.md](references/serialization.md) - What can/can't + serialize +- [error-redirect-handling.md](references/error-redirect-handling.md) - + fail/redirect/error +- [client-auth-invalidation.md](references/client-auth-invalidation.md) - + invalidateAll() after client-side auth + +## Notes + +- Server load → universal load via `data` param | ALWAYS + `throw redirect()/error()` +- No class instances/functions from server load (not serializable) +- **Last verified:** 2025-01-11 + + diff --git a/.agents/skills/sveltekit-data-flow/references/client-auth-invalidation.md b/.agents/skills/sveltekit-data-flow/references/client-auth-invalidation.md new file mode 100644 index 0000000..d64c884 --- /dev/null +++ b/.agents/skills/sveltekit-data-flow/references/client-auth-invalidation.md @@ -0,0 +1,125 @@ +# Client-Side Auth Invalidation + +When using client-side auth libraries (Better Auth, Firebase, Supabase client), +layout server data doesn't auto-refresh after login/logout. + +## The Problem + +```typescript +// signin/+page.svelte - BROKEN +async function handle_signin() { + await auth_client.signIn.email({ email, password }); + goto('/'); // Layout still shows "logged out" +} +``` + +**Why?** Client-side navigation (`goto()`) doesn't re-run server load functions. +The session cookie is set, but `+layout.server.ts` data is stale. + +## Solution A: Inline Invalidation (Simple) + +Call `invalidateAll()` after auth state change: + +```typescript +// signin/+page.svelte +import { goto, invalidateAll } from '$app/navigation'; + +async function handle_signin() { + const result = await auth_client.signIn.email({ email, password }); + if (result.error) return; + + await invalidateAll(); // Re-runs ALL load functions + goto('/'); +} + +// signout handler +async function handle_signout() { + await auth_client.signOut(); + await invalidateAll(); +} +``` + +**Use when:** Single auth entry point, simple apps. + +## Solution B: Auth State Listener (Robust) + +Set up a listener in root layout that auto-invalidates on auth changes: + +```svelte + + +``` + +**Use when:** Multiple auth flows (OAuth, magic links, etc.), complex apps. + +## Common Mistakes + +### Not awaiting invalidateAll() + +```typescript +// WRONG - race condition +invalidateAll(); +goto('/'); + +// RIGHT - wait for data refresh +await invalidateAll(); +goto('/'); +``` + +### Destructuring layout data + +```svelte + + + + + + +{data.user?.email} +``` + +## When invalidateAll() Runs + +Per [Svelte docs](https://svelte.dev/tutorial/kit/invalidate-all): + +> `invalidateAll()` is the nuclear option - it re-runs ALL load functions +> for the current page, regardless of dependencies. + +This includes: +- `+layout.server.ts` (all levels) +- `+layout.ts` (all levels) +- `+page.server.ts` +- `+page.ts` + +## Comparison with Server-Side Auth + +| Approach | Auth Location | Invalidation | +|----------|--------------|--------------| +| Form actions | Server (`+page.server.ts`) | Automatic (page reload) | +| Client auth | Browser (auth_client) | Manual (`invalidateAll()`) | + +Form actions with `throw redirect()` cause a full navigation, which +naturally re-runs load functions. Client-side auth with `goto()` does not. + +## See Also + +- [error-redirect-handling.md](error-redirect-handling.md) - Server-side redirects +- [load-functions.md](load-functions.md) - Server vs universal loads diff --git a/.agents/skills/sveltekit-data-flow/references/error-redirect-handling.md b/.agents/skills/sveltekit-data-flow/references/error-redirect-handling.md new file mode 100644 index 0000000..590d13e --- /dev/null +++ b/.agents/skills/sveltekit-data-flow/references/error-redirect-handling.md @@ -0,0 +1,407 @@ +# Error & Redirect Handling: fail(), redirect(), error() + +## The Three Ways to Handle Failures + +| Function | When | Must Throw? | Use Case | +| ------------ | ---------------- | ----------- | ------------------------------------- | +| `fail()` | Validation error | No (return) | Form validation errors | +| `redirect()` | Navigate user | **YES** | After successful action | +| `error()` | Fatal error | **YES** | Unauthorized, not found, server error | + +## fail() - Validation Errors + +**Return** (don't throw) from form actions to show validation errors: + +```typescript +import { fail } from '@sveltejs/kit'; + +export const actions = { + default: async ({ request }) => { + const data = await request.formData(); + const email = data.get('email'); + + // Validation failed - return fail() + if (!email || !email.includes('@')) { + return fail(400, { + email, + error: 'Invalid email', + missing: !email, + }); + } + + // Success - process and maybe redirect + await processEmail(email); + throw redirect(303, '/success'); + }, +}; +``` + +```svelte + + + +{#if form?.error} +

    {form.error}

    +{/if} + +
    + + + {#if form?.missing} + Email is required + {/if} + + +
    +``` + +**Key points:** + +- **Return** fail() (don't throw) +- Status code (400 = bad request) +- Return validation errors + form data to repopulate fields +- Accessible via `form` prop in page component +- Page stays on same URL + +## redirect() - Navigation + +**Throw** redirect() to navigate user to another page: + +```typescript +import { redirect } from '@sveltejs/kit'; + +export const actions = { + login: async ({ request, cookies }) => { + const data = await request.formData(); + + const user = await authenticate(data); + + if (!user) { + return fail(401, { error: 'Invalid credentials' }); + } + + cookies.set('session', user.sessionToken, { path: '/' }); + + // Success - redirect to dashboard + throw redirect(303, '/dashboard'); // MUST throw + }, +}; +``` + +**Status codes:** + +- `303` - See Other (recommended for POST → GET redirect) +- `301` - Moved Permanently +- `302` - Found (temporary redirect) +- `307` - Temporary Redirect (preserves method) +- `308` - Permanent Redirect (preserves method) + +**Use 303** for most cases (especially after form submission). + +**Key points:** + +- **MUST throw** (not return) +- Use status 303 for form actions +- Can redirect to external URLs +- Can use relative paths: `throw redirect(303, '..')` + +## error() - Fatal Errors + +**Throw** error() for unrecoverable errors (auth, not found, server +error): + +```typescript +import { error } from '@sveltejs/kit'; + +export const load = async ({ params, locals }) => { + const post = await db.query.posts.findFirst({ + where: eq(posts.id, params.id), + }); + + if (!post) { + throw error(404, 'Post not found'); // MUST throw + } + + if (post.authorId !== locals.userId) { + throw error(403, 'Forbidden'); // MUST throw + } + + return { post }; +}; +``` + +**Common status codes:** + +- `400` - Bad Request +- `401` - Unauthorized (not logged in) +- `403` - Forbidden (logged in but no permission) +- `404` - Not Found +- `500` - Internal Server Error + +**With custom error page:** + +```typescript +// src/routes/posts/[id]/+page.server.ts +import { error } from '@sveltejs/kit'; + +export const load = async ({ params }) => { + const post = await getPost(params.id); + + if (!post) { + throw error(404, { + message: 'Post not found', + postId: params.id, + }); + } + + return { post }; +}; +``` + +```svelte + + + +

    {$page.status}: {$page.error.message}

    + +{#if $page.error.postId} +

    Could not find post with ID: {$page.error.postId}

    +{/if} +``` + +**Key points:** + +- **MUST throw** (not return) +- Renders closest `+error.svelte` file +- Accessible via `$page.status` and `$page.error` +- Stops load function execution +- Use for authorization, not found, server errors + +## Common Mistakes + +### ❌ Not Throwing redirect() + +```typescript +// WRONG +export const actions = { + default: async () => { + redirect(303, '/home'); // DOESN'T WORK + }, +}; + +// RIGHT +export const actions = { + default: async () => { + throw redirect(303, '/home'); // MUST throw + }, +}; +``` + +### ❌ Not Throwing error() + +```typescript +// WRONG +export const load = async () => { + error(404, 'Not found'); // DOESN'T WORK +}; + +// RIGHT +export const load = async () => { + throw error(404, 'Not found'); // MUST throw +}; +``` + +### ❌ Throwing fail() + +```typescript +// WRONG +export const actions = { + default: async () => { + throw fail(400, { error: 'Bad' }); // Don't throw + }, +}; + +// RIGHT +export const actions = { + default: async () => { + return fail(400, { error: 'Bad' }); // Return + }, +}; +``` + +### ❌ Catching redirect Without Rethrowing + +```typescript +// WRONG +export const actions = { + default: async () => { + try { + await doSomething(); + throw redirect(303, '/success'); + } catch (e) { + console.error(e); // Catches redirect - it won't work! + return fail(500, { error: 'Failed' }); + } + }, +}; + +// RIGHT +import { isRedirect } from '@sveltejs/kit'; + +export const actions = { + default: async () => { + try { + await doSomething(); + throw redirect(303, '/success'); + } catch (e) { + if (isRedirect(e)) throw e; // Rethrow redirect + console.error(e); + return fail(500, { error: 'Failed' }); + } + }, +}; +``` + +## Decision Tree + +``` +Problem in form action? +├─ Validation error (show to user) → return fail(400, { errors }) +├─ Success (navigate) → throw redirect(303, '/success') +└─ Fatal error (auth, not found) → throw error(403, 'Forbidden') + +Problem in load function? +├─ Data not found → throw error(404, 'Not found') +├─ Unauthorized → throw error(401, 'Unauthorized') +├─ Forbidden → throw error(403, 'Forbidden') +└─ Server error → throw error(500, 'Server error') +``` + +## Patterns + +### Pattern 1: Validate, Process, Redirect + +```typescript +export const actions = { + create: async ({ request }) => { + const data = await request.formData(); + + // 1. Validate + if (!isValid(data)) { + return fail(400, { errors: getErrors(data) }); + } + + // 2. Process + const post = await createPost(data); + + // 3. Redirect + throw redirect(303, `/posts/${post.id}`); + }, +}; +``` + +### Pattern 2: Check Auth, Load Data + +```typescript +export const load = async ({ locals, params }) => { + // 1. Check auth + if (!locals.user) { + throw error(401, 'Please log in'); + } + + // 2. Load data + const post = await getPost(params.id); + + if (!post) { + throw error(404, 'Post not found'); + } + + // 3. Check permission + if (post.authorId !== locals.user.id) { + throw error(403, 'Not your post'); + } + + return { post }; +}; +``` + +### Pattern 3: Conditional Redirect + +```typescript +export const load = async ({ locals }) => { + // Redirect if already logged in + if (locals.user) { + throw redirect(303, '/dashboard'); + } + + return {}; // Show login page +}; +``` + +## Summary Table + +| | fail() | redirect() | error() | +| -------------------- | ----------------- | --------------- | --------------- | +| **Throw or return?** | Return | **Throw** | **Throw** | +| **Use in** | Form actions | Actions & load | Actions & load | +| **Purpose** | Validation errors | Navigate | Fatal errors | +| **Status codes** | 400-499 | 301-308 | 400-599 | +| **Accessible via** | `form` prop | N/A (navigates) | `+error.svelte` | +| **Stays on page?** | Yes | No (navigates) | No (error page) | +| **Example** | Invalid email | After save | Not found | + +## Client-Side Navigation After Auth + +When using client-side auth (e.g., Better Auth client), **always use `goto()` with `invalidateAll: true`** to ensure layout data refreshes: + +```svelte + +``` + +### ❌ Common Mistake: Separate invalidateAll + goto + +```typescript +// WRONG: Data might not refresh before navigation +await invalidateAll(); +goto('/dashboard'); + +// ALSO WRONG: goto doesn't wait for invalidation +await invalidateAll(); +await goto('/dashboard'); +``` + +### ✅ Correct Pattern + +```typescript +// RIGHT: Single call with invalidateAll option +await goto('/dashboard', { invalidateAll: true }); +``` + +**Why this matters:** After client-side auth, cookies are set but the root layout's `load` function (which typically checks `auth.api.getSession()`) has cached data. Using `invalidateAll: true` forces all load functions to re-run with the new session cookie. + +**Note:** Server-side redirects with `throw redirect()` don't have this problem because they trigger a full page load. + +## Quick Checklist + +- ✅ Using `return fail()` for validation errors? +- ✅ Using `throw redirect()` (not return) after success? +- ✅ Using `throw error()` (not return) for fatal errors? +- ✅ Not catching redirects/errors without rethrowing? +- ✅ Using status code 303 for POST redirects? +- ✅ Using `goto(url, { invalidateAll: true })` after client-side auth? diff --git a/.agents/skills/sveltekit-data-flow/references/form-actions.md b/.agents/skills/sveltekit-data-flow/references/form-actions.md new file mode 100644 index 0000000..62fa0fc --- /dev/null +++ b/.agents/skills/sveltekit-data-flow/references/form-actions.md @@ -0,0 +1,316 @@ +# Form Actions + +## Basic Pattern + +Form actions live in `+page.server.ts` and handle form submissions: + +```typescript +// +page.server.ts +import { fail, redirect } from '@sveltejs/kit'; +import type { Actions } from './$types'; + +export const actions: Actions = { + default: async ({ request }) => { + const data = await request.formData(); + const email = data.get('email'); + const password = data.get('password'); + + if (!email) { + return fail(400, { email, missing: true }); + } + + await login(email, password); + throw redirect(303, '/dashboard'); + }, +}; +``` + +```svelte + + + +
    + + {#if form?.missing} +

    Email is required

    + {/if} + + +
    +``` + +## Named Actions + +```typescript +export const actions: Actions = { + login: async ({ request }) => { + // Handle login + }, + + register: async ({ request }) => { + // Handle registration + }, +}; +``` + +```svelte +
    ...
    +
    ...
    +``` + +## Return Values + +### Option 1: fail() - Show Error to User + +```typescript +import { fail } from '@sveltejs/kit'; + +export const actions = { + default: async ({ request }) => { + const data = await request.formData(); + + if (!isValid(data)) { + return fail(400, { + error: 'Invalid data', + fields: Object.fromEntries(data), // Preserve input + }); + } + + // Success - no return needed + }, +}; +``` + +### Option 2: redirect() - Navigate After Success + +```typescript +import { redirect } from '@sveltejs/kit'; + +export const actions = { + default: async ({ request }) => { + await processForm(request); + throw redirect(303, '/success'); // MUST throw + }, +}; +``` + +### Option 3: error() - Fatal Error + +```typescript +import { error } from '@sveltejs/kit'; + +export const actions = { + default: async ({ request }) => { + const user = await getCurrentUser(); + + if (!user) { + throw error(401, 'Unauthorized'); // MUST throw + } + + // Process... + }, +}; +``` + +## Progressive Enhancement + +Form works without JavaScript: + +```svelte + + +
    + +
    +``` + +With custom handling: + +```svelte +
    { + // Before submit + formData.append('timestamp', Date.now().toString()); + + return async ({ result, update }) => { + // After response + if (result.type === 'success') { + await update(); // Update form prop + } + }; + }} +> + ... +
    +``` + +## Validation Pattern + +```typescript +import { fail } from '@sveltejs/kit'; +import { z } from 'zod'; + +const schema = z.object({ + email: z.string().email(), + password: z.string().min(8), +}); + +export const actions = { + default: async ({ request }) => { + const data = await request.formData(); + const rawData = { + email: data.get('email'), + password: data.get('password'), + }; + + const result = schema.safeParse(rawData); + + if (!result.success) { + return fail(400, { + errors: result.error.flatten().fieldErrors, + data: rawData, + }); + } + + // result.data is validated + await createUser(result.data); + throw redirect(303, '/welcome'); + }, +}; +``` + +## Common Mistakes + +### ❌ Not Throwing redirect/error + +```typescript +// WRONG +export const actions = { + default: async () => { + redirect(303, '/home'); // DOESN'T WORK - must throw + }, +}; + +// RIGHT +export const actions = { + default: async () => { + throw redirect(303, '/home'); + }, +}; +``` + +### ❌ Catching redirect Without Rethrowing + +```typescript +// WRONG +export const actions = { + default: async () => { + try { + await doSomething(); + throw redirect(303, '/success'); + } catch (e) { + console.error(e); // Catches redirect! + } + }, +}; + +// RIGHT +export const actions = { + default: async () => { + try { + await doSomething(); + throw redirect(303, '/success'); + } catch (e) { + if (e instanceof Redirect) throw e; // Rethrow + console.error(e); + } + }, +}; +``` + +### ❌ Returning non-serializable data + +```typescript +// WRONG +export const actions = { + default: async () => { + return { date: new Date() }; // Date is not serializable + }, +}; + +// RIGHT +export const actions = { + default: async () => { + return { date: new Date().toISOString() }; + }, +}; +``` + +## File Upload + +```typescript +export const actions = { + default: async ({ request }) => { + const data = await request.formData(); + const file = data.get('file') as File; + + if (!file || file.size === 0) { + return fail(400, { error: 'No file uploaded' }); + } + + const bytes = await file.arrayBuffer(); + const buffer = Buffer.from(bytes); + + await saveFile(buffer, file.name); + throw redirect(303, '/uploads'); + }, +}; +``` + +```svelte +
    + + +
    +``` + +## Form Prop in Page + +```svelte + + +{#if form?.error} +
    {form.error}
    +{/if} + +{#if form?.success} +
    Success!
    +{/if} + +
    + + {#if form?.errors?.email} +

    {form.errors.email}

    + {/if} + + +
    +``` + +## Key Rules + +1. ✅ Actions must be in `+page.server.ts` (not `+page.ts`) +2. ✅ ALWAYS throw `redirect()` and `error()` (not return) +3. ✅ Return `fail()` for validation errors +4. ✅ Return only serializable data +5. ✅ Don't catch redirects/errors without rethrowing +6. ✅ Use `enhance` for progressive enhancement +7. ✅ Access FormData with `data.get('fieldName')` diff --git a/.agents/skills/sveltekit-data-flow/references/load-functions.md b/.agents/skills/sveltekit-data-flow/references/load-functions.md new file mode 100644 index 0000000..e051389 --- /dev/null +++ b/.agents/skills/sveltekit-data-flow/references/load-functions.md @@ -0,0 +1,292 @@ +# Load Functions: Server vs Universal + +## Decision Matrix + +| Need | Use | File | +| ----------------------------------- | -------------- | ----------------- | +| Database access | Server load | `+page.server.ts` | +| Secrets/env vars | Server load | `+page.server.ts` | +| Server-only packages | Server load | `+page.server.ts` | +| Browser APIs (window, localStorage) | Universal load | `+page.ts` | +| Client-side fetch | Universal load | `+page.ts` | +| Runs on both | Universal load | `+page.ts` | + +## Server Load (+page.server.ts) + +**When:** Need server-only resources (DB, secrets, server APIs) + +**Runs:** Only on server (never in browser) + +```typescript +// src/routes/profile/+page.server.ts +import type { PageServerLoad } from './$types'; +import { db } from '$lib/server/database'; + +export const load: PageServerLoad = async ({ locals, params }) => { + // Access server-only resources + const user = await db.query.users.findFirst({ + where: eq(users.id, locals.userId), + }); + + const posts = await db.query.posts.findMany({ + where: eq(posts.authorId, user.id), + }); + + // Must return serializable data + return { + user: { + id: user.id, + name: user.name, + email: user.email, + }, + posts, + }; +}; +``` + +**Key points:** + +- Runs only on server +- Can access `$lib/server/*` imports +- Can use secrets from `env` safely +- Return values must be JSON-serializable +- Output is automatically passed to universal load + +## Universal Load (+page.ts) + +**When:** Need to run on both server and client, or need browser APIs + +**Runs:** Server (during SSR) AND client (during navigation) + +```typescript +// src/routes/dashboard/+page.ts +import type { PageLoad } from './$types'; + +export const load: PageLoad = async ({ data, fetch }) => { + // `data` comes from +page.server.ts if it exists + const { user } = data; + + // Fetch additional data (works on both server and client) + const response = await fetch('/api/stats'); + const stats = await response.json(); + + // Can access browser APIs (but check if in browser first) + const theme = + typeof window !== 'undefined' + ? localStorage.getItem('theme') + : null; + + return { + user, + stats, + theme, + }; +}; +``` + +**Key points:** + +- Runs on both server AND client +- Receives server load output as `data` parameter +- Use SvelteKit's `fetch` (automatically handles SSR) +- Check `typeof window !== 'undefined'` for browser APIs +- Cannot import from `$lib/server/*` + +## Data Flow + +``` +Request → Server Load (+page.server.ts) + ↓ (returns { user }) + Universal Load (+page.ts) + ↓ (receives data: { user }, returns { user, stats }) + Page Component (+page.svelte) + ↓ (receives data: { user, stats }) +``` + +**Example:** + +```typescript +// +page.server.ts +export const load = async () => { + return { serverData: 'from server' }; +}; + +// +page.ts +export const load = async ({ data }) => { + console.log(data.serverData); // 'from server' + return { ...data, clientData: 'from universal' }; +}; + +// +page.svelte + +``` + +## Common Patterns + +### Pattern 1: Server + Universal + +```typescript +// +page.server.ts - Fetch sensitive data +export const load = async ({ locals }) => { + const user = await getUser(locals.session); + return { user }; +}; + +// +page.ts - Fetch public data +export const load = async ({ data, fetch }) => { + const publicPosts = await fetch('/api/posts').then((r) => r.json()); + return { ...data, publicPosts }; +}; +``` + +### Pattern 2: Conditional Universal Load + +```typescript +// +page.ts +import { browser } from '$app/environment'; + +export const load = async ({ fetch }) => { + const serverData = await fetch('/api/data').then((r) => r.json()); + + // Only run in browser + let clientOnlyData = null; + if (browser) { + clientOnlyData = localStorage.getItem('cache'); + } + + return { serverData, clientOnlyData }; +}; +``` + +### Pattern 3: Depends for Revalidation + +```typescript +// +page.ts +export const load = async ({ fetch, depends }) => { + depends('app:posts'); // Invalidate with invalidate('app:posts') + + const posts = await fetch('/api/posts').then((r) => r.json()); + return { posts }; +}; + +// Somewhere else: +import { invalidate } from '$app/navigation'; +invalidate('app:posts'); // Re-runs load function +``` + +## Common Mistakes + +### ❌ Importing Server Code in Universal Load + +```typescript +// +page.ts - WRONG +import { db } from '$lib/server/database'; // ERROR - can't import server code + +export const load = async () => { + const users = await db.query.users.findMany(); // Won't work + return { users }; +}; +``` + +**Fix:** Move to `+page.server.ts` + +### ❌ Returning Non-Serializable Data + +```typescript +// +page.server.ts - WRONG +export const load = async () => { + const user = await User.findOne(); // Returns class instance + return { user }; // ERROR - class instances aren't serializable +}; +``` + +**Fix:** Return plain objects + +```typescript +export const load = async () => { + const user = await User.findOne(); + return { + user: { + id: user.id, + name: user.name, + email: user.email, + }, + }; +}; +``` + +### ❌ Using window/localStorage Without Check + +```typescript +// +page.ts - WRONG +export const load = async () => { + const theme = localStorage.getItem('theme'); // ERROR on server + return { theme }; +}; +``` + +**Fix:** Check for browser + +```typescript +import { browser } from '$app/environment'; + +export const load = async () => { + const theme = browser ? localStorage.getItem('theme') : 'light'; + return { theme }; +}; +``` + +## TypeScript + +```typescript +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ + locals, + params, + url, +}) => { + // TypeScript knows return type must be serializable + return { + user: { + id: 1, + name: 'Alice', + }, + }; +}; +``` + +```typescript +import type { PageLoad } from './$types'; + +export const load: PageLoad = async ({ data, fetch, params }) => { + // `data` is typed from +page.server.ts return type + return { + ...data, + extra: 'data', + }; +}; +``` + +## When to Use Which + +**Use Server Load when:** + +- Need database access +- Need secrets/environment variables +- Need server-only npm packages +- Need to hide implementation details + +**Use Universal Load when:** + +- Need browser APIs (localStorage, window) +- Need to fetch public APIs (works on both) +- Want client-side navigation without server round-trip +- Data is public and doesn't need server resources + +**Use Both when:** + +- Server load fetches sensitive data +- Universal load fetches public data or adds client-side enhancements diff --git a/.agents/skills/sveltekit-data-flow/references/serialization.md b/.agents/skills/sveltekit-data-flow/references/serialization.md new file mode 100644 index 0000000..a343b57 --- /dev/null +++ b/.agents/skills/sveltekit-data-flow/references/serialization.md @@ -0,0 +1,317 @@ +# Serialization: What Can/Can't Be Returned + +## The Rule + +**Server load functions and form actions must return JSON-serializable +data.** + +Data travels from server → client as JSON. Non-JSON types break. + +## ✅ Serializable (Safe) + +| Type | Example | Notes | +| ------------ | ------------------------- | --------------------------------- | +| String | `"hello"` | ✅ | +| Number | `42`, `3.14` | ✅ | +| Boolean | `true`, `false` | ✅ | +| null | `null` | ✅ | +| Array | `[1, 2, 3]` | ✅ | +| Plain Object | `{ name: 'Alice' }` | ✅ | +| Nested | `{ user: { posts: [] } }` | ✅ If all values are serializable | + +## ❌ NOT Serializable (Breaks) + +| Type | Example | Why | Fix | +| -------------- | -------------- | -------------------------- | -------------------------------------------- | +| Date | `new Date()` | Becomes string | Use `.toISOString()` | +| undefined | `undefined` | Removed from JSON | Use `null` | +| Function | `() => {}` | Can't serialize | Remove or convert to data | +| Class instance | `new User()` | Only serializes properties | Convert to plain object | +| Map | `new Map()` | Becomes `{}` | Convert to object: `Object.fromEntries(map)` | +| Set | `new Set()` | Becomes `{}` | Convert to array: `Array.from(set)` | +| BigInt | `123n` | Error | Convert to string | +| Symbol | `Symbol('id')` | Removed | Don't use | +| RegExp | `/test/` | Becomes `{}` | Convert to string | +| Error | `new Error()` | Loses stack | Extract message/code | + +## Examples + +### ❌ Wrong: Returning Date + +```typescript +// +page.server.ts - WRONG +export const load = async () => { + return { + createdAt: new Date(), // Serializes as string, not Date object + }; +}; +``` + +```svelte + + +``` + +### ✅ Right: Convert Date to ISO String + +```typescript +// +page.server.ts - RIGHT +export const load = async () => { + const user = await db.users.findFirst(); + + return { + user: { + id: user.id, + name: user.name, + createdAt: user.createdAt.toISOString(), // Convert to string + }, + }; +}; +``` + +```svelte + + +``` + +### ❌ Wrong: Returning Class Instance + +```typescript +// +page.server.ts - WRONG +class User { + constructor( + public id: number, + public name: string, + ) {} + + getDisplayName() { + return `User: ${this.name}`; + } +} + +export const load = async () => { + const user = new User(1, 'Alice'); + return { user }; // Methods are lost during serialization +}; +``` + +```svelte + +``` + +### ✅ Right: Return Plain Object + +```typescript +// +page.server.ts - RIGHT +export const load = async () => { + const user = await db.users.findFirst(); + + return { + user: { + id: user.id, + name: user.name, + email: user.email, + // Only plain data, no methods + }, + }; +}; +``` + +### ❌ Wrong: undefined Values + +```typescript +// +page.server.ts - WRONG +export const load = async () => { + return { + name: 'Alice', + email: undefined, // Removed during JSON.stringify + }; +}; +``` + +```svelte + +``` + +### ✅ Right: Use null + +```typescript +// +page.server.ts - RIGHT +export const load = async () => { + return { + name: 'Alice', + email: null, // Preserved + }; +}; +``` + +### ❌ Wrong: Map/Set + +```typescript +// +page.server.ts - WRONG +export const load = async () => { + const tags = new Set(['svelte', 'typescript']); + const metadata = new Map([['version', '1.0']]); + + return { + tags, // Becomes {} + metadata, // Becomes {} + }; +}; +``` + +### ✅ Right: Convert to Array/Object + +```typescript +// +page.server.ts - RIGHT +export const load = async () => { + const tags = new Set(['svelte', 'typescript']); + const metadata = new Map([['version', '1.0']]); + + return { + tags: Array.from(tags), // ['svelte', 'typescript'] + metadata: Object.fromEntries(metadata), // { version: '1.0' } + }; +}; +``` + +## ORM Returns (Drizzle, Prisma) + +Most ORMs return plain objects with Date fields: + +```typescript +// +page.server.ts +export const load = async () => { + const user = await db.query.users.findFirst(); + // user = { id: 1, name: 'Alice', createdAt: Date } + + return { + user: { + ...user, + createdAt: user.createdAt.toISOString(), // Convert Date + }, + }; +}; +``` + +Or use a helper: + +```typescript +function serialize>(obj: T): T { + return JSON.parse(JSON.stringify(obj)); // Forces serialization +} + +export const load = async () => { + const user = await db.query.users.findFirst(); + return { user: serialize(user) }; +}; +``` + +## BigInt + +```typescript +// +page.server.ts - WRONG +export const load = async () => { + return { + userId: 123456789012345678901234567890n, // ERROR - can't serialize + }; +}; + +// +page.server.ts - RIGHT +export const load = async () => { + return { + userId: '123456789012345678901234567890', // String + }; +}; +``` + +## Detecting Serialization Issues + +SvelteKit will throw an error if you try to return non-serializable +data: + +``` +Error: Data returned from `load` while rendering / is not serializable: + - Cannot stringify arbitrary non-POJOs +``` + +## Testing Serialization + +```typescript +const data = { user: new User() }; + +try { + JSON.parse(JSON.stringify(data)); + console.log('✅ Serializable'); +} catch (e) { + console.log('❌ Not serializable'); +} +``` + +## TypeScript Help + +Use `satisfies` to catch issues at compile time: + +```typescript +import type { PageServerLoad } from './$types'; + +export const load = (async () => { + return { + user: { + id: 1, + name: 'Alice', + createdAt: new Date(), // TypeScript allows this, but it's problematic + }, + }; +}) satisfies PageServerLoad; +``` + +Better: Create a type that only allows primitives: + +```typescript +type Serializable = + | string + | number + | boolean + | null + | { [key: string]: Serializable } + | Serializable[]; + +export const load: PageServerLoad = async () => { + const data: Serializable = { + user: { + id: 1, + name: 'Alice', + createdAt: new Date().toISOString(), // Must be string + }, + }; + + return data; +}; +``` + +## Quick Checklist + +Before returning from server load or form action: + +1. ✅ All values are string, number, boolean, null, array, or plain + object? +2. ✅ No Date objects? (convert to `.toISOString()`) +3. ✅ No undefined? (use null) +4. ✅ No class instances? (convert to plain objects) +5. ✅ No Map/Set? (convert to object/array) +6. ✅ No functions? +7. ✅ No BigInt? (convert to string) diff --git a/.agents/skills/sveltekit-structure/README.md b/.agents/skills/sveltekit-structure/README.md new file mode 100644 index 0000000..ede6b2d --- /dev/null +++ b/.agents/skills/sveltekit-structure/README.md @@ -0,0 +1,17 @@ +# Sveltekit Structure + +SvelteKit structure guidance. Use for routing, layouts, error +handling, and SSR. Covers file naming (+page vs +layout vs +server), +nested layouts, error boundaries, and hydration. + +## Structure + +- `SKILL.md` - Main skill instructions +- `references/` - Detailed documentation loaded as needed +- `scripts/` - Executable code for deterministic operations +- `assets/` - Templates, images, or other resources + +## Usage + +This skill is automatically discovered by Claude when relevant to the +task. diff --git a/.agents/skills/sveltekit-structure/SKILL.md b/.agents/skills/sveltekit-structure/SKILL.md new file mode 100644 index 0000000..9c049b6 --- /dev/null +++ b/.agents/skills/sveltekit-structure/SKILL.md @@ -0,0 +1,79 @@ +--- +name: sveltekit-structure +# IMPORTANT: Keep description on ONE line for Claude Code compatibility +# prettier-ignore +description: SvelteKit structure guidance. Use for routing, layouts, error handling, SSR, or svelte:boundary. Covers file naming, nested layouts, error boundaries, pending UI, and hydration. +--- + +# SvelteKit Structure + +## Quick Start + +**File types:** `+page.svelte` (page) | `+layout.svelte` (wrapper) | +`+error.svelte` (error boundary) | `+server.ts` (API endpoint) + +**Routes:** `src/routes/about/+page.svelte` → `/about` | +`src/routes/posts/[id]/+page.svelte` → `/posts/123` + +**Layouts:** Apply to all child routes. `+layout.svelte` at any level +wraps descendants. + +## Example + +``` +src/routes/ +├── +layout.svelte # Root layout (all pages) +├── +page.svelte # Homepage / +├── about/+page.svelte # /about +└── dashboard/ + ├── +layout.svelte # Dashboard layout (dashboard pages only) + ├── +page.svelte # /dashboard + └── settings/+page.svelte # /dashboard/settings +``` + +```svelte + + + + +
    {@render children()}
    +
    +``` + +## Reference Files + +- [file-naming.md](references/file-naming.md) - File naming + conventions +- [layout-patterns.md](references/layout-patterns.md) - Nested layouts +- [error-handling.md](references/error-handling.md) - +error.svelte + placement +- [svelte-boundary.md](references/svelte-boundary.md) - + Component-level error/pending +- [ssr-hydration.md](references/ssr-hydration.md) - SSR and + browser-only code + +## Notes + +- Layouts: `{@render children()}` | Errors: +error.svelte _above_ + failing route +- Groups: `(name)` folders don't affect URL | Client-only: check + `browser` +- **Last verified:** 2025-01-11 + + diff --git a/.agents/skills/sveltekit-structure/references/error-handling.md b/.agents/skills/sveltekit-structure/references/error-handling.md new file mode 100644 index 0000000..c81a084 --- /dev/null +++ b/.agents/skills/sveltekit-structure/references/error-handling.md @@ -0,0 +1,274 @@ +# Error Handling + +## Error Boundary Placement + +**Key rule:** `+error.svelte` must be _above_ the failing route in the +hierarchy. + +``` +src/routes/ +├── +error.svelte # Catches errors in all routes below +├── +page.svelte # If this errors → uses +error.svelte above +└── admin/ + ├── +error.svelte # Catches errors in admin routes + └── +page.svelte # If this errors → uses admin/+error.svelte +``` + +**Wrong:** + +``` +src/routes/dashboard/ +├── +layout.svelte # If this errors... +└── +error.svelte # This won't catch it (too low) +``` + +**Right:** + +``` +src/routes/ +├── +error.svelte # Catches dashboard layout errors +└── dashboard/ + ├── +layout.svelte + └── +error.svelte # Catches dashboard page errors +``` + +## Error Propagation + +Errors bubble up to the nearest `+error.svelte`: + +``` +src/routes/ +├── +error.svelte # Level 1 (root fallback) +└── blog/ + ├── +error.svelte # Level 2 (blog fallback) + └── [slug]/ + ├── +layout.server.ts # Error here → blog/+error.svelte + ├── +page.server.ts # Error here → blog/+error.svelte + └── +page.svelte # Error here → blog/+error.svelte +``` + +**If no error boundary exists at that level, it goes to the parent.** + +## Basic Error Page + +```svelte + + + +

    {$page.status}

    {$page.error.message}

    +``` + +## Custom Error Data + +```typescript +// +page.server.ts +import { error } from '@sveltejs/kit'; + +export const load = async ({ params }) => { + const post = await getPost(params.id); + + if (!post) { + throw error(404, { + message: 'Post not found', + postId: params.id, + }); + } + + return { post }; +}; +``` + +```svelte + + + +

    {$page.status}

    +

    {$page.error.message}

    + +{#if $page.error.postId} +

    Could not find post with ID: {$page.error.postId}

    +{/if} +``` + +## Status Code Specific Errors + +```svelte + + + +{#if $page.status === 404} +

    Page Not Found

    +

    The page you're looking for doesn't exist.

    + Go home +{:else if $page.status === 403} +

    Access Denied

    +

    You don't have permission to view this page.

    +{:else if $page.status === 401} +

    Unauthorized

    +

    Please log in to continue.

    + Login +{:else if $page.status >= 500} +

    Server Error

    +

    Something went wrong on our end. Please try again later.

    +{:else} +

    Error {$page.status}

    +

    {$page.error.message}

    +{/if} +``` + +## Common Status Codes + +- **400** - Bad Request (malformed request) +- **401** - Unauthorized (not logged in) +- **403** - Forbidden (logged in but no permission) +- **404** - Not Found (resource doesn't exist) +- **500** - Internal Server Error (unhandled exception) +- **503** - Service Unavailable (server overloaded/down) + +## Error in Layout vs Page + +**Layout error:** + +```typescript +// src/routes/dashboard/+layout.server.ts +export const load = async ({ locals }) => { + if (!locals.user) { + throw error(401, 'Not logged in'); // Caught by +error.svelte ABOVE dashboard + } + return { user: locals.user }; +}; +``` + +**Page error:** + +```typescript +// src/routes/dashboard/settings/+page.server.ts +export const load = async () => { + throw error(404, 'Settings not found'); // Caught by nearest +error.svelte +}; +``` + +## Expected Errors vs Unexpected Errors + +**Expected (use error()):** + +```typescript +// User requests invalid resource +if (!post) throw error(404, 'Post not found'); + +// User lacks permission +if (post.authorId !== user.id) throw error(403, 'Not your post'); +``` + +**Unexpected (let it bubble):** + +```typescript +// Unhandled exceptions (DB connection fails, etc.) +// Will show generic 500 error +const posts = await db.query.posts.findMany(); // Might throw +``` + +## Handling Errors in handleError Hook + +```typescript +// src/hooks.server.ts +import type { HandleServerError } from '@sveltejs/kit'; + +export const handleError: HandleServerError = ({ error, event }) => { + // Log to error tracking service + console.error('Error:', error, 'Path:', event.url.pathname); + + // Return user-friendly message (don't expose internals) + return { + message: 'An unexpected error occurred', + code: error?.code ?? 'UNKNOWN', + }; +}; +``` + +## Error in Load vs Error in Component + +**Load function error (recommended):** + +```typescript +// +page.server.ts +export const load = async ({ params }) => { + const user = await getUser(params.id); + if (!user) throw error(404, 'User not found'); // Shows +error.svelte + return { user }; +}; +``` + +**Component error (not ideal):** + +```svelte + + + +

    {data.user.name}

    +``` + +**Best practice:** Validate and throw errors in `load` functions, not +components. + +## Fallback Error Handling + +Always have a root `+error.svelte`: + +```svelte + + + +

    Oops! Something went wrong

    + +{#if dev} + +
    {JSON.stringify($page.error, null, 2)}
    +{:else} + +

    We're sorry, but something unexpected happened.

    +{/if} + +Go home +``` + +## Testing Error Boundaries + +```typescript +// +page.server.ts +export const load = async ({ url }) => { + // Test error page in dev + if (url.searchParams.has('test-error')) { + throw error(500, 'Test error'); + } + + return {}; +}; +``` + +Visit `http://localhost:5173/page?test-error` to see error boundary. + +## Key Takeaways + +1. ✅ Place `+error.svelte` ABOVE failing routes +2. ✅ Errors bubble up to nearest error boundary +3. ✅ Always have a root `src/routes/+error.svelte` +4. ✅ Throw errors in `load` functions, not components +5. ✅ Use specific status codes (401, 403, 404, 500) +6. ✅ Customize error pages per section (blog, admin, etc.) +7. ✅ Use `handleError` hook for logging/monitoring +8. ✅ Show detailed errors in dev, generic in production diff --git a/.agents/skills/sveltekit-structure/references/file-naming.md b/.agents/skills/sveltekit-structure/references/file-naming.md new file mode 100644 index 0000000..b38409e --- /dev/null +++ b/.agents/skills/sveltekit-structure/references/file-naming.md @@ -0,0 +1,69 @@ +# File Naming Conventions + +## Core Files + +| File | Purpose | Runs | Example | +| ------------------- | --------------------- | --------------------- | --------------------------------------------- | +| `+page.svelte` | Page component | Client & Server (SSR) | `/routes/about/+page.svelte` → `/about` | +| `+page.ts` | Universal load | Client & Server | Data for +page.svelte | +| `+page.server.ts` | Server load & actions | Server only | DB queries, form actions | +| `+layout.svelte` | Layout wrapper | Client & Server | Wraps child routes | +| `+layout.ts` | Layout universal load | Client & Server | Data for +layout.svelte | +| `+layout.server.ts` | Layout server load | Server only | Auth, user data | +| `+error.svelte` | Error boundary | Client & Server | Shown when error thrown | +| `+server.ts` | API endpoint | Server only | `/routes/api/users/+server.ts` → `/api/users` | + +## Route Parameters + +| Pattern | Matches | Example | +| -------------- | -------------- | ---------------------------------------------------------------- | +| `[id]` | Single param | `/posts/[id]/+page.svelte` → `/posts/123` | +| `[slug]` | Single param | `/blog/[slug]/+page.svelte` → `/blog/hello-world` | +| `[[optional]]` | Optional param | `/search/[[query]]/+page.svelte` → `/search` or `/search/svelte` | +| `[...rest]` | Rest params | `/docs/[...path]/+page.svelte` → `/docs/a/b/c` | + +## Route Groups + +| Pattern | Purpose | URL | +| ------------- | ---------------------------- | ---------------------------------------------- | +| `(group)` | Group routes (no URL impact) | `/(app)/dashboard/+page.svelte` → `/dashboard` | +| `(marketing)` | Separate layouts | Different layout for marketing pages | + +## Special Files + +- `hooks.server.ts` - Server hooks (handle function, runs on every + request) +- `hooks.client.ts` - Client hooks (runs in browser) +- `app.html` - HTML template +- `service-worker.ts` - Service worker +- `params/*.ts` - Param validators + +## Common Patterns + +``` +src/routes/ +├── +layout.svelte # Root layout +├── +layout.ts # Root data +├── +page.svelte # Homepage +├── +error.svelte # Root error boundary +│ +├── (app)/ # App routes (grouped) +│ ├── +layout.svelte # App layout (auth required) +│ ├── dashboard/+page.svelte # /dashboard +│ └── settings/+page.svelte # /settings +│ +├── (marketing)/ # Marketing routes (grouped) +│ ├── +layout.svelte # Marketing layout +│ ├── about/+page.svelte # /about +│ └── pricing/+page.svelte # /pricing +│ +├── blog/ +│ ├── +page.svelte # /blog (list) +│ └── [slug]/ +│ ├── +page.svelte # /blog/post-title +│ └── +page.server.ts # Load post data +│ +└── api/ + └── posts/ + └── +server.ts # API: GET/POST /api/posts +``` diff --git a/.agents/skills/sveltekit-structure/references/layout-patterns.md b/.agents/skills/sveltekit-structure/references/layout-patterns.md new file mode 100644 index 0000000..7b251d6 --- /dev/null +++ b/.agents/skills/sveltekit-structure/references/layout-patterns.md @@ -0,0 +1,387 @@ +# Layout Patterns + +## Basic Layout + +```svelte + + + +
    Header
    +
    {@render children()}
    +
    Footer
    +``` + +**Key points:** + +- Must declare `children` in `$props()` +- Use `{@render children()}` to render nested content +- Root layout wraps ALL pages + +## Nested Layouts + +Layouts inherit from parent layouts: + +``` +src/routes/ +├── +layout.svelte # Root layout (all pages) +└── dashboard/ + ├── +layout.svelte # Dashboard layout (dashboard pages only) + └── +page.svelte # Uses both layouts +``` + +**Result:** Root layout wraps dashboard layout wraps page. + +**Rendering order:** + +``` +Root +layout.svelte + └─ Dashboard +layout.svelte + └─ +page.svelte +``` + +### Example: Nested Layout Pattern + +```svelte + + + +
    + + {@render children()} +
    +``` + +```svelte + + + +
    + +
    {@render children()}
    +
    +``` + +```svelte +

    Dashboard Home

    +``` + +**Rendered HTML structure:** + +```html +
    + +
    + +
    +

    Dashboard Home

    +
    +
    +
    +``` + +## Layout Groups + +Use `(groups)` to organize without affecting URLs: + +``` +src/routes/ +├── (marketing)/ +│ ├── +layout.svelte # Marketing layout +│ ├── about/+page.svelte # /about (uses marketing layout) +│ └── pricing/+page.svelte # /pricing (uses marketing layout) +│ +└── (app)/ + ├── +layout.svelte # App layout + ├── dashboard/+page.svelte # /dashboard (uses app layout) + └── settings/+page.svelte # /settings (uses app layout) +``` + +**Key points:** + +- Parentheses make groups invisible in URLs +- `/about` route, NOT `/(marketing)/about` +- Different layouts for different sections +- Useful for authentication boundaries + +### Example: Marketing vs App Layouts + +```svelte + + + +
    + +
    + +{@render children()} + +
    © 2024 Company
    +``` + +```svelte + + + +
    + +
    + +{@render children()} +``` + +## Reset Layout + +Use `@` in filename to break layout inheritance: + +**NOT RECOMMENDED** - This feature is deprecated in SvelteKit 2+. + +Instead, use layout groups to create separate hierarchies: + +``` +src/routes/ +├── +layout.svelte # Root layout (for most pages) +├── (app)/ +│ └── +layout.svelte # App layout +└── (public)/ + └── +layout.svelte # Public layout (completely separate) +``` + +## Layout with Data Loading + +```svelte + +export const load = async ({ locals }) => { + // Available to all child routes + return { + user: locals.user, + }; +}; +``` + +```svelte + + + +
    + {#if data.user} + Welcome, {data.user.name} + {:else} + Login + {/if} +
    + +{@render children()} +``` + +## Conditional Layout Content + +```svelte + + + +{#if !$page.url.pathname.startsWith('/admin')} +
    Public Header
    +{/if} + +{@render children()} + +{#if !$page.url.pathname.includes('/checkout')} +
    Footer
    +{/if} +``` + +**Better approach:** Use layout groups instead of conditionals. + +## Protected Layouts + +```typescript +// src/routes/(app)/+layout.server.ts +import { redirect } from '@sveltejs/kit'; + +export const load = async ({ locals }) => { + if (!locals.user) { + throw redirect(303, '/login'); + } + + return { + user: locals.user, + }; +}; +``` + +All routes under `(app)` group now require authentication. + +## Sharing Layout State + +```svelte + + + +{@render children()} +``` + +```svelte + + + +

    Hello, {user.name}

    +``` + +## Layout Slot Props (Snippets) + +```svelte + + + +
    + +
    + {#if header} +
    {@render header()}
    + {/if} +
    {@render children()}
    +
    +
    +``` + +```svelte + +{#snippet header()} +

    Custom Dashboard Header

    +{/snippet} + +

    Dashboard content

    +``` + +## Common Patterns + +### Pattern 1: Auth Boundary + +``` +src/routes/ +├── +layout.svelte # Root (no auth) +├── (public)/ +│ ├── +layout.svelte # Public layout (landing pages) +│ ├── +page.svelte # / +│ └── about/+page.svelte # /about +└── (protected)/ + ├── +layout.server.ts # Check auth, redirect if not logged in + ├── +layout.svelte # Protected layout (app UI) + ├── dashboard/+page.svelte # /dashboard + └── settings/+page.svelte # /settings +``` + +### Pattern 2: Multi-tenant with Shared Root + +``` +src/routes/ +├── +layout.svelte # Root layout (shared) +├── admin/ +│ ├── +layout.svelte # Admin layout +│ └── users/+page.svelte # /admin/users +└── client/ + ├── +layout.svelte # Client layout + └── projects/+page.svelte # /client/projects +``` + +### Pattern 3: Progressive Enhancement + +```svelte + + + +
    + {#if $navigating} +
    + {/if} + + {@render children()} +
    + + +``` + +## Layout Best Practices + +1. ✅ Keep root layout minimal (shared across ALL pages) +2. ✅ Use layout groups for separate sections +3. ✅ Load shared data in layout's load function +4. ✅ Use context for sharing state with descendants +5. ✅ Avoid too many nested layouts (max 2-3 levels) +6. ✅ Don't put auth logic in root layout (use groups) +7. ✅ Remember: layouts share data DOWN, not UP +8. ❌ Avoid conditionals in layouts (use groups instead) + +## Debugging Layouts + +```svelte + + + +{@render children()} +``` + +## Key Takeaways + +- Layouts wrap all child routes +- Nested layouts create hierarchy (root → section → page) +- Groups `(name)` organize without affecting URLs +- Load data in `+layout.server.ts` to share with children +- Use context to share reactive state +- Keep layouts simple and focused diff --git a/.agents/skills/sveltekit-structure/references/ssr-hydration.md b/.agents/skills/sveltekit-structure/references/ssr-hydration.md new file mode 100644 index 0000000..bc59f1e --- /dev/null +++ b/.agents/skills/sveltekit-structure/references/ssr-hydration.md @@ -0,0 +1,92 @@ +# SSR & Hydration + +## The Problem + +SvelteKit runs on server (SSR) then hydrates in browser. Code using +browser APIs (`window`, `document`, `localStorage`) fails on server. + +## Solution: Check for Browser + +```typescript +import { browser } from '$app/environment'; + +// In load function +export const load = async () => { + const theme = browser ? localStorage.getItem('theme') : 'light'; + return { theme }; +}; +``` + +```svelte + + +``` + +## Common Mistakes + +### ❌ Using window Without Check + +```typescript +// WRONG - fails on server +export const load = async () => { + const width = window.innerWidth; // ERROR on server + return { width }; +}; + +// RIGHT +import { browser } from '$app/environment'; + +export const load = async () => { + const width = browser ? window.innerWidth : 1024; + return { width }; +}; +``` + +### ❌ Accessing DOM in Load + +```typescript +// WRONG +export const load = async () => { + const el = document.getElementById('root'); // ERROR on server +}; + +// RIGHT - Do DOM stuff in onMount +export const load = async () => { + return {}; +}; +// Then in component: +onMount(() => { + const el = document.getElementById('root'); +}); +``` + +## Disable SSR (Not Recommended) + +```typescript +// +page.ts +export const ssr = false; // Disables SSR for this page +``` + +Only use when absolutely necessary (e.g., heavy Canvas/WebGL). diff --git a/.agents/skills/sveltekit-structure/references/svelte-boundary.md b/.agents/skills/sveltekit-structure/references/svelte-boundary.md new file mode 100644 index 0000000..21eaf1b --- /dev/null +++ b/.agents/skills/sveltekit-structure/references/svelte-boundary.md @@ -0,0 +1,150 @@ +# svelte:boundary Component + +> Available in Svelte 5.3+ + +## Two Purposes + +1. **Error boundaries** - catch rendering errors +2. **Pending UI** - show loading state while `await` resolves + +## Basic Error Boundary + +```svelte + console.error(e)}> + + + {#snippet failed(error, reset)} +

    Error: {error.message}

    + + {/snippet} +
    +``` + +## Pending UI (Loading States) + +> **⚠️ Known Bug:** `` + `{@const await}` causes +> infinite navigation loops during client-side page transitions when +> pages share async queries. See +> [sveltejs/svelte#17717](https://github.com/sveltejs/svelte/issues/17717). +> Use `{#await}` blocks until this is fixed. + +With `experimental.async: true` (Svelte 5.36+), `{@const await}` is +possible but **not safe for pages with shared queries or navigation**: + +```svelte + + + {#snippet pending()} + + {/snippet} + + {@const data = await loadData()} + + +``` + +**Safe alternative:** + +```svelte +{#await loadData()} + +{:then data} + +{:catch error} +

    Error: {error.message}

    +{/await} +``` + +## Combined Error + Pending + +Use `svelte:boundary` for **error catching only**, with `{#await}` for +async data: + +```svelte + + {#snippet failed(error, reset)} +

    Failed to load user

    + + {/snippet} + + {#await fetchUser()} +

    Loading user...

    + {:then user} + + {:catch error} +

    Error: {error.message}

    + {/await} +
    +``` + +## What Gets Caught + +**Caught:** + +- Errors during rendering +- Errors in `$effect` + +**NOT Caught:** + +- Event handler errors (`onclick`, etc.) +- Errors after `setTimeout` +- Async errors outside boundary's await + +## vs +error.svelte + +| Feature | svelte:boundary | +error.svelte | +| -------- | ----------------------- | ------------- | +| Scope | Component subtree | Route segment | +| Reset | Built-in reset function | Navigate away | +| Pending | Yes (pending snippet) | No | +| Use case | Component-level | Page-level | + +## Error Tracking Integration + +```svelte + { + // Send to Sentry, LogRocket, etc. + errorTracker.captureException(error); + }} +> + + + {#snippet failed(error, reset)} + + {/snippet} + +``` + +## Nested Boundaries + +Inner boundary catches first: + +```svelte + + + {#snippet failed(e)} +

    Outer caught: {e.message}

    + {/snippet} + + + + {#snippet failed(e)} +

    Inner caught: {e.message}

    + {/snippet} + + +
    +
    +``` + +## Key Points + +- Use `svelte:boundary` for component-level error isolation +- Use `+error.svelte` for route-level error pages +- `pending` snippet shows only on **initial** load (not on refresh - no flicker) +- `failed` snippet replaces content on error +- `reset` function lets users retry +- Errors in event handlers are NOT caught +- Requires `experimental.async: true` in svelte.config.js for `{@const await}` +- **⚠️ Bug:** `{@const await}` + navigation causes infinite loops ([#17717](https://github.com/sveltejs/svelte/issues/17717)) — use `{#await}` instead diff --git a/.agents/skills/typescript-best-practices/SKILL.md b/.agents/skills/typescript-best-practices/SKILL.md new file mode 100644 index 0000000..703db2b --- /dev/null +++ b/.agents/skills/typescript-best-practices/SKILL.md @@ -0,0 +1,270 @@ +--- +name: typescript-best-practices +description: Provides TypeScript patterns for type-first development, making illegal states unrepresentable, exhaustive handling, and runtime validation. Must use when reading or writing TypeScript/JavaScript files. +--- + +# TypeScript Best Practices + +## Pair with React Best Practices + +When working with React components (`.tsx`, `.jsx` files or `@react` imports), always load `react-best-practices` alongside this skill. This skill covers TypeScript fundamentals; React-specific patterns (effects, hooks, refs, component design) are in the dedicated React skill. + +## Type-First Development + +Types define the contract before implementation. Follow this workflow: + +1. **Define the data model** - types, interfaces, and schemas first +2. **Define function signatures** - input/output types before logic +3. **Implement to satisfy types** - let the compiler guide completeness +4. **Validate at boundaries** - runtime checks where data enters the system + +### Make Illegal States Unrepresentable + +Use the type system to prevent invalid states at compile time. + +**Discriminated unions for mutually exclusive states:** +```ts +// Good: only valid combinations possible +type RequestState = + | { status: 'idle' } + | { status: 'loading' } + | { status: 'success'; data: T } + | { status: 'error'; error: Error }; + +// Bad: allows invalid combinations like { loading: true, error: Error } +type RequestState = { + loading: boolean; + data?: T; + error?: Error; +}; +``` + +**Branded types for domain primitives:** +```ts +type UserId = string & { readonly __brand: 'UserId' }; +type OrderId = string & { readonly __brand: 'OrderId' }; + +// Compiler prevents passing OrderId where UserId expected +function getUser(id: UserId): Promise { /* ... */ } + +function createUserId(id: string): UserId { + return id as UserId; +} +``` + +**Const assertions for literal unions:** +```ts +const ROLES = ['admin', 'user', 'guest'] as const; +type Role = typeof ROLES[number]; // 'admin' | 'user' | 'guest' + +// Array and type stay in sync automatically +function isValidRole(role: string): role is Role { + return ROLES.includes(role as Role); +} +``` + +**Required vs optional fields - be explicit:** +```ts +// Creation: some fields required +type CreateUser = { + email: string; + name: string; +}; + +// Update: all fields optional +type UpdateUser = Partial; + +// Database row: all fields present +type User = CreateUser & { + id: UserId; + createdAt: Date; +}; +``` + +## Module Structure + +Prefer smaller, focused files: one component, hook, or utility per file. Split when a file handles multiple concerns or exceeds ~200 lines. Colocate tests with implementation (`foo.test.ts` alongside `foo.ts`). Group related files by feature rather than by type. + +## Functional Patterns + +- Prefer `const` over `let`; use `readonly` and `Readonly` for immutable data. +- Use `array.map/filter/reduce` over `for` loops; chain transformations in pipelines. +- Write pure functions for business logic; isolate side effects in dedicated modules. +- Avoid mutating function parameters; return new objects/arrays instead. + +## Instructions + +- Enable `strict` mode; model data with interfaces and types. Strong typing catches bugs at compile time. +- Every code path returns a value or throws; use exhaustive `switch` with `never` checks in default. Unhandled cases become compile errors. +- Propagate errors with context; catching requires re-throwing or returning a meaningful result. Hidden failures delay debugging. +- Handle edge cases explicitly: empty arrays, null/undefined inputs, boundary values. Defensive checks prevent runtime surprises. +- Use `await` for async calls; wrap external calls with contextual error messages. Unhandled rejections crash Node processes. +- Add or update focused tests when changing logic; test behavior, not implementation details. + +## Examples + +Explicit failure for unimplemented logic: +```ts +export function buildWidget(widgetType: string): never { + throw new Error(`buildWidget not implemented for type: ${widgetType}`); +} +``` + +Exhaustive switch with never check: +```ts +type Status = "active" | "inactive"; + +export function processStatus(status: Status): string { + switch (status) { + case "active": + return "processing"; + case "inactive": + return "skipped"; + default: { + const _exhaustive: never = status; + throw new Error(`unhandled status: ${_exhaustive}`); + } + } +} +``` + +Wrap external calls with context: +```ts +export async function fetchWidget(id: string): Promise { + const response = await fetch(`/api/widgets/${id}`); + if (!response.ok) { + throw new Error(`fetch widget ${id} failed: ${response.status}`); + } + return response.json(); +} +``` + +Debug logging with namespaced logger: +```ts +import debug from "debug"; + +const log = debug("myapp:widgets"); + +export function createWidget(name: string): Widget { + log("creating widget: %s", name); + const widget = { id: crypto.randomUUID(), name }; + log("created widget: %s", widget.id); + return widget; +} +``` + +## Runtime Validation with Zod + +- Define schemas as single source of truth; infer TypeScript types with `z.infer<>`. Avoid duplicating types and schemas. +- Use `safeParse` for user input where failure is expected; use `parse` at trust boundaries where invalid data is a bug. +- Compose schemas with `.extend()`, `.pick()`, `.omit()`, `.merge()` for DRY definitions. +- Add `.transform()` for data normalization at parse time (trim strings, parse dates). +- Include descriptive error messages; use `.refine()` for custom validation logic. + +### Examples + +Schema as source of truth with type inference: +```ts +import { z } from "zod"; + +const UserSchema = z.object({ + id: z.string().uuid(), + email: z.string().email(), + name: z.string().min(1), + createdAt: z.string().transform((s) => new Date(s)), +}); + +type User = z.infer; +``` + +Return parse results to callers (never swallow errors): +```ts +import { z, SafeParseReturnType } from "zod"; + +export function parseUserInput(raw: unknown): SafeParseReturnType { + return UserSchema.safeParse(raw); +} + +// Caller handles both success and error: +const result = parseUserInput(formData); +if (!result.success) { + setErrors(result.error.flatten().fieldErrors); + return; +} +await submitUser(result.data); +``` + +Strict parsing at trust boundaries: +```ts +export async function fetchUser(id: string): Promise { + const response = await fetch(`/api/users/${id}`); + if (!response.ok) { + throw new Error(`fetch user ${id} failed: ${response.status}`); + } + const data = await response.json(); + return UserSchema.parse(data); // throws if API contract violated +} +``` + +Schema composition: +```ts +const CreateUserSchema = UserSchema.omit({ id: true, createdAt: true }); +const UpdateUserSchema = CreateUserSchema.partial(); +const UserWithPostsSchema = UserSchema.extend({ + posts: z.array(PostSchema), +}); +``` + +## Configuration + +- Load config from environment variables at startup; validate with Zod before use. Invalid config should crash immediately. +- Define a typed config object as single source of truth; avoid accessing `process.env` throughout the codebase. +- Use sensible defaults for development; require explicit values for production secrets. + +### Examples + +Typed config with Zod validation: +```ts +import { z } from "zod"; + +const ConfigSchema = z.object({ + PORT: z.coerce.number().default(3000), + DATABASE_URL: z.string().url(), + API_KEY: z.string().min(1), + NODE_ENV: z.enum(["development", "production", "test"]).default("development"), +}); + +export const config = ConfigSchema.parse(process.env); +``` + +Access config values (not process.env directly): +```ts +import { config } from "./config"; + +const server = app.listen(config.PORT); +const db = connect(config.DATABASE_URL); +``` + +## Optional: type-fest + +For advanced type utilities beyond TypeScript builtins, consider [type-fest](https://github.com/sindresorhus/type-fest): + +- `Opaque` - cleaner branded types than manual `& { __brand }` pattern +- `PartialDeep` - recursive partial for nested objects +- `ReadonlyDeep` - recursive readonly for immutable data +- `LiteralUnion` - literals with autocomplete + string fallback +- `SetRequired` / `SetOptional` - targeted field modifications +- `Simplify` - flatten complex intersection types in IDE tooltips + +```ts +import type { Opaque, PartialDeep, SetRequired } from 'type-fest'; + +// Branded type (cleaner than manual approach) +type UserId = Opaque; + +// Deep partial for patch operations +type UserPatch = PartialDeep; + +// Make specific fields required +type UserWithEmail = SetRequired, 'email'>; +``` diff --git a/.gitignore b/.gitignore index a461635..5ed83d8 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,6 @@ vite.config.ts.timestamp-* # Test results /test-results /playwright-report + +# Skills CLI (actual files are in .agents/skills/) +/.claude/skills diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..89affbd --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,30 @@ +{ + "version": 1, + "skills": { + "drizzle-migrations": { + "source": "erichowens/some_claude_skills", + "sourceType": "github", + "computedHash": "d2af96b92935e26f53d97616aa51a02f114c9da8129c5943408d3b4b19a10423" + }, + "svelte5-best-practices": { + "source": "ejirocodes/agent-skills", + "sourceType": "github", + "computedHash": "bfcbba3d355aede6f6be9720f195206d4dcaa7c152787ea00c06cb4a1ac44ba4" + }, + "sveltekit-data-flow": { + "source": "spences10/svelte-skills-kit", + "sourceType": "github", + "computedHash": "1ef78409b38d1cb7324d8e0a78ac3f4303e0a9c5b5789bfc2d646c0806faa618" + }, + "sveltekit-structure": { + "source": "spences10/svelte-skills-kit", + "sourceType": "github", + "computedHash": "a935fd51b32e4e37d23286c04748825a6a193ef74d9980385449aa4f80646f9d" + }, + "typescript-best-practices": { + "source": "0xbigboss/claude-code", + "sourceType": "github", + "computedHash": "838d6737e59e8b15457a6af23112bc3a107a8ac098606a19a43c2be0db2dbe5a" + } + } +}