Web Prototype Skill
Scaffold and build clean, user-testing-ready web app prototypes using SvelteKit as a SPA (no SSR), TypeScript 6+, and the Norwegian Designsystemet component library.
Technology Stack
| Layer | Choice | Version |
|---|---|---|
| Framework | SvelteKit | 2.x (latest) |
| Language | TypeScript | 6.x (latest) |
| UI Components | @digdir/designsystemet-web | 1.x (latest) |
| CSS/Tokens | @digdir/designsystemet-css | 1.x (latest) |
| Font | Nunito Sans (Google Fonts) | — |
| Build | Vite | bundled with SvelteKit |
| Adapter (local) | @sveltejs/adapter-auto | bundled |
| Adapter (deploy) | @sveltejs/adapter-static | separate install |
Package discipline: Only install packages if strictly necessary. Prefer the packages above. Never install newly-released or obscure packages — require at minimum several months of community usage and active maintenance history.
Project Structure
my-app/
├── src/
│ ├── app.html # HTML shell — load Google Fonts here
│ ├── app.css # Global styles, Designsystemet CSS import
│ ├── routes/
│ │ ├── +layout.ts # export const ssr = false; export const prerender = true (if static)
│ │ ├── +layout.svelte # App shell, nav, global imports
│ │ └── +page.svelte # Home page
│ └── lib/ # Shared components and utilities
├── static/ # Static assets
├── vite.config.ts # SvelteKit config lives here (adapter, compilerOptions); sv no longer writes svelte.config.js
├── tsconfig.json
└── package.json
Scaffolding Steps
1. Generate the project
Empty directory (a standalone prototype):
npx sv create my-app
# Choose: SvelteKit minimal, TypeScript, no add-ons
cd my-app
npm install
Existing project directory — the normal case after /faseflyt:nytt-prosjekt,
where kunnskap/, CLAUDE.md, .claude/ and .gitignore already exist:
npx sv@latest create --template minimal --types ts --no-add-ons --no-dir-check --install npm .
--no-dir-check skips the "Directory not empty. Continue?" prompt, which is
what makes the command runnable from Claude Code. It also means the overwrite
below happens silently. sv create overwrites every file it also generates,
and it does not merge. In a faseflyt project the collision is .gitignore: the
project's own rules (comments, dist/, .claude/settings.local.json, data
folders) are replaced by the SvelteKit template. Therefore: before
sv create, read .gitignore and keep its contents. After, read the new
file and append every line from the old one that is missing, under
# Project rules (restored after sv create). Show the user before/after. The
SvelteKit entries stay (the vite.config.*.timestamp-* lines are new and
wanted); the project's rules are added back, never swapped in. Observed
2026-09-04 with exactly this command in a faseflyt project: the committed
.gitignore lost its comments and its dist/ line, and was restored by hand.
Confirmed in the sv 0.17.0 source: the template .gitignore is copied straight
over the existing file, and even with the prompt on, a directory holding only
.git and .gitignore counts as empty.
2. Install Designsystemet packages
npm install @digdir/designsystemet-web @digdir/designsystemet-css @digdir/designsystemet-types
3. Disable SSR (SPA mode)
Create src/routes/+layout.ts:
// src/routes/+layout.ts
export const ssr = false;
4. Configure adapter
sv create (0.17.0, measured 2026-09-04) no longer writes svelte.config.js.
The SvelteKit config is passed to sveltekit() in vite.config.ts, generated
like this:
// vite.config.ts — as generated by sv
import adapter from '@sveltejs/adapter-auto';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [
sveltekit({
compilerOptions: {
runes: ({ filename }) =>
filename.split(/[/\\]/).includes('node_modules') ? undefined : true
},
adapter: adapter()
})
]
});
Leave it as is for local development. For a static deploy, install
@sveltejs/adapter-static and change only the import and the adapter line in
this same file:
import adapter from '@sveltejs/adapter-static';
// ...
adapter: adapter({ fallback: 'index.html' })
Older projects: if a svelte.config.js exists (created with an earlier
sv), the adapter lives there. Edit that file; do not create a second config.
5. Set up fonts in src/app.html
Only Google Fonts is allowed as external CDN. Load Nunito Sans here:
<!doctype html>
<html lang="nb">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- Google Fonts: Nunito Sans only -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Nunito+Sans:ital,opsz,wght@0,6..12,300..900;1,6..12,300..900&display=swap"
rel="stylesheet"
/>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
6. Global CSS (src/app.css)
/* Set Nunito Sans as the global font */
:root {
--ds-font-family: 'Nunito Sans', system-ui, sans-serif;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
font-family: var(--ds-font-family);
margin: 0;
padding: 0;
}
7. Layout (src/routes/+layout.svelte)
<script lang="ts">
// Import Designsystemet web components (registers custom elements)
import '@digdir/designsystemet-web';
// Import Designsystemet CSS (tokens and base styles) and make it hot reload
import "@digdir/designsystemet-css";
import "@digdir/designsystemet-css/theme"; // default theme; step 9 swaps this for a generated one
import '../app.css';
// sv ships the favicon as src/lib/assets/favicon.svg and links it from the
// layout, not from app.html — keep that, or the tab shows a broken icon.
import favicon from '$lib/assets/favicon.svg';
const { children } = $props();
</script>
<svelte:head>
<link rel="icon" href={favicon} />
</svelte:head>
{@render children()}
8. TypeScript config
Ensure tsconfig.json targets modern TypeScript 6 features:
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"strict": true,
"moduleResolution": "bundler",
"target": "ES2022",
"verbatimModuleSyntax": true,
"isolatedModules": true,
"types": ["@digdir/designsystemet-web"]
}
}
9. Brand colors (only when the default Designsystemet look is not acceptable)
The default theme is fine for most prototypes. When the prototype must carry
the organisation's own colors, do not override --ds-color-* variables:
each color is a scale of 16 hardcoded values with no base-color variable, so a
single override changes one shade and leaves the other fifteen (hover, active,
borders, text) untouched (measured 2026-08-30). Generate
a theme with Designsystemet's own tooling instead:
-
Add
designsystemet.config.jsonin the project root. The CLI flags for colors are deprecated; the config file is the supported way:{ "$schema": "./node_modules/@digdir/designsystemet/dist/config.schema.json", "outDir": "./design-tokens", "clean": true, "themes": { "theme": { "colors": { "accent": "#1E98F5", // the brand color; scale is generated from it "neutral": "#1E2B3C" }, "borderRadius": 4 } } }Ask the user for the hex values; never invent brand colors. Additional keys under
colorsbecome additionaldata-colorvalues. The theme builder at https://theme.designsystemet.no/ produces a valid config if the user prefers a visual tool. -
Generate tokens and build the CSS (both are idempotent;
cleankeeps the output directories tidy):npx @digdir/designsystemet tokens create npx @digdir/designsystemet tokens buildOutput:
design-tokens-build/theme.css(plus.d.tsfiles). Measured 2026-09-05 with@digdir/designsystemet1.21.0. -
In
src/routes/+layout.svelte, replace the default theme import with the built one. Keep the base CSS import as is:import "@digdir/designsystemet-css"; // import "@digdir/designsystemet-css/theme"; <- default theme, replaced by: import '../../design-tokens-build/theme.css'; -
Commit
designsystemet.config.json,design-tokens/anddesign-tokens-build/. The generated files are build artifacts: never edit them by hand, re-run both commands after upgrading@digdir/*packages.
CSS Discipline — Non-Negotiables
Before writing ANY HTML element or CSS rule: look up the DS reference files. If a DS class or component exists for it, you MUST use it — no exceptions.
Mandatory DS class lookup
ALWAYS see - references/components.md — DS classes for usage of semantic elements, custom html elements, and common patterns
Web components vs CSS-only — exhaustive whitelist
@digdir/designsystemet-web registers exactly these custom elements:
| Custom element | Also needs CSS class? |
|---|---|
<ds-breadcrumbs> | yes — class="ds-breadcrumbs" |
<ds-error-summary> | yes — class="ds-error-summary" |
<ds-field> | yes — class="ds-field" |
<ds-pagination> | yes — class="ds-pagination" |
<ds-suggestion> | no |
<ds-tabs>, <ds-tablist>, <ds-tab>, <ds-tabpanel> | <ds-tabs> needs class="ds-tabs" |
Everything else is CSS-only. If the component name is not in the table above, you MUST use a standard HTML element with the DS class — never invent a <ds-X> custom element for it. Examples:
<!-- CORRECT -->
<div class="ds-alert" data-color="info" role="alert">...</div>
<button class="ds-button">...</button>
<div class="ds-card">...</div>
<!-- WRONG — these custom elements do not exist -->
<ds-alert class="ds-alert">...</ds-alert>
<ds-button class="ds-button">...</ds-button>
<ds-card class="ds-card">...</ds-card>
What you must NEVER do
- No naked semantic elements — never write
<h2>,<p>,<label>,<a>,<button>, or a card<div>without the corresponding DS class from the table above. - No custom color variables — never define
--color-*or--brand-*in:root, and never override--ds-color-*either. Use DS tokens; brand colors go through step 9 (a generated theme), not through CSS. - No hardcoded hex/rgb colors — no
#3b82f6,rgba(0,0,0,0.5), etc. Use DS color tokens instead. - No
font-familydeclarations in component styles — it's already inherited from:rootvia--ds-font-family. Every repeated declaration is noise. - No custom
font-sizefor text — usedata-sizeon.ds-heading/.ds-paragraphinstead.data-sizeis not universal: it works on.ds-heading,.ds-paragraph,.ds-avatarand.ds-spinner, and on a container to set the inherited size mode. Anywhere else it does nothing. - No custom button, card, alert, tag, or tab styling — use the DS components.
- Never guess an attribute name — Designsystemet fails silently. An attribute the class does not know is ignored, the element renders unstyled, and
svelte-checkreports nothing. Two measured cases:data-sizeoutside the four classes above, anddata-variant="primary"on.ds-button, where primary is the base style and has no attribute at all. Checkreferences/components.mdfirst — it documents the attributes per class — and the class's own CSS innode_modules/@digdir/designsystemet-css/dist/srcwhen the reference does not answer or seems to contradict what you observe.
Custom <style> blocks are for layout only
The only CSS you should write in a component's <style> block:
display: flex/gridand related propertiesmax-width,width,heightpadding,margin— prefervar(--ds-size-N)tokens over hardcodedpxvaluesposition,z-indexfor sticky or overlapping elementsoverflow,text-decoration: none(for link-as-card patterns)
If you're about to write background:, color:, border-color:, font-size:, or font-family: in a custom style — stop and use a DS class or token instead.
Svelte 5 runes in TypeScript
Use Svelte 5 runes syntax — the current standard:
// Reactive state
let count = $state(0);
let doubled = $derived(count * 2);
// Effects
$effect(() => {
console.log('count changed:', count);
});
Prototype Design Principles
These principles keep prototypes lean and user-testing-ready:
- DS-first, always — reach for a DS class or designsystemet attribute before writing any CSS. If a DS component exists for what you need, use it — no exceptions. Custom styles are a last resort, only for layout.
- Real interactions — wire up buttons, forms, and navigation so the tester can actually click through
- Fake data is fine — hardcode realistic sample data; avoid real APIs unless required
- Accessible by default — Designsystemet components are WCAG-compliant; use semantic HTML
- One route per screen — use SvelteKit file-based routing to separate screens naturally
- Shared state — use shared reactive state in
.svelte.tsfiles with$statefor cross-page state - Norwegian language — all UI text, labels, headings, and placeholder content must be written in Norwegian Bokmål (nb). Never use English as the display language.
- Not for production — every prototype must display a clearly visible disclaimer, e.g. a banner at the top of the page: "Dette er en prototype laget av Claude og kan ikke brukes i produksjon." Use
<div class="ds-alert" data-color="info" role="alert">for this (ds-alertis CSS-only — never<ds-alert>). - Responsive out of the box — Designsystemet components and typography scale to narrow screens by themselves. A «mobil/finpuss» phase means the user opens the preview in a narrow window and looks; it does not mean writing media queries or installing test tooling. Never install browsers or screenshot tools (
playwright,puppeteer,chromium) to check layout yourself — verification is the user's, and on a managed PC a browser download to the user profile is exactly what endpoint security flags.
Routing Example (Multi-screen Prototype)
src/routes/
├── +layout.ts # ssr = false
├── +layout.svelte # nav + and shared layout
├── +page.svelte # home screen "/"
├── step-1/
│ └── +page.svelte # "/step-1"
├── step-2/
│ └── +page.svelte # "/step-2"
└── confirmation/
└── +page.svelte # "/confirmation"
Navigation between screens:
Prefer a link — navigation is a link, not a button:
<a href="/step-2" class="ds-button">Next</a>
Use .ds-link instead of .ds-button when it should read as text rather than
as an action.
If the navigation must be programmatic, use SvelteKit's goto:
<script lang="ts">
import { goto } from '$app/navigation';
</script>
<button class="ds-button" type="button" onclick={() => goto('/step-2')}>Next</button>
Finishing Up
After scaffolding, always:
- Run
npm run devto confirm the dev server starts - Run
npm auditand report results - Tell the user how to run it locally:
npm run dev - Run the security review checklist below against the actual codebase
- Summarize the security review findings
- Offer to add more screens, tweak components, or add state management
Security Review Checklist
Run this checklist after scaffolding and report results to the user.
What to check
- No external scripts or stylesheets except Google Fonts (fonts.googleapis.com, fonts.gstatic.com)
- All npm packages are well-established (check
npm info <pkg>— look for age, weekly downloads, maintainer reputation) - No
eval()orinnerHTMLwith user-controlled input in any.svelteor.tsfile - No hardcoded secrets (API keys, passwords) — prototypes should use
.envfiles withPUBLIC_prefix for anything non-secret - CSP-friendly — no inline scripts outside of Svelte-compiled output
- No
dangerouslySetInnerHTMLequivalent — Svelte's{@html}should only be used with trusted, sanitized content - Dependencies have no known CVEs — run
npm auditand fix or explain any findings - Supply-chain hygiene — avoid packages < 3 months old, prefer packages with >10k weekly downloads
How to run
npm audit
# If issues found:
npm audit fix
Read the codebase and check for security issues. After review, tell the user:
✅ Security review complete. This prototype [passes / has the following notes]: ... It is safe to run locally / share with user testers.