Agent Skills: Svelte Guidelines

Svelte 5 patterns including TanStack Query mutations, shadcn-svelte components, and component composition. Use when writing Svelte components, using TanStack Query, or working with shadcn-svelte UI.

UncategorizedID: epicenterhq/epicenter/svelte

Repository

EpicenterHQLicense: AGPL-3.0
4,024273

Install this agent skill to your local

pnpm dlx add-skill https://github.com/EpicenterHQ/epicenter/tree/HEAD/.agents/skills/svelte

Skill Files

Browse the full folder contents for svelte.

Download Skill

Loading file tree…

.agents/skills/svelte/SKILL.md

Skill Metadata

Name
svelte
Description
Svelte 5 patterns including runes ($state, $derived, $props), TanStack Query, SvelteMap reactive state, shadcn-svelte components, and component composition. Use when the user mentions .svelte files, Svelte components, or when using TanStack Query, fromTable/fromKv, or shadcn-svelte UI.

Svelte Guidelines

Reference Repositories

  • Svelte — Svelte 5 framework with runes and fine-grained reactivity
  • shadcn-svelte — Port of shadcn/ui for Svelte with Bits UI primitives
  • shadcn-svelte-extras — Additional components for shadcn-svelte

Related Skills: See query-layer for TanStack Query integration. See styling for CSS and Tailwind conventions.

When to Apply This Skill

Use this pattern when you need to:

  • Build Svelte 5 components that use TanStack Query mutations.
  • Replace nested ternary $derived mappings with satisfies Record lookups.
  • Decide between createMutation in .svelte and .execute() in .ts.
  • Follow shadcn-svelte import, composition, and component organization patterns.
  • Refactor one-off handle* wrappers into inline template actions.
  • Convert SvelteMap data to arrays for derived state or component props.

References

Load these on demand based on what you're working on:


$derived Value Mapping: Use satisfies Record, Not Ternaries

When a $derived expression maps a finite union to output values, use a satisfies Record lookup. Never use nested ternaries. Never use $derived.by() with a switch just to map values.

<!-- Bad: nested ternary in $derived -->
<script lang="ts">
	const tooltip = $derived(
		syncStatus.current === 'connected'
			? 'Connected'
			: syncStatus.current === 'connecting'
				? 'Connecting…'
				: 'Offline',
	);
</script>

<!-- Bad: $derived.by with switch for a pure value lookup -->
<script lang="ts">
	const tooltip = $derived.by(() => {
		switch (syncStatus.current) {
			case 'connected': return 'Connected';
			case 'connecting': return 'Connecting…';
			case 'offline': return 'Offline';
		}
	});
</script>

<!-- Good: $derived with satisfies Record -->
<script lang="ts">
	import type { SyncStatus } from '@epicenter/sync-client';

	const tooltip = $derived(
		({
			connected: 'Connected',
			connecting: 'Connecting…',
			offline: 'Offline',
		} satisfies Record<SyncStatus, string>)[syncStatus.current],
	);
</script>

Why satisfies Record wins:

  • Compile-time exhaustiveness: add a value to the union and TypeScript errors on the missing key. Nested ternaries silently fall through.
  • It's a data declaration, not control flow. The mapping is immediately visible.
  • $derived() stays a single expression — no need for $derived.by().

Reserve $derived.by() for multi-statement logic where you genuinely need a function body. For value lookups, keep it as $derived() with a record.

as const is unnecessary when using satisfies. satisfies Record<T, string> already validates shape and value types.

See docs/articles/record-lookup-over-nested-ternaries.md for rationale.

When to Use SvelteMap vs $state

Use SvelteMap when items have stable IDs and you need keyed lookup. Use $state for primitives, local UI booleans, and sequential data without identity.

| Data Shape | Use | Example | |---|---|---| | Workspace table rows (have IDs) | fromTable()SvelteMap | recordings, conversations, notes | | Workspace KV (single key) | fromKv() | selectedFolderId, sortBy | | Browser API keyed data | new SvelteMap() + listeners | Chrome tabs, windows | | Primitive value | $state(value) | $state(false), $state(''), $state(0) | | Sequential data without IDs | $state<T[]>([]) | terminal history, command history | | Ordered list where position matters | $state<T[]>([]) | open file tab order |

Anti-Pattern: $state for ID-Keyed Collections

// ❌ BAD: O(n) lookups, coarse reactivity, referential instability
let conversations = $state<Conversation[]>(readAll());
const metadata = $derived(conversations.find((c) => c.id === id)); // O(n) scan

// ✅ GOOD: O(1) lookups, per-key reactivity, stable $derived array
const conversationsMap = fromTable(workspace.tables.conversations);
const conversations = $derived(
	conversationsMap.values().toArray().sort((a, b) => b.updatedAt - a.updatedAt),
);
const metadata = $derived(conversationsMap.get(id)); // O(1) lookup

Three problems with $state<T[]> for keyed data:

  1. O(n) lookups — every .find() scans the whole array
  2. Coarse reactivity — updating one item re-triggers everything reading the array
  3. Referential instability — sorting in a getter creates a new array every access, causing TanStack Table infinite loops

See docs/articles/sveltemap-over-state-for-keyed-collections.md for the full rationale.

Reactive Table State Pattern

When a factory function exposes workspace table data via fromTable, follow this three-layer convention:

// 1. Map — reactive source (private, suffixed with Map)
const foldersMap = fromTable(workspaceClient.tables.folders);

// 2. Derived array — cached materialization (private, no suffix)
const folders = $derived(foldersMap.values().toArray());

// 3. Getter — public API (matches the derived name)
return {
	get folders() {
		return folders;
	},
};

Naming: {name}Map (private source) → {name} (cached derived) → get {name}() (public getter).

With Sort or Filter

Chain operations inside $derived — the entire pipeline is cached:

const tabs = $derived(tabsMap.values().toArray().sort((a, b) => b.savedAt - a.savedAt));
const notes = $derived(allNotes.filter((n) => n.deletedAt === undefined));

See the typescript skill for iterator helpers (.toArray(), .filter(), .find() on IteratorObject).

Template Props

For component props expecting T[], derive in the script block — never materialize in the template:

<!-- Bad: re-creates array on every render -->
<FujiSidebar entries={entries.values().toArray()} />

<!-- Good: cached via $derived -->
<script>
	const entriesArray = $derived(entries.values().toArray());
</script>
<FujiSidebar entries={entriesArray} />

Why $derived, Not a Plain Getter

Put reactive computations in $derived, not inside public getters.

A getter may still be reactive if it reads reactive state, but it recomputes on every access. $derived computes reactively and caches until dependencies change.

Use $derived for the computation. Use the getter only as a pass-through to expose that derived value.

See docs/articles/derived-vs-getter-caching-matters.md for rationale.

Reactive State Module Conventions

State modules use a factory function that returns a flat object with getters and methods, exported as a singleton.

function createBookmarkState() {
	const bookmarksMap = fromTable(workspaceClient.tables.bookmarks);
	const bookmarks = $derived(bookmarksMap.values().toArray());

	return {
		get bookmarks() { return bookmarks; },
		async add(tab: Tab) { /* ... */ },
		remove(id: BookmarkId) { /* ... */ },
	};
}

export const bookmarkState = createBookmarkState();

Naming

| Concern | Convention | Example | |---|---|---| | Export name | xState for domain state; descriptive noun for utilities | bookmarkState, notesState, deviceConfig, vadRecorder | | Factory function | createX() matching the export name | createBookmarkState() | | File name | Domain name, optionally with -state suffix | bookmark-state.svelte.ts, auth.svelte.ts |

Use the State suffix when the export name would collide with a key property (bookmarkState.bookmarks, not bookmarks.bookmarks).

Accessor Patterns

| Data Shape | Accessor | Example | |---|---|---| | Collection | Named getter | bookmarkState.bookmarks, notesState.notes | | Single reactive value | .current (Svelte 5 convention) | selectedFolderId.current, serverUrl.current | | Keyed lookup | .get(key) | toolTrustState.get(name), deviceConfig.get(key) |

The .current convention comes from runed (the standard Svelte 5 utility library). All 34+ runed utilities use .current. Never use .value (Vue convention).

Persisted State Utilities

For localStorage/sessionStorage persistence, use createPersistedState (single value) or createPersistedMap (typed multi-key config) from @epicenter/svelte.

// Single value — .current accessor
import { createPersistedState } from '@epicenter/svelte';
const theme = createPersistedState({
	key: 'app-theme',
	schema: type("'light' | 'dark'"),
	defaultValue: 'dark',
});
theme.current; // read
theme.current = 'light'; // write + persist

// Multi-key config — .get()/.set() with SvelteMap (per-key reactivity)
import { createPersistedMap, defineEntry } from '@epicenter/svelte';
const config = createPersistedMap({
	prefix: 'myapp.config.',
	definitions: {
		'theme': defineEntry(type("'light' | 'dark'"), 'dark'),
		'fontSize': defineEntry(type('number'), 14),
	},
});
config.get('theme'); // typed read
config.set('theme', 'light'); // typed write + persist

Both accept storage?: Storage (defaults to window.localStorage) for dependency injection.

Mutation Pattern Preference

In Svelte Files (.svelte)

Always prefer createMutation from TanStack Query for mutations. This provides:

  • Loading states (isPending)
  • Error states (isError)
  • Success states (isSuccess)
  • Better UX with automatic state management

The Preferred Pattern

Pass onSuccess and onError as the second argument to .mutate() to get maximum context:

<script lang="ts">
	import { createMutation } from '@tanstack/svelte-query';
	import * as rpc from '$lib/query';

	// Wrap .options in accessor function, no parentheses on .options
	// Name it after what it does, NOT with a "Mutation" suffix (redundant)
	const deleteSession = createMutation(
		() => rpc.sessions.deleteSession.options,
	);

	// Local state that we can access in callbacks
	let isDialogOpen = $state(false);
</script>

<Button
	onclick={() => {
		// Pass callbacks as second argument to .mutate()
		deleteSession.mutate(
			{ sessionId },
			{
				onSuccess: () => {
					// Access local state and context
					isDialogOpen = false;
					toast.success('Session deleted');
					goto('/sessions');
				},
				onError: (error) => {
					toast.error(error.title, { description: error.description });
				},
			},
		);
	}}
	disabled={deleteSession.isPending}
>
	{#if deleteSession.isPending}
		Deleting...
	{:else}
		Delete
	{/if}
</Button>

Why This Pattern?

  • More context: Access to local variables and state at the call site
  • Better organization: Success/error handling is co-located with the action
  • Flexibility: Different calls can have different success/error behaviors

In TypeScript Files (.ts)

Always use .execute() since createMutation requires component context:

// In a .ts file (e.g., load function, utility)
const result = await rpc.sessions.createSession.execute({
	body: { title: 'New Session' },
});

const { data, error } = result;
if (error) {
	// Handle error
} else if (data) {
	// Handle success
}

Exception: When to Use .execute() in Svelte Files

Only use .execute() in Svelte files when:

  1. You don't need loading states
  2. You're performing a one-off operation
  3. You need fine-grained control over async flow

Single-Use Functions: Inline or Document

If a function is defined in the script tag and used only once in the template, inline it at the call site. This applies to event handlers, callbacks, and any other single-use logic.

Why Inline?

Single-use extracted functions add indirection — the reader jumps between the function definition and the template to understand what happens on click/keydown/etc. Inlining keeps cause and effect together at the point where the action happens.

<!-- BAD: Extracted single-use function with no JSDoc or semantic value -->
<script>
	function handleShare() {
		share.mutate({ id });
	}

	function handleSelectItem(itemId: string) {
		goto(`/items/${itemId}`);
	}
</script>

<Button onclick={handleShare}>Share</Button>
<Item onclick={() => handleSelectItem(item.id)} />

<!-- GOOD: Inlined at the call site -->
<Button onclick={() => share.mutate({ id })}>Share</Button>
<Item onclick={() => goto(`/items/${item.id}`)} />

This also applies to longer handlers. If the logic is linear (guard clauses + branches, not deeply nested), inline it even if it's 10–15 lines:

<!-- GOOD: Inlined keyboard shortcut handler -->
<svelte:window onkeydown={(e) => {
	const meta = e.metaKey || e.ctrlKey;
	if (!meta) return;
	if (e.key === 'k') {
		e.preventDefault();
		commandPaletteOpen = !commandPaletteOpen;
		return;
	}
	if (e.key === 'n') {
		e.preventDefault();
		notesState.createNote();
	}
}} />

The Exception: JSDoc + Semantic Name

Keep a single-use function extracted only when both conditions are met:

  1. It has JSDoc explaining why it exists as a named unit.
  2. The name provides a clear semantic meaning that makes the template more readable than the inlined version would be.
<script lang="ts">
	/**
	 * Navigate the note list with arrow keys, wrapping at boundaries.
	 * Operates on the flattened display-order ID list to respect date grouping.
	 */
	function navigateWithArrowKeys(e: KeyboardEvent) {
		// 15 lines of keyboard navigation logic...
	}
</script>

<!-- The semantic name communicates intent better than inlined logic would -->
<div onkeydown={navigateWithArrowKeys} tabindex="-1">

Without JSDoc and a meaningful name, extract it anyway — the indirection isn't earning its keep.

Multi-Use Functions

Functions used 2 or more times should always stay extracted — this rule only applies to single-use functions.

Styling

For general CSS and Tailwind guidelines, see the styling skill.

shadcn-svelte Best Practices

Component Organization

  • Use the CLI: bunx shadcn-svelte@latest add [component]
  • Each component in its own folder under $lib/components/ui/ with an index.ts export
  • Follow kebab-case for folder names (e.g., dialog/, toggle-group/)
  • Group related sub-components in the same folder
  • When using $state, $derived, or functions only referenced once in markup, inline them directly

Import Patterns

Namespace imports (preferred for multi-part components):

import * as Dialog from '$lib/components/ui/dialog';
import * as ToggleGroup from '$lib/components/ui/toggle-group';

Named imports (for single components):

import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';

Lucide icons (always use individual imports from @lucide/svelte):

// Good: Individual icon imports
import Database from '@lucide/svelte/icons/database';
import MinusIcon from '@lucide/svelte/icons/minus';
import MoreVerticalIcon from '@lucide/svelte/icons/more-vertical';

// Bad: Don't import multiple icons from lucide-svelte
import { Database, MinusIcon, MoreVerticalIcon } from 'lucide-svelte';

The path uses kebab-case (e.g., more-vertical, minimize-2), and you can name the import whatever you want (typically PascalCase with optional Icon suffix).

Styling and Customization

  • Always use the cn() utility from $lib/utils for combining Tailwind classes
  • Modify component code directly rather than overriding styles with complex CSS
  • Use tailwind-variants for component variant systems
  • Follow the background/foreground convention for colors
  • Leverage CSS variables for theme consistency

Component Usage Patterns

Use proper component composition following shadcn-svelte patterns:

<Dialog.Root bind:open={isOpen}>
	<Dialog.Trigger>
		<Button>Open</Button>
	</Dialog.Trigger>
	<Dialog.Content>
		<Dialog.Header>
			<Dialog.Title>Title</Dialog.Title>
		</Dialog.Header>
	</Dialog.Content>
</Dialog.Root>

Custom Components

  • When extending shadcn components, create wrapper components that maintain the design system
  • Add JSDoc comments for complex component props
  • Ensure custom components follow the same organizational patterns
  • Consider semantic appropriateness (e.g., use section headers instead of cards for page sections)

Props Pattern

Always Inline Props Types

Never create a separate type Props = {...} declaration. Always inline the type directly in $props():

<!-- BAD: Separate Props type -->
<script lang="ts">
	type Props = {
		selectedWorkspaceId: string | undefined;
		onSelect: (id: string) => void;
	};

	let { selectedWorkspaceId, onSelect }: Props = $props();
</script>

<!-- GOOD: Inline props type -->
<script lang="ts">
	let { selectedWorkspaceId, onSelect }: {
		selectedWorkspaceId: string | undefined;
		onSelect: (id: string) => void;
	} = $props();
</script>

Children Prop Never Needs Type Annotation

The children prop is implicitly typed in Svelte. Never annotate it:

<!-- BAD: Annotating children -->
<script lang="ts">
	let { children }: { children: Snippet } = $props();
</script>

<!-- GOOD: children is implicitly typed -->
<script lang="ts">
	let { children } = $props();
</script>

<!-- GOOD: Other props need types, but children does not -->
<script lang="ts">
	let { children, title, onClose }: {
		title: string;
		onClose: () => void;
	} = $props();
</script>

Self-Contained Component Pattern

Prefer Component Composition Over Parent State Management

When building interactive components (especially with dialogs/modals), create self-contained components rather than managing state at the parent level.

The Anti-Pattern (Parent State Management)

<!-- Parent component -->
<script>
	let deletingItem = $state(null);
</script>

{#each items as item}
	<Button onclick={() => (deletingItem = item)}>Delete</Button>
{/each}

<AlertDialog open={!!deletingItem}>
	<!-- Single dialog for all items -->
</AlertDialog>

The Pattern (Self-Contained Components)

<!-- DeleteItemButton.svelte -->
<script lang="ts">
	import { createMutation } from '@tanstack/svelte-query';
	import { rpc } from '$lib/query';

	let { item }: { item: Item } = $props();
	let open = $state(false);

	const deleteItem = createMutation(() => rpc.items.delete.options);
</script>

<AlertDialog.Root bind:open>
	<AlertDialog.Trigger>
		<Button>Delete</Button>
	</AlertDialog.Trigger>
	<AlertDialog.Content>
		<Button onclick={() => deleteItem.mutate({ id: item.id })}>
			Confirm Delete
		</Button>
	</AlertDialog.Content>
</AlertDialog.Root>

<!-- Parent component -->
{#each items as item}
	<DeleteItemButton {item} />
{/each}

Why This Pattern Works

  • No parent state pollution: Parent doesn't need to track which item is being deleted
  • Better encapsulation: All delete logic lives in one place
  • Simpler mental model: Each row has its own delete button with its own dialog
  • No callbacks needed: Component handles everything internally
  • Scales better: Adding new actions doesn't complicate the parent

When to Apply This Pattern

  • Action buttons in table rows (delete, edit, etc.)
  • Confirmation dialogs for list items
  • Any repeating UI element that needs modal interactions
  • When you find yourself passing callbacks just to update parent state

The key insight: It's perfectly fine to instantiate multiple dialogs (one per row) rather than managing a single shared dialog with complex state. Modern frameworks handle this efficiently, and the code clarity is worth it.

Referential Stability for Reactive Data Sources

The Problem: New Array = Infinite Loop with TanStack Table

When feeding data from a reactive SvelteMap (or any signal-based store) into createSvelteTable, the get data() getter must return a referentially stable array. If it creates a new array on every access, TanStack Table's internal $derived enters an infinite loop:

1. $derived calls get data() → new array (Array.from().sort())
2. TanStack Table sees "data changed" → updates internal $state (row model)
3. $state mutation invalidates the $derived
4. $derived re-runs → get data() → new array again (always new!)
5. → infinite loop → page freeze

TanStack Query hid this problem because its cache returns the same reference until a refetch. SvelteMap getters that do Array.from(map.values()).sort() create a new array every call.

The Fix: Memoize with $derived

In .svelte.ts modules, use $derived to compute the sorted/filtered array once per SvelteMap change:

// ❌ BAD: New array on every access → infinite loop with TanStack Table
get sorted(): Recording[] {
    return Array.from(map.values()).sort(
        (a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(),
    );
}

// ✅ GOOD: $derived caches the result, stable reference between SvelteMap changes
const sorted = $derived(
    Array.from(map.values()).sort(
        (a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(),
    ),
);

// Expose via getter (returns cached $derived value)
get sorted(): Recording[] {
    return sorted;
}

When This Matters

The infinite loop only happens when the array is consumed by something that tracks reference identity in a reactive context:

  • createSvelteTable({ get data() { ... } })DANGEROUS (infinite loop)
  • $derived(someStore.sorted) where the result feeds back into state — DANGEROUS
  • {#each someStore.sorted as item} in a template — SAFE (Svelte's each block diffs by value, renders once per change)
  • $derived(someStore.get(id))SAFE (returns existing object reference from SvelteMap.get())

Rule of Thumb

If a .svelte.ts state module has a computed getter that returns an array/object, and that getter could be consumed by TanStack Table or a $derived chain that feeds into $state, always memoize with $derived. The cost is near-zero (one extra signal), and it prevents a class of bugs that's invisible in development until the page freezes.

Loading and Empty State Patterns

Never Use Plain Text for Loading States

Always use the Spinner component from @epicenter/ui/spinner instead of plain text like "Loading...". This applies to:

  • {#await} blocks gating on async readiness
  • {#if} / {:else} conditional loading
  • Button loading states

Full-Page Loading (Async Gate)

When gating UI on an async promise (e.g. whenReady, whenSynced), use Empty.* for both loading and error states. This keeps the structure symmetric:

<script lang="ts">
	import * as Empty from '@epicenter/ui/empty';
	import { Spinner } from '@epicenter/ui/spinner';
	import TriangleAlertIcon from '@lucide/svelte/icons/triangle-alert';
</script>

{#await someState.whenReady}
	<Empty.Root class="flex-1">
		<Empty.Media>
			<Spinner class="size-5 text-muted-foreground" />
		</Empty.Media>
		<Empty.Title>Loading tabs…</Empty.Title>
	</Empty.Root>
{:then _}
	<MainContent />
{:catch}
	<Empty.Root class="flex-1">
		<Empty.Media>
			<TriangleAlertIcon class="size-8 text-muted-foreground" />
		</Empty.Media>
		<Empty.Title>Failed to load</Empty.Title>
		<Empty.Description>Something went wrong. Try reloading.</Empty.Description>
	</Empty.Root>
{/await}

Inline Loading (Conditional)

When loading state is controlled by a boolean or null check:

<script lang="ts">
	import { Spinner } from '@epicenter/ui/spinner';
</script>

{#if data}
	<Content {data} />
{:else}
	<div class="flex h-full items-center justify-center">
		<Spinner class="size-5 text-muted-foreground" />
	</div>
{/if}

Button Loading State

Use Spinner inside the button, matching the AuthForm pattern:

<Button onclick={handleAction} disabled={isPending}>
	{#if isPending}<Spinner class="size-3.5" />{:else}Submit{/if}
</Button>

Empty State (No Data)

Use the Empty.* compound component for empty states (no results, no items):

<script lang="ts">
	import * as Empty from '@epicenter/ui/empty';
	import FolderOpenIcon from '@lucide/svelte/icons/folder-open';
</script>

<Empty.Root class="py-8">
	<Empty.Media>
		<FolderOpenIcon class="size-8 text-muted-foreground" />
	</Empty.Media>
	<Empty.Title>No items found</Empty.Title>
	<Empty.Description>Create an item to get started</Empty.Description>
</Empty.Root>

Key Rules

  • Never show plain text ("Loading...", "Loading tabs…") without a Spinner
  • Always include {:catch} on {#await} blocks — prevents infinite spinner on failure
  • Use text-muted-foreground for loading text and spinner color
  • Use size-5 for full-page spinners, size-3.5 for inline/button spinners
  • Match the Empty.* compound component pattern for both error and empty states

Prop-First Data Derivation

When a component receives a prop that already carries the information needed for a decision, derive from the prop. Never reach into global state for data the component already has.

<!-- BAD: Reading global state for info the prop already carries -->
<script lang="ts">
	import { viewState } from '$lib/state';
	let { note }: { note: Note } = $props();

	// viewState.isRecentlyDeletedView is redundant — note.deletedAt has the answer
	const showRestoreActions = $derived(viewState.isRecentlyDeletedView);
</script>

<!-- GOOD: Derive from the prop itself -->
<script lang="ts">
	let { note }: { note: Note } = $props();

	// The note knows its own state — no global state needed
	const isDeleted = $derived(note.deletedAt !== undefined);
</script>

Why This Matters

  • Self-describing: The component works correctly regardless of which view rendered it.
  • Fewer imports: Dropping a global state import reduces coupling.
  • Testable: Pass a note with deletedAt set and the component behaves correctly — no need to mock view state.

The Rule

If the data needed for a decision is already on a prop (directly or derivable), always derive from the prop. Global state is for information the component genuinely doesn't have.

Styling

For general CSS and Tailwind guidelines, see the styling skill.