Skip to main content

🧠 Evalium Front-End AI Assistant Onboarding Guide (MVP)

Your mission is to build the front-end for the Evalium MVP, a modern assessment platform. You will use SvelteKit and TypeScript, focusing on Svelte 5's rune-based reactivity.

You will create two core experiences: a clear, efficient interface for Admins to create and manage assessments, and a clean, accessible interface for Candidates to take them.

Guiding Principles

  • Simplicity and Clarity: Interfaces should be intuitive for non-technical SMB users.
  • Accessibility First: All components must be keyboard-navigable and screen-reader friendly.
  • Trust and Security: A clear separation between client and server logic is non-negotiable.

🛠️ Technical Stack

  • Framework: SvelteKit (using Svelte 5 Runes)
  • Language: TypeScript
  • API Contract: OpenAPI v3 (openapi.yaml)
  • API Client: Generated via openapi-typescript-codegen
  • Development Mocks: MSW (Mock Service Worker)
  • Styling: Plain, scoped CSS (no utility-class frameworks)
  • Testing: Vitest, Svelte Testing Library, vitest-axe for accessibility checks
  • Linting: ESLint with eslint-plugin-svelte and eslint-plugin-svelte-a11y

🏗️ Project Setup & Configuration

This project is nested inside a /frontend directory. This requires specific configuration to ensure module aliases and Vite's dev server work correctly.

1. Root tsconfig.json: A tsconfig.json file must exist at the repository root. Its purpose is to map paths for the editor and TypeScript language server.

{
"extends": "./frontend/.svelte-kit/tsconfig.json",
"compilerOptions": {
"module": "esnext",
"moduleResolution": "bundler",
"baseUrl": ".",
"paths": {
"$lib": ["frontend/src/lib"],
"$lib/*": ["frontend/src/lib/*"]
}
},
"include": ["frontend/src/**/*"],
"exclude": ["node_modules", "frontend/src/lib/paraglide"]
}

2. Vite Configuration (frontend/vite.config.ts):

The Vite dev server must be allowed to access files outside the frontend directory (specifically, the root node_modules).

import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';

export default defineConfig({
plugins: [sveltekit()],
server: {
fs: {
// Allow serving files from one level up to the project root
allow: ['..']
}
}
});

Project Folder Structure

Adhere to this structure. All shared components, server logic, and utilities go in src/lib.

└── src/
├── lib/
│ ├── components/
│ │ ├── shared/
│ │ └── evaluations/
│ ├── server/
│ └── utils/
├── mocks/
│ ├── browser.ts
│ └── handlers.ts
├── routes/
│ ├── (public)/
│ ├── (app)/
│ └── take/[sessionId]/
├── app.d.ts
├── app.html
└── hooks.server.ts

✅ Core Development Patterns (How to Build)

These are the required patterns for building the application.

  1. Reactivity with Runes

All reactive state must be declared using $state. All computed values must use $derived.

Important: A $derived variable holds a signal (a function-like object). To get its value in your <script> logic, you must call it as a function. Svelte automatically handles this for you in the template.

<script lang="ts">
let count = $state(0);
let doubled = $derived(count * 2);
</script>
<!-- In the template, it's automatic -->
<p>Doubled is {doubled}</p>
  1. Component Data Flow
  • Data is passed from parent to child via $props.
  • HTML content is passed into components using the children snippet.
<script lang="ts">
let { title, children } = $props();
</script>

<h2>{title}</h2>
<div class="content">
{@render children()}
</div>
  • Typing Snippet Parameters: When a snippet receives data, its parameters must be explicitly typed to ensure type safety.
{#snippet row(question: Question)}
<td>{question.stem}</td>
{/snippet}
  1. Data Loading

All data fetched from the server for a page must be loaded in a +page.server.ts file using a load function. Do not use fetch directly inside .svelte components for server data.

Crucially, the response from any fetch call must be strongly typed. Do not allow the default any type from response.json() to be returned from the load function, as this breaks type safety in your components.

Example: Typing an API Response

// src/routes/evaluations/+page.server.ts
import type { PageServerLoad } from './$types';
import type { Evaluation } from '$lib/api'; // Import the type from the generated client

// Define the shape of the expected API response
interface EvaluationsApiResponse {
meta: { total: number; page: number; };
data: Evaluation[];
}

export const load: PageServerLoad = async ({ fetch }) => {
const response = await fetch('/evaluations');

// Assert the type of the JSON data
const responseData = await response.json() as EvaluationsApiResponse;

// Now, TypeScript knows the exact shape of `responseData.data`
return {
evaluations: responseData.data ?? []
};
};
  1. Form Submissions and Validation

All data submissions must use SvelteKit actions and be progressively enhanced with use:enhance. A two-layer validation approach is required.

a. Client-Side Validation for UX: Provide immediate feedback by validating on the client. Use $derived state to check for errors and disable the submit button until the form is valid. This prevents unnecessary server requests.

<script lang="ts">
let editable = $state({ stem: '', choices: [] });

let errors = $derived({
stem: !editable.stem.trim() ? 'Stem is required.' : '',
choices: editable.choices.some(c => !c.trim()) ? 'All choices are required.' : ''
});

let isInvalid = $derived(!!errors.stem || !!errors.choices);
</script>

<Button type="submit" disabled={isInvalid}>Save</Button>

b. Server-Side Validation for Integrity: Actions in +page.server.ts must re-validate all data. If validation fails, use fail() from @sveltejs/kit to return a 4xx status with the submitted data and an errors object.

// +page.server.ts
import { fail } from '@sveltejs/kit';

export const actions = {
save: async ({ request }) => {
// ... get form data
if (!data.stem) {
return fail(422, { data, errors: { stem: 'Stem is required.' } });
}
// ... save logic
return { success: true, savedData: data };
}
};

c. Displaying Accessible Errors: Child components must accept the form prop to display server errors. Use aria-invalid and aria-describedby to link fields to their error messages.

<script lang="ts">
let { form } = $props<{
form?: { errors?: { stem?: string }; data?: Question };
}>();
</script>

<textarea aria-invalid={!!form?.errors?.stem} aria-describedby="stem-error" />
{#if form?.errors?.stem}
<span id="stem-error" class="error">{form.errors.stem}</span>
{/if}
  1. Security
  • All database logic, authentication helpers, or any code that uses private environment variables must be placed in the src/lib/server/ directory.
  • Candidate access to evaluations must be handled via secure, one-time-use JWTs passed in the URL (e.g., /take/[sessionId]). Candidates do not have user accounts.
  1. Accessibility

All components must be accessible.

  • Use semantic HTML (<button>, <nav>, <main>).
  • Ensure all interactive elements are keyboard accessible.
  • Adhere to the accessibility rules provided by the eslint-plugin-svelte-a11y linter.
  • Write component tests that include an automated accessibility check with axe.
// Example in a test file
import { axe } from 'vitest-axe';

test('should have no accessibility violations', async () => {
const { container } = render(MyComponent);
expect(await axe(container)).toHaveNoViolations();
});
  1. Internationalization (i18n) for UI Text

All user-facing text in the application UI (buttons, labels, titles, etc.) must be implemented using our i18n library, ParaglideJS. Do not hardcode English strings directly in the components.

Message definitions are located in JSON files within the src/messages directory (e.g., en.json). You will import and use the type-safe message functions that Paraglide automatically generates.

Example Usage:

<script lang="ts">
// Import the generated, type-safe message functions
import * as m from '$lib/paraglide/messages';

let { data } = $props(); // Assuming data.user.name is available
</script>

<h1>{m.dashboard_title()}</h1>

<p>{m.welcome_user({ name: data.user.name })}</p>

<button>{m.create_evaluation_button()}</button>

Note on Routing: The application uses a [[lang]] parameter in the URL to determine the language. Paraglide's setup will handle this automatically.

  1. Branding & Theming All component styling must use CSS custom properties for branding. A central theme configuration will set these variables, allowing components to inherit them.
/* Example in a component's <style> block */
.button-primary {
background-color: var(--brand-primary, #0d6efd); /* Uses brand color with a fallback */
}
  1. Namespacing Mock API Routes

All MSW handlers in src/mocks/handlers.ts must be namespaced with an /api/ prefix.

This is critical to prevent your mock server from conflicting with SvelteKit's file-based router. A handler for /questions would accidentally intercept the request for the /questions page itself, causing the browser to receive JSON instead of JavaScript. Namespacing ensures MSW only responds to intentional API calls.

Example:

// In src/mocks/handlers.ts

// INCORRECT - This is too broad and will conflict with the page route
http.get('*/questions', () => { /* ... */ }),

// CORRECT - This is specific and will not conflict
http.get('*/api/questions', () => { /* ... */ }),

Correspondingly, all fetch calls in your load functions must also use the /api/ prefix to match the mock handler.

// In a .server.ts or .ts load function
await fetch('/api/questions');
  1. Mocking Server-Side load Functions

SvelteKit's load functions can run on the server (for initial page loads/refreshes) and in the browser (for client-side navigation). MSW's default setup only intercepts browser requests, which will cause a 404 error when a page is refreshed because the server-side fetch is not mocked.

The best practice is to run a Node.js-compatible version of MSW during development. The most reliable method is to create a custom Vite plugin that starts the mock server alongside the Vite dev server.

Example vite.config.ts:

import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';

/** @type {import('vite').Plugin} */
const mswPlugin = {
name: 'msw-plugin',
async configureServer(server) {
if (process.env.NODE_ENV === 'development') {
const { startMockServer } = await import('./src/mocks/node.ts');
startMockServer();
}
}
};

export default defineConfig({
plugins: [mswPlugin, sveltekit()]
});

Example src/mocks/node.ts:

import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);

// Export a function to be called by the Vite plugin.
export function startMockServer() {
server.listen({ onUnhandledRequest: 'bypass' });
console.log('🔶 Mock server for Node.js is running');
}
  1. Handling Form Success with Optimistic UI (MSW)

When using a stateless mock server (MSW), the default use:enhance behavior of refetching data will not show UI updates. To provide an "optimistic" and instantaneous UI update during development, a parent-child communication pattern is required.

a. Server Action Must Return Data: A successful server action (e.g., saveQuestion) must return the created or updated data object in its response.

// in +page.server.ts
export const actions: Actions = {
saveQuestion: async ({ request }) => {
// ... validation ...
const savedQuestion = await api.save(question);
return { success: true, question: savedQuestion };
}
}

b. Child Component Emits Success: The component containing the form (QuestionEditor.svelte) should accept an onsuccess callback prop. The use:enhance function will call this callback with the data from the successful server action.

<!-- QuestionEditor.svelte -->
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionResult } from '@sveltejs/kit';

let { onsuccess, onclose, ... } = $props<{
onsuccess?: (savedQuestion: Question) => void;
// ... other props
}>();

const handleEnhance = () => {
return async ({ result }: { result: ActionResult }) => {
if (result.type === 'success' && result.data?.question) {
if (onsuccess) {
onsuccess(result.data.question);
}
onclose();
}
};
};
</script>

<form use:enhance={handleEnhance}>
<!-- ... -->
</form>

c. Parent Page Handles UI Update: The parent page (questions/+page.svelte) provides the onsuccess callback. This function is responsible for updating the local $state array with the new data, creating the optimistic UI update.

<!-- questions/+page.svelte -->
<script lang="ts">
let questions = $state(data.questions);

function handleSaveSuccess(savedQuestion: Question) {
const index = questions.findIndex(q => q.id === savedQuestion.id);
if (index !== -1) {
questions[index] = savedQuestion; // Update
} else {
questions.unshift(savedQuestion); // Add new
}
}
</script>

<!-- ... -->
<QuestionEditor {question} {form} onsuccess={handleSaveSuccess} />
<!-- ... -->

🚫 What to Avoid

  • Do not use legacy Svelte patterns. This includes $:, export let for props, on:, slots, and svelte/store for state management. Use the modern patterns described above.
    • For example, instead of $: myValue = ..., you must use const myValue = $derived(...).
  • Do not fetch server data directly from components. All server data must go through load functions.
  • Do not create inaccessible markup. Pay close attention to linter warnings for missing labels, alt attributes, and incorrect ARIA roles.
  • bind:group requires an Array: The bind:group directive for checkboxes must be bound to an Array, not a Set. Using a Set will cause a runtime error.