| Layer | Tool | Version |
|---|---|---|
| Framework | SvelteKit | ^2.0 |
| Language | Svelte + TypeScript | ^5.0 |
| Styling | Tailwind CSS | ^4.0 |
| UI Components | Skeleton UI | ^4.0 |
| Charts | D3.js | ^7.0 |
| Package manager | pnpm | — |
pnpm dev # start dev server (http://localhost:5173)
pnpm build # production build
pnpm preview # preview production build
pnpm check # TypeScript + Svelte diagnosticssrc/
app.html # sets data-theme / data-mode via inline script
app.css # Tailwind + Skeleton + theme imports
lib/
data/
mock.ts # all mock data + shared TypeScript interfaces
components/
charts/
LineChart.svelte # D3 time-series line chart
BarChart.svelte # D3 categorical bar chart
KpiCard.svelte # metric card with delta indicator
LightSwitch.svelte # dark/light toggle (persists to localStorage)
ThemePicker.svelte # theme <select> (persists to localStorage)
themes/
lucid.css # custom Lucid theme (primary theme for this project)
routes/
+layout.svelte # imports app.css
+page.svelte # main dashboard page
Always use runes. Never use the Options API.
| Old (forbidden) | New (required) |
|---|---|
export let x |
let { x } = $props() |
let x = 0 (reactive) |
let x = $state(0) |
$: y = x * 2 |
const y = $derived(x * 2) |
onMount(() => ...) |
$effect(() => ...) |
<slot /> |
{@render children()} |
bind:this targets must be typed and declared as $state:
let el = $state<SVGGElement | undefined>(undefined);Every design element must use Skeleton presets and tokens as the first choice. Only reach for raw Tailwind utilities when no Skeleton equivalent exists (e.g. flex, gap-4, w-full). Never use Tailwind color utilities (bg-gray-*, text-indigo-*, border-gray-*) — always use Skeleton tokens.
Colors
<!-- correct -->
<div class="bg-surface-100-900 text-primary-500 border-surface-300-700">
<!-- forbidden -->
<div class="bg-gray-100 text-indigo-500 border-gray-300">The -100-900 suffix pattern renders 100 in light mode and 900 in dark mode automatically.
Presets — use these for all filled/tonal/outlined UI elements:
<div class="preset-filled-surface-100-900"> <!-- solid fill -->
<div class="preset-tonal-primary"> <!-- muted fill -->
<div class="preset-outlined-primary-500"> <!-- border only -->Buttons — always btn + a preset, never custom background classes:
<button class="btn preset-filled-primary-500">Primary action</button>
<button class="btn btn-sm preset-tonal-surface">Secondary</button>
<button class="btn preset-outlined-primary-500">Outlined</button>Cards — always card + a filled/tonal preset:
<div class="card preset-filled-surface-100-900 p-6">...</div>Form controls — use Skeleton's select, input, textarea base classes + a preset:
<select class="select preset-tonal-surface">...</select>
<input class="input preset-tonal-surface" />Badges / chips
<span class="badge preset-filled-primary-500">New</span>
<span class="badge preset-tonal-warning">Pending</span>Radius — The Lucid theme sets --radius-base: 9999rem (pills) and --radius-container: 0.375rem. Use rounded-base for interactive elements, rounded-container for cards/panels.
SVG attributes support CSS variables — use Skeleton tokens even in D3 charts:
<path stroke="var(--color-primary-500)" />
<rect fill="var(--color-secondary-500)" />Only fall back to hardcoded hex values if a specific shade not covered by a theme token is needed.
The default/custom theme lives in src/lib/themes/lucid.css. Its brand colors:
| Token | Hue | Character |
|---|---|---|
primary |
Blue (~255°) | Main brand, CTAs |
secondary |
Yellow-warm (~70°) | Highlights, badges |
tertiary |
Orange-red (~27°) | Alerts, accents |
success |
Green (~147°) | Positive deltas |
error |
Red-orange (~30°) | Negative deltas |
surface |
Cool grey-blue | Backgrounds, borders |
Fonts: Avenir/Montserrat (body), Seravek/Gill Sans (headings).
- Theme is stored in
localStorage.theme, applied viadata-themeon<html> - Mode is stored in
localStorage.mode, applied viadata-modeon<html> - The inline
<script>inapp.htmlrestores both before first paint (prevents flash) - Tailwind's
dark:variant is wired to[data-mode="dark"]via@custom-variantinapp.css - To add a new theme: import it in
app.css, add to thethemesarray inThemePicker.svelte
D3 = math only. Svelte = DOM.
<script lang="ts">
import { scaleLinear, axisLeft, select } from 'd3';
let { data } = $props();
// D3 computes — Svelte renders
const yScale = $derived(scaleLinear().domain([0, 100]).range([300, 0]));
// Axis injection via bind:this + $effect
let yAxisEl = $state<SVGGElement | undefined>(undefined);
$effect(() => {
const scale = yScale; // read derived inside effect to track it
if (yAxisEl) select(yAxisEl).call(axisLeft(scale));
});
</script>
<!-- Svelte renders the SVG structure -->
{#each data as d}
<circle cx={xScale(d.x)} cy={yScale(d.y)} r="4" />
{/each}
<g bind:this={yAxisEl} />Never use D3's .append(), .enter(), or .exit() — let Svelte handle DOM mutations.
Chart responsiveness: all charts use viewBox + class="w-full h-auto" so they scale with their container. The internal coordinate system is fixed (W=800 for line, W=560 for bar).
Chart color prop: pass a Skeleton CSS variable string. This works in SVG presentation attributes in all modern browsers and automatically responds to theme switching:
color="var(--color-primary-500)" // line chart default
color="var(--color-secondary-500)" // bar chart default
color="var(--color-tertiary-500)" // users / alternate seriesExported shapes:
TimeSeriesPoint { date: Date; value: number }
CategoryPoint { label: string; value: number }
KpiMetric { title; value; unit; change; subtitle }Exported datasets:
revenueTimeSeries— 12 months of monthly revenueusersTimeSeries— 12 months of monthly active userssegmentRevenue— revenue broken down by customer segmentkpis— four KPI card metrics
When adding new data, keep it in mock.ts and export typed arrays. Real data sources can be swapped in by matching the same interfaces.
- Create
src/lib/components/charts/MyChart.svelte - Accept
data,color,height,formatYas props (match existing chart API for consistency) - Use a fixed
viewBoxwidth,$derivedfor scales,$effect+bind:thisfor axes - Add a scoped
<style>block targetingsvg :global(.axis text/line/.domain) - Import and use in
+page.svelte