| name | frontend-guide |
| description | Read before writing or reviewing any frontend code in lightly_studio_view - Svelte, TypeScript, or SvelteKit files. Covers component structure and naming, stores vs runes, absolute vs relative imports, Shadcn and Tailwind usage, Svelte 5 syntax, TanStack Query hooks, bundle size limits, Storybook stories, and vitest conventions. |
Frontend coding guidelines
Coding standards for frontend development in LightlyStudio using SvelteKit and TypeScript.
Key Principles
- Write concise, technical TypeScript code. Use Svelte-specific features (like runes) only when necessary within Svelte-compiled components.
- Keep components under 100 lines. Split code into logical, testable parts.
- Embrace TDD. Use vitest for unit and integration tests.
- Follow Svelte and SvelteKit official documentation.
Framework-agnostic approach
Minimize framework-specific syntax to reduce coupling and improve testability:
- Prefer writable/readable/derived stores over runes for state management - stores have explicit imports and clearer dependencies.
- Use runes only when their specific features are necessary for component-level reactivity.
- Less framework coupling means easier maintenance, onboarding, and migration.
Project structure & naming
- PascalCase for component names and their folders (e.g.,
AuthForm/AuthForm.svelte).
- camelCase for non-component files, variables, functions, and props (e.g.,
useAuth.ts, const myVar).
- Every component and hook lives in its own folder scoping related files together.
Canonical layout:
src/
components/
AuthForm/
AuthForm.svelte
AuthForm.test.ts
AuthForm.stories.svelte # if needed
UserDashboard/
UserDashboard.svelte
UserDashboard.test.ts
UserDashboard.helpers.ts # if needed
UserProfile/ # subcomponent
UserProfile.svelte
UserProfile.test.ts
lib/
hooks/
index.ts # barrel exports
useAuth/
useAuth.ts
useAuth.test.ts
useData/
useData.ts
useData.test.ts
- Use
.svelte.ts files for component logic, state machines, and hooks that use TanStack Query (since v6 uses runes internally).
- Use barrel exports (
index.ts) to define a module's public API. Import from module level, not deep paths:
import { useData, useAuth } from "$lib/hooks";
Imports
- Absolute imports for shared modules:
from "$lib/hooks", from "$lib/components/ui/button".
- Relative imports only within the same module folder:
from "../UserDashboard.helpers".
TypeScript
- Use TypeScript for all code.
- Define
interface for component props, function parameters, and return types:
interface UseDataParams {
title: string;
onClick: () => void;
}
interface UseDataReturn {
data: string;
isLoading: boolean;
}
export function useData(params: UseDataParams): UseDataReturn {
return { data: params.title, isLoading: false };
}
- Avoid exporting/importing types. Derive types from source code using utility types to keep things DRY:
type UseDataParams = Parameters<typeof useData>[0];
type UseDataReturn = ReturnType<typeof useData>;
type TitleOnly = Pick<UseDataParams, "title">;
type WithoutTitle = Omit<UseDataParams, "title">;
UI and Styling
- Shadcn components from
$lib/components/ui for standard UI elements (buttons, inputs, cards, tabs, alerts, tables).
- Project-specific components from
$lib/components when combining multiple Shadcn components, adding business logic, or needing custom behavior.
- Bits-UI as the base component library underlying Shadcn.
- Lucide Icons for all icons - import from
@lucide/svelte.
- Use
cn() from $lib/utils for conditional Tailwind class composition.
- Prefer explicit props over object spreading - components should receive only the specific props they need. Exception: forwarding HTML attributes via
...rest, or intentional wrapper/proxy components.
Performance
Keep JS chunk sizes below 500KB. Use dynamic imports for heavy components:
<script lang="ts">
import { onMount } from 'svelte';
let HeavyChart;
onMount(async () => {
const module = await import('$lib/components/HeavyChart.svelte');
HeavyChart = module.default;
});
</script>
{#if HeavyChart}
<svelte:component this={HeavyChart} />
{/if}
When reviewing bundle size:
- Check build output for chunk-size warnings.
- Identify heavy dependencies before adding them to eagerly loaded routes.
- Prefer lazy loading, vendor splitting, or dependency deduplication when a chunk exceeds the limit.
Svelte 5 syntax
Use Svelte 5 patterns in all new code:
Props - use $props() with a typed interface:
<script lang="ts">
interface Props {
value: string;
placeholder?: string;
onSearch: (query: string) => void;
disabled?: boolean;
}
let {
value,
placeholder = 'Enter text',
onSearch,
disabled = false
}: Props = $props();
</script>
Event handlers - use onclick, onchange, etc. (not on:click, on:change).
Reactive declarations - use $derived or derived() stores, not $:. Do not mix the two approaches - pick one per file/module.
Page state - use $app/state, not $app/stores:
import { page } from "$app/state";
page.params.sampleId;
Hooks and reactivity - for hooks that wrap TanStack Query, accept a getter function (thunk) for reactive parameters. TanStack Query v6 uses thunks for reactivity — do not pass Svelte stores or $derived values:
export const useFrames = (
getParams: () => { video_frame_collection_id: string; filter: VideoFrameFilter }
) => {
const query = createInfiniteQuery(() => {
const { video_frame_collection_id, filter } = getParams();
return {
...getAllFramesInfiniteOptions({
path: { video_frame_collection_id },
body: { filter }
}),
getNextPageParam: (lastPage) => lastPage.nextCursor || undefined
};
});
return { query };
};
const { query } = useFrames(() => ({
video_frame_collection_id: collectionId,
filter: currentFilter
}));
For non-TanStack hooks with static parameters, pass values via SvelteKit's page load function or direct props.
State management & hooks
Create small, reusable hooks in src/lib/hooks - avoid monolithic stores. We do not use a services folder; hooks handle data fetching and state.
Ref: lightly_studio_view/src/lib/hooks/useTags/useTags.ts, lightly_studio_view/src/lib/hooks/useFeatureFlags/useFeatureFlags.ts.
Generic hooks go in src/lib/hooks; component-specific hooks go in the component's folder.
For data fetching and API work:
- Use TanStack Query for all data fetching. TanStack Query v6 is runes-based — hooks that call
createQuery/createInfiniteQuery must be .svelte.ts files.
- The query result is a reactive proxy (not a Svelte store). Access properties like
query.isSuccess, query.data directly — no $ prefix needed.
- Implement proper request handling and response formatting in API routes.
Store-based hook example (playground):
import { writable } from 'svelte/store';
const count = writable<number>(0);
export function useCounter() {
function increment() {
count.update((c) => c + 1);
}
function resetCount() {
count.set(0);
}
return { count, increment, resetCount };
}
<script lang="ts">
import { useCounter } from './useCounter';
const { count, increment } = useCounter();
</script>
<button onclick={() => increment()}>Count: {$count}</button>
Avoiding props drilling
Prefer these solutions in order:
- Svelte Context API (
setContext/getContext) - for state shared within a component subtree.
- Svelte stores - for truly global state (auth, preferences) shared across unrelated trees.
$app/state - for server-loaded page data accessible by any component on the page.
- Svelte 5 Snippets (
Snippet type) - for UI composition / layout customization without passing content as props.
Routing and Pages
- Use SvelteKit's file-based routing in
src/routes/.
- Use dynamic routes with slug syntax. E.g. sample details at
lightly_studio_view/src/routes/datasets/[dataset_id]/[collection_type]/[collection_id]/images/[sampleId]/.
- Use
+layout.svelte for shared layouts. E.g. the collection layout at lightly_studio_view/src/routes/datasets/[dataset_id]/[collection_type]/[collection_id]/+layout.svelte.
Storybook
Use simplified story syntax - no explicit {#snippet children()} for text content:
<Story name="H1" args={{ variant: 'h1' }}>
Heading 1 - Large Page Title
</Story>
Testing
Testing levels: Unit tests for isolated components/functions, integration tests for component interactions, end-to-end tests for full application flows.
Unit test example:
import { render, screen } from "@testing-library/svelte";
import MyComponent from "./MyComponent.svelte";
describe("MyComponent", () => {
it("renders the title", () => {
render(MyComponent, { props: { title: "Hello World" } });
expect(screen.getByText("Hello World")).toBeInTheDocument();
});
});
Test optimization rules
- Use
defaultProps helper objects to avoid repeating prop definitions across tests. Override individual props with spread: { ...defaultProps, isUploading: true }.
- No mirror tests for simple boolean toggles - testing the truthy case is sufficient when a single boolean controls the state.
- Combine related assertions into one test rather than creating separate tests for closely related checks (e.g., placeholder + accessible label).
- Test behavior, not implementation - don't assert on CSS classes, internal structure, or how hooks are called. Assert on user-visible outcomes.
- Remove duplicate tests that verify the same behavior with different queries.
Running tests
Before submitting code:
make static-checks
npm run test:unit
All checks must pass before committing.