Welcome. This test should take 2–3 hours for a capable developer. Read this document fully before starting.
A minimal marketing-style site using a page builder pattern. Content is authored in a shared Sanity project; your SvelteKit app fetches it at build time and renders it as a statically generated site. The pattern mirrors our production setup directly.
Stack: SvelteKit · Sanity CMS · TailwindCSS · Vercel
git clone https://github.com/droplab/developer-onboarding-sveltekit.git
cd developer-onboarding-sveltekit
pnpm install
Create a .env.local file in the project root. Never commit this file.
cp env.example .env.local
Fill in the values we've provided separately:
PUBLIC_SANITY_PROJECT_ID=your_project_id
PUBLIC_SANITY_DATASET=production
SANITY_API_READ_TOKEN=your_read_token
Note: The Sanity project is read-only. You cannot modify the schema or documents in the shared project — that's intentional. The bonus task (Task 4) involves a separate step if you choose to attempt it; see below.
pnpm dev
The app will be available at http://localhost:5173. At this point you'll see a mostly empty shell — that's your starting point.
src/
├── lib/
│ ├── sanity.ts # Sanity client — already configured
│ ├── stores/
│ │ └── theme.svelte.ts # Create this — global theme singleton (Task 4)
│ └── components/
│ ├── BlockRenderer.svelte # Your main task — see Task 2
│ ├── ThemeToggle.svelte # Create this — theme toggle button (Task 4)
│ ├── blocks/
│ │ ├── Hero.svelte # Stub — implement in Task 3
│ │ ├── TextBlock.svelte # Stub — implement in Task 3
│ │ └── ImageGrid.svelte # Stub — implement in Task 3
├── routes/
│ ├── +layout.svelte # Mount ThemeToggle, apply dark: classes, sync theme to DOM (Task 4)
│ └── [slug]/
│ ├── +page.server.ts # Your main task — see Task 1
│ └── +page.svelte # Wires BlockRenderer to the loaded data
File: src/routes/[slug]/+page.server.ts
Write a GROQ query that fetches the page document matching the current slug. Return the title and blocks array.
Requirements:
$lib/sanityhome — visiting /home should work once this task is completeThe Sanity document structure looks like this:
page {
_type: "page"
slug: { current: string }
title: string
blocks: array of block objects (see Task 3 for block shapes)
}
File: src/lib/components/BlockRenderer.svelte
Complete the component so it maps Sanity _type values to the correct Svelte component. The stub already imports the three block components — you need to wire up the dispatch logic.
Requirements:
hero → Hero.svelte, textBlock → TextBlock.svelte, imageGrid → ImageGrid.svelte_type values should fail gracefully — log a warning, render nothing, do not throwblock propEach component receives its full block object as a block prop. Style with TailwindCSS. All layouts must be responsive — we check at 375px, 768px, and 1280px viewport widths.
Hero.svelteBlock shape:
{
_type: "hero"
heading: string
subheading?: string
ctaLabel?: string
ctaUrl?: string
backgroundImage?: SanityImageAsset
}
Requirements:
heading and subheadingctaLabel and ctaUrl are present, render a styled anchor tagTextBlock.svelteBlock shape:
{
_type: "textBlock"
body: PortableText // Sanity Portable Text array
}
Requirements:
@portabletext/svelte — install it if not already presentImageGrid.svelteBlock shape:
{
_type: "imageGrid"
images: Array<{
asset: SanityImageAsset
alt?: string
}>
columns: 2 | 3 | 4
}
Requirements:
columns value from the block data — not hardcodedcolumns value@sanity/image-url) to generate image src URLs — it's already installedAdd a persistent light/dark theme to the site. This task tests your understanding of Svelte 5's module-level runes pattern — how $state and $derived work at module scope to create a true global singleton, how $effect syncs reactive state to the DOM, and how to handle browser APIs safely in a server-rendered environment.
Files to create:
src/lib/stores/theme.svelte.ts — the state singletonsrc/lib/components/ThemeToggle.svelte — the toggle button (a pure consumer of the singleton)Files to modify:
src/routes/+layout.svelte — mount ThemeToggle and apply theme-aware classes to the layout wrappertailwind.config.js — enable class-based dark modetheme.svelte.ts)The state must live at module scope — declared as bare $state and $derived calls at the top level of the file, outside any function. This is what makes it a true singleton: the module is evaluated once when first imported and the same reactive values are shared by every importer for the lifetime of the application.
The file extension must be .svelte.ts — runes are only valid inside .svelte and .svelte.ts files.
The structure to follow:
// src/lib/stores/theme.svelte.ts
// --- module-level $state ---
let current = $state<'light' | 'dark'>('light')
// --- module-level $derived ---
const isDark = $derived(current === 'dark')
const label = $derived(isDark ? 'Dark' : 'Light')
const icon = $derived(isDark ? '🌙' : '☀️')
const oppositeLabel = $derived(`Switch to ${isDark ? 'light' : 'dark'} mode`)
// Export a plain object with getters so reactive values are tracked
// correctly by Svelte's compiler at the call site
export const theme = {
get current() { return current },
get isDark() { return isDark },
get label() { return label },
get icon() { return icon },
get oppositeLabel() { return oppositeLabel },
toggle() { current = isDark ? 'light' : 'dark' },
set(value: 'light' | 'dark') { current = value },
}
Important: do not wrap
$state/$derivedin acreateTheme()factory function that gets called from the component — that creates a fresh instance per call site, not a singleton.
+layout.svelte)Use $effect inside +layout.svelte (not in the module) to handle the two browser-only side effects:
dark class to <html> — Tailwind's class-based dark mode requires document.documentElement.classList to be toggled whenever theme.isDark changeslocalStorage — write theme.current to localStorage on every change so the preference survives a page reloadAlso in onMount (or an $effect guarded for client-only execution), read the initial value from localStorage if present, otherwise fall back to the prefers-color-scheme media query. Call theme.set() with the resolved value.
SSR safety:
localStorage,document, andwindowdo not exist on the server. Any code that references them must only run in the browser. An unguarded reference will crash the server-side render. Think carefully about where and how you initialise.
ThemeToggle.svelte)ThemeToggle.svelte is a pure consumer — it imports theme and renders its values. It owns no state of its own.
Requirements:
<button> displaying theme.icon and theme.labelaria-label to theme.oppositeLabeltheme.toggle() on click$state, no $effect — just reads and a single event handlerIn tailwind.config.js, set:
darkMode: 'class'
Then use dark: variants on any elements you want to theme — the layout wrapper, block components, or both. At minimum the page background and primary text colour should respond to the theme.
$state and $derived are declared at module scope in theme.svelte.ts — not inside a functiontheme object uses getters for all reactive valuesThemeToggle.svelte contains no $state or $effect — all values come from the singletonclassList toggle + localStorage write) happens in $effect inside +layout.svelte, not in the modulelocalStorage / prefers-color-scheme and only runs in the browserwritable, readable, derived from svelte/store), no getContext / setContext — runes onlyOnly attempt this if you have time remaining.
You'll need your own Sanity account for this task, as you cannot modify the shared project schema.
testimonial schema type with these fields:{
_type: "testimonial"
quote: string // required
attribution: string // required
avatar?: SanityImage // optional
}
testimonial document to your dataset with real contentTestimonial.svelte and register it in BlockRenderer.env at your own project ID and datasetRequirements:
@sveltejs/adapter-vercel in static output modeBefore sending back:
/home route renders all three blocks from Sanity contentcolumns field valueSANITY_API_READ_TOKEN is not committed to the repolocalStorage or prefers-color-scheme on first visitisDark, label, icon, oppositeLabel) live in the singleton — not computed in the templateNOTES.md is included (see below)Send us:
[your-github-handle] as a collaborator)NOTES.mdInclude a brief NOTES.md in the root of your repo. It doesn't need to be long — a few bullet points covering:
We read these carefully. Clear thinking in writing is as important to us as clean code.
| Package | Purpose |
|---|---|
@sanity/client |
Sanity API client |
@sanity/image-url |
Image URL builder |
@portabletext/svelte |
Portable Text renderer |
tailwindcss |
Utility CSS |
@sveltejs/adapter-vercel |
Vercel deployment adapter |
These are the conventions we follow in production. We'll be looking for alignment with them in your submission.
Use Allman style for all blocks — opening braces on their own line, not inline.
function greet(name: string)
{
return `Hello, ${name}`
}
if (condition)
{
doSomething()
}
else
{
doSomethingElse()
}
This applies to functions, conditionals, loops, and class bodies. Avoid K&R / one-true-brace style.
TypeScript is there to catch mistakes, not to become an obstacle. Follow these guidelines:
unknown, any, or an ambiguous union that would actually cause a bug.string, number, boolean, and plain object shapes cover most cases. Reach for utility types (Partial, Pick, ReturnType, etc.) only when they genuinely reduce duplication.interface declaration is only worth it if the shape is reused in multiple places.The goal is code that reads clearly without a TypeScript PhD — if a type annotation is making the code harder to read, it's probably not needed.
Keep components focused on one job. If a component is doing two unrelated things, split it.
If you find yourself writing the same markup, logic, or style in more than one place, extract it:
$lib@apply class or a shared wrapper componentOne copy of the truth, referenced everywhere it's needed.
Using AI tools to help write code is fine — we use them too. What matters is what you do with the output.
Raw AI-generated code is a starting point, not a final answer. Before committing anything AI-assisted:
We're not looking for code that was written without help — we're looking for code that reflects clear thinking and good judgement, regardless of how it got there.
src/lib/sanity.ts before writing your GROQ query — the client is already initialised with the project ID and dataset from your .envprerender export and load functions in +page.server.ts are the right tools for Task 1 — docs here@sanity/image-url builder takes a client instance and an asset reference — the pattern is urlFor(image).width(800).url()grid-cols-{n} utilities work well for the image grid, but you'll need to drive the column count dynamically from the block data.svelte.ts file — $state and $derived are only reactive inside .svelte or .svelte.ts files, not plain .ts fileslocalStorage on first load is onMount or an $effect — both only run in the browser, never on the serverIf anything in this brief is genuinely unclear, reach out before spending time on the wrong interpretation. We'd rather answer a question than review a submission that went off-track.
Good luck!