managarten/.claude/guidelines/sveltekit-web.md
Wuesteon 0b539bde6b 📝 docs: add comprehensive Claude Code guidelines
Add detailed documentation for Claude Code in .claude/ directory:
- code-style.md: formatting, naming, linting rules
- database.md: Drizzle ORM patterns and schema conventions
- testing.md: Jest/Vitest patterns with mock factories
- nestjs-backend.md: controller, service, DTO patterns
- error-handling.md: Go-style Result types and error codes
- sveltekit-web.md: Svelte 5 runes and store patterns
- expo-mobile.md: React Native with NativeWind
- authentication.md: Mana Core Auth integration

Update root CLAUDE.md to reference new guidelines
2025-12-03 00:44:49 +01:00

17 KiB

SvelteKit Web Guidelines

Overview

All web applications use SvelteKit 2 with Svelte 5 in runes mode. This guide covers component patterns, state management, routing, and API integration.

Project Structure

apps/{project}/apps/web/
├── src/
│   ├── app.html              # HTML template
│   ├── app.css               # Global styles (Tailwind)
│   ├── app.d.ts              # Type declarations
│   ├── hooks.server.ts       # Server hooks (auth)
│   ├── lib/
│   │   ├── components/       # Reusable components
│   │   │   ├── ui/           # Generic UI components
│   │   │   └── {feature}/    # Feature-specific components
│   │   ├── stores/           # Svelte 5 stores (.svelte.ts)
│   │   ├── api/              # API client
│   │   ├── utils/            # Utilities
│   │   └── types/            # TypeScript types
│   └── routes/
│       ├── +layout.svelte    # Root layout
│       ├── +page.svelte      # Home page
│       ├── (auth)/           # Auth route group
│       │   ├── login/
│       │   └── register/
│       └── (protected)/      # Protected route group
│           ├── +layout.svelte
│           ├── files/
│           └── settings/
├── static/                   # Static assets
├── svelte.config.js
├── vite.config.ts
├── tailwind.config.js
└── package.json

Svelte 5 Runes

State with $state

<script lang="ts">
  // Reactive state
  let count = $state(0);
  let name = $state('');
  let items = $state<string[]>([]);

  // Object state
  let user = $state<User | null>(null);

  // Functions that modify state
  function increment() {
    count++;  // Direct mutation works
  }

  function addItem(item: string) {
    items = [...items, item];  // Or reassignment
  }
</script>

Derived Values with $derived

<script lang="ts">
  let count = $state(0);
  let items = $state<Item[]>([]);

  // Computed value - updates automatically
  const doubled = $derived(count * 2);
  const itemCount = $derived(items.length);
  const hasItems = $derived(items.length > 0);

  // Complex derived
  const sortedItems = $derived(
    [...items].sort((a, b) => a.name.localeCompare(b.name))
  );

  // Derived with conditions
  const displayText = $derived(
    count === 0 ? 'No items' :
    count === 1 ? '1 item' :
    `${count} items`
  );
</script>

Effects with $effect

<script lang="ts">
  import { browser } from '$app/environment';

  let searchQuery = $state('');
  let results = $state<SearchResult[]>([]);

  // Run effect when dependencies change
  $effect(() => {
    if (!browser) return;

    // This runs when searchQuery changes
    const timer = setTimeout(async () => {
      results = await search(searchQuery);
    }, 300);

    // Cleanup function
    return () => clearTimeout(timer);
  });

  // Effect for initialization
  $effect(() => {
    if (browser) {
      loadInitialData();
    }
  });
</script>

Props with $props

<script lang="ts">
  import type { File } from '$lib/types';

  // Define props with types
  interface Props {
    file: File;
    selected?: boolean;
    onDelete?: (id: string) => void;
    onSelect?: (file: File) => void;
  }

  // Destructure with defaults
  let {
    file,
    selected = false,
    onDelete,
    onSelect
  }: Props = $props();

  function handleDelete() {
    onDelete?.(file.id);
  }
</script>

<div class:selected onclick={() => onSelect?.(file)}>
  <span>{file.name}</span>
  {#if onDelete}
    <button onclick={handleDelete}>Delete</button>
  {/if}
</div>

Bindable Props with $bindable

<script lang="ts">
  interface Props {
    value: string;
    disabled?: boolean;
  }

  let { value = $bindable(), disabled = false }: Props = $props();
</script>

<input bind:value {disabled} />

<!-- Usage: -->
<!-- <TextInput bind:value={searchQuery} /> -->

Stores (Svelte 5 Pattern)

Store File (.svelte.ts)

// src/lib/stores/files.svelte.ts
import { browser } from '$app/environment';
import { api } from '$lib/api/client';
import type { File, AppError } from '$lib/types';

// Private state
let files = $state<File[]>([]);
let loading = $state(false);
let error = $state<AppError | null>(null);
let selectedId = $state<string | null>(null);

// Derived values
const selectedFile = $derived(
  files.find(f => f.id === selectedId) ?? null
);

const fileCount = $derived(files.length);

// Actions
async function loadFiles(folderId?: string): Promise<void> {
  if (!browser) return;

  loading = true;
  error = null;

  const result = await api.files.list(folderId);

  if (result.ok) {
    files = result.data;
  } else {
    error = result.error;
  }

  loading = false;
}

async function deleteFile(id: string): Promise<boolean> {
  const result = await api.files.delete(id);

  if (result.ok) {
    files = files.filter(f => f.id !== id);
    if (selectedId === id) selectedId = null;
    return true;
  }

  error = result.error;
  return false;
}

function selectFile(id: string | null): void {
  selectedId = id;
}

function reset(): void {
  files = [];
  loading = false;
  error = null;
  selectedId = null;
}

// Export as object with getters
export const fileStore = {
  // Getters for state
  get files() { return files; },
  get loading() { return loading; },
  get error() { return error; },
  get selectedFile() { return selectedFile; },
  get fileCount() { return fileCount; },

  // Actions
  loadFiles,
  deleteFile,
  selectFile,
  reset,
};

Using Stores in Components

<script lang="ts">
  import { fileStore } from '$lib/stores/files.svelte';
  import { onMount } from 'svelte';

  onMount(() => {
    fileStore.loadFiles();
  });

  async function handleDelete(id: string) {
    const success = await fileStore.deleteFile(id);
    if (success) {
      showToast('File deleted');
    }
  }
</script>

{#if fileStore.loading}
  <LoadingSpinner />
{:else if fileStore.error}
  <ErrorMessage message={fileStore.error.message} />
{:else}
  <FileList
    files={fileStore.files}
    selectedId={fileStore.selectedFile?.id}
    onSelect={(file) => fileStore.selectFile(file.id)}
    onDelete={handleDelete}
  />
{/if}

API Client

// src/lib/api/client.ts
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import type { Result, AppError } from '@manacore/shared-errors';
import { ErrorCode } from '@manacore/shared-errors';
import { PUBLIC_BACKEND_URL } from '$env/static/public';

interface ApiResponse<T> {
  ok: boolean;
  data?: T;
  error?: AppError;
}

async function request<T>(
  endpoint: string,
  options: RequestInit = {}
): Promise<Result<T>> {
  if (!browser) {
    return { ok: false, error: { code: ErrorCode.INTERNAL_ERROR, message: 'SSR not supported' } };
  }

  try {
    const token = authStore.token;

    const response = await fetch(`${PUBLIC_BACKEND_URL}${endpoint}`, {
      ...options,
      headers: {
        'Content-Type': 'application/json',
        ...(token ? { Authorization: `Bearer ${token}` } : {}),
        ...options.headers,
      },
    });

    // Handle 401 - redirect to login
    if (response.status === 401) {
      authStore.logout();
      goto('/login');
      return { ok: false, error: { code: ErrorCode.UNAUTHORIZED, message: 'Session expired' } };
    }

    const json: ApiResponse<T> = await response.json();

    if (!json.ok || json.error) {
      return {
        ok: false,
        error: json.error ?? { code: ErrorCode.UNKNOWN_ERROR, message: 'Request failed' },
      };
    }

    return { ok: true, data: json.data as T };
  } catch (error) {
    return {
      ok: false,
      error: { code: ErrorCode.EXTERNAL_SERVICE_ERROR, message: 'Network error' },
    };
  }
}

// Typed API endpoints
export const api = {
  files: {
    list: (folderId?: string) =>
      request<File[]>(`/api/v1/files${folderId ? `?folderId=${folderId}` : ''}`),

    get: (id: string) =>
      request<File>(`/api/v1/files/${id}`),

    create: (data: CreateFileDto) =>
      request<File>('/api/v1/files', {
        method: 'POST',
        body: JSON.stringify(data),
      }),

    update: (id: string, data: UpdateFileDto) =>
      request<File>(`/api/v1/files/${id}`, {
        method: 'PATCH',
        body: JSON.stringify(data),
      }),

    delete: (id: string) =>
      request<void>(`/api/v1/files/${id}`, { method: 'DELETE' }),
  },

  folders: {
    list: () => request<Folder[]>('/api/v1/folders'),
    get: (id: string) => request<Folder>(`/api/v1/folders/${id}`),
    create: (data: CreateFolderDto) =>
      request<Folder>('/api/v1/folders', {
        method: 'POST',
        body: JSON.stringify(data),
      }),
  },
};

Routing

Route Groups

src/routes/
├── +layout.svelte          # Root layout (applies to all)
├── +page.svelte            # / (home)
├── (auth)/                 # Auth pages (no sidebar)
│   ├── +layout.svelte      # Auth layout
│   ├── login/+page.svelte
│   └── register/+page.svelte
└── (app)/                  # App pages (with sidebar)
    ├── +layout.svelte      # App layout with auth check
    ├── files/
    │   ├── +page.svelte    # /files
    │   └── [id]/+page.svelte  # /files/:id
    └── settings/+page.svelte

Layout with Auth Check

<!-- src/routes/(app)/+layout.svelte -->
<script lang="ts">
  import { browser } from '$app/environment';
  import { goto } from '$app/navigation';
  import { authStore } from '$lib/stores/auth.svelte';
  import Sidebar from '$lib/components/layout/Sidebar.svelte';

  let { children } = $props();

  // Check auth on mount
  $effect(() => {
    if (browser && !authStore.isAuthenticated) {
      goto('/login');
    }
  });
</script>

{#if authStore.isAuthenticated}
  <div class="flex h-screen">
    <Sidebar />
    <main class="flex-1 overflow-auto">
      {@render children()}
    </main>
  </div>
{:else}
  <div class="flex items-center justify-center h-screen">
    <LoadingSpinner />
  </div>
{/if}

Dynamic Routes

<!-- src/routes/(app)/files/[id]/+page.svelte -->
<script lang="ts">
  import { page } from '$app/stores';
  import { api } from '$lib/api/client';

  let file = $state<File | null>(null);
  let loading = $state(true);
  let error = $state<string | null>(null);

  // Load file when ID changes
  $effect(() => {
    const fileId = $page.params.id;
    loadFile(fileId);
  });

  async function loadFile(id: string) {
    loading = true;
    error = null;

    const result = await api.files.get(id);

    if (result.ok) {
      file = result.data;
    } else {
      error = result.error.message;
    }

    loading = false;
  }
</script>

{#if loading}
  <LoadingSpinner />
{:else if error}
  <ErrorMessage message={error} />
{:else if file}
  <FileViewer {file} />
{/if}

Components

Component Pattern

<!-- src/lib/components/files/FileCard.svelte -->
<script lang="ts">
  import type { File } from '$lib/types';
  import { formatBytes, formatDate } from '$lib/utils/format';
  import FileIcon from './FileIcon.svelte';

  interface Props {
    file: File;
    selected?: boolean;
    onSelect?: () => void;
    onDelete?: () => void;
  }

  let { file, selected = false, onSelect, onDelete }: Props = $props();

  const formattedSize = $derived(formatBytes(file.size));
  const formattedDate = $derived(formatDate(file.createdAt));
</script>

<div
  class="p-4 rounded-lg border transition-colors cursor-pointer
         {selected ? 'border-primary bg-primary/5' : 'border-gray-200 hover:border-gray-300'}"
  onclick={onSelect}
  role="button"
  tabindex="0"
  onkeydown={(e) => e.key === 'Enter' && onSelect?.()}
>
  <div class="flex items-start gap-3">
    <FileIcon mimeType={file.mimeType} />

    <div class="flex-1 min-w-0">
      <h3 class="font-medium truncate">{file.name}</h3>
      <p class="text-sm text-gray-500">
        {formattedSize}{formattedDate}
      </p>
    </div>

    {#if onDelete}
      <button
        class="p-2 text-gray-400 hover:text-red-500"
        onclick|stopPropagation={onDelete}
        aria-label="Delete file"
      >
        <TrashIcon />
      </button>
    {/if}
  </div>
</div>

Snippets (Slot Replacement)

<!-- Parent component -->
<script lang="ts">
  import Modal from '$lib/components/ui/Modal.svelte';

  let showModal = $state(false);
</script>

<Modal bind:open={showModal}>
  {#snippet header()}
    <h2>Confirm Delete</h2>
  {/snippet}

  {#snippet content()}
    <p>Are you sure you want to delete this file?</p>
  {/snippet}

  {#snippet footer()}
    <button onclick={() => showModal = false}>Cancel</button>
    <button onclick={handleDelete}>Delete</button>
  {/snippet}
</Modal>

<!-- Modal.svelte -->
<script lang="ts">
  import type { Snippet } from 'svelte';

  interface Props {
    open: boolean;
    header?: Snippet;
    content?: Snippet;
    footer?: Snippet;
  }

  let { open = $bindable(), header, content, footer }: Props = $props();
</script>

{#if open}
  <div class="modal-overlay" onclick={() => open = false}>
    <div class="modal" onclick|stopPropagation>
      {#if header}
        <div class="modal-header">{@render header()}</div>
      {/if}

      {#if content}
        <div class="modal-content">{@render content()}</div>
      {/if}

      {#if footer}
        <div class="modal-footer">{@render footer()}</div>
      {/if}
    </div>
  </div>
{/if}

Styling

Tailwind Configuration

// tailwind.config.js
import sharedConfig from '@manacore/shared-tailwind';

export default {
  presets: [sharedConfig],
  content: ['./src/**/*.{html,js,svelte,ts}'],
  theme: {
    extend: {
      // Project-specific overrides
    },
  },
};

Global Styles

/* src/app.css */
@import 'tailwindcss';
@import '@manacore/shared-tailwind/theme.css';

/* Custom utilities */
@layer utilities {
  .scrollbar-thin {
    scrollbar-width: thin;
  }
}

/* Custom components */
@layer components {
  .btn-primary {
    @apply px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors;
  }
}

Form Handling

<script lang="ts">
  import { api } from '$lib/api/client';

  let name = $state('');
  let email = $state('');
  let loading = $state(false);
  let errors = $state<Record<string, string>>({});

  async function handleSubmit(e: SubmitEvent) {
    e.preventDefault();
    errors = {};

    // Client-side validation
    if (!name.trim()) errors.name = 'Name is required';
    if (!email.trim()) errors.email = 'Email is required';
    if (Object.keys(errors).length > 0) return;

    loading = true;
    const result = await api.users.create({ name, email });
    loading = false;

    if (result.ok) {
      goto('/users');
    } else {
      // Handle server errors
      if (result.error.code === 'ERR_5002') {
        errors.email = 'Email already exists';
      } else {
        errors.form = result.error.message;
      }
    }
  }
</script>

<form onsubmit={handleSubmit}>
  {#if errors.form}
    <div class="text-red-500 mb-4">{errors.form}</div>
  {/if}

  <div class="mb-4">
    <label for="name">Name</label>
    <input id="name" bind:value={name} class:border-red-500={errors.name} />
    {#if errors.name}
      <span class="text-red-500 text-sm">{errors.name}</span>
    {/if}
  </div>

  <div class="mb-4">
    <label for="email">Email</label>
    <input id="email" type="email" bind:value={email} class:border-red-500={errors.email} />
    {#if errors.email}
      <span class="text-red-500 text-sm">{errors.email}</span>
    {/if}
  </div>

  <button type="submit" disabled={loading} class="btn-primary">
    {loading ? 'Saving...' : 'Save'}
  </button>
</form>

Environment Variables

// Access in .svelte or .ts files
import { PUBLIC_BACKEND_URL, PUBLIC_MANA_CORE_AUTH_URL } from '$env/static/public';

// .env file
PUBLIC_BACKEND_URL=http://localhost:3016
PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001

Anti-Patterns to Avoid

Don't Use Old Svelte Syntax

<!-- BAD - Old Svelte 4 syntax -->
<script>
  let count = 0;
  $: doubled = count * 2;
  $: console.log(count);
</script>

<!-- GOOD - Svelte 5 runes -->
<script>
  let count = $state(0);
  const doubled = $derived(count * 2);
  $effect(() => console.log(count));
</script>

Don't Create Stores in Components

<!-- BAD - Store created in component -->
<script>
  let store = $state({ items: [] });  // This is local, not shared
</script>

<!-- GOOD - Import store from .svelte.ts file -->
<script>
  import { itemStore } from '$lib/stores/items.svelte';
</script>

Don't Fetch in Render

<!-- BAD - Fetches on every render -->
<script>
  const promise = fetch('/api/data').then(r => r.json());
</script>

{#await promise}...{/await}

<!-- GOOD - Fetch in effect or onMount -->
<script>
  import { onMount } from 'svelte';

  let data = $state(null);

  onMount(async () => {
    data = await fetch('/api/data').then(r => r.json());
  });
</script>