0b76a4b119
- Delete Mode1Test, Mode2Test, Mode3Test, Mode5Test (keep only Mode6Test) - Delete old documentation, Docker files, and k8s configs - Add comprehensive Svelte 5 reference library (svelte-reference/) - Update ModeSwitch orchestrator: - Fix port to 3005 (never use 3000/3001) - Add API endpoints for mode switching - Add bun-types and proper tsconfig - Clean up mode folder mappings
734 lines
16 KiB
Markdown
734 lines
16 KiB
Markdown
# Svelte Styling & Tailwind Integration
|
|
|
|
## Overview
|
|
|
|
Svelte provides scoped CSS by default and integrates seamlessly with Tailwind CSS. This guide covers both approaches and best practices.
|
|
|
|
---
|
|
|
|
## 🎨 Scoped CSS (Default)
|
|
|
|
### Basic Scoped Styles
|
|
|
|
```svelte
|
|
<script lang="ts">
|
|
let { variant = 'primary' }: { variant?: 'primary' | 'secondary' } = $props();
|
|
</script>
|
|
|
|
<button class={variant}>
|
|
<slot />
|
|
</button>
|
|
|
|
<style>
|
|
/* These styles are automatically scoped to this component */
|
|
button {
|
|
padding: 0.5rem 1rem;
|
|
border-radius: 0.25rem;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.primary {
|
|
background: var(--color-primary);
|
|
color: white;
|
|
border: none;
|
|
}
|
|
|
|
.primary:hover {
|
|
background: var(--color-primary-dark);
|
|
}
|
|
|
|
.secondary {
|
|
background: transparent;
|
|
color: var(--color-primary);
|
|
border: 2px solid currentColor;
|
|
}
|
|
|
|
.secondary:hover {
|
|
background: var(--color-primary);
|
|
color: white;
|
|
}
|
|
</style>
|
|
```
|
|
|
|
### Global Styles with :global()
|
|
|
|
```svelte
|
|
<style>
|
|
/* Scoped to this component */
|
|
.container {
|
|
padding: 1rem;
|
|
}
|
|
|
|
/* Applies globally - use sparingly */
|
|
:global(body) {
|
|
margin: 0;
|
|
font-family: system-ui;
|
|
}
|
|
|
|
/* Global styles WITHIN a scoped selector */
|
|
.container :global(a) {
|
|
color: var(--color-link);
|
|
}
|
|
|
|
/* Global class that can be used anywhere */
|
|
:global(.sr-only) {
|
|
position: absolute;
|
|
width: 1px;
|
|
height: 1px;
|
|
padding: 0;
|
|
margin: -1px;
|
|
overflow: hidden;
|
|
clip: rect(0, 0, 0, 0);
|
|
border: 0;
|
|
}
|
|
</style>
|
|
```
|
|
|
|
### CSS Custom Properties (Variables)
|
|
|
|
```svelte
|
|
<!-- Define in root layout or app.css -->
|
|
<style>
|
|
:root {
|
|
/* Colors */
|
|
--color-primary: #3b82f6;
|
|
--color-primary-dark: #2563eb;
|
|
--color-secondary: #6b7280;
|
|
--color-success: #10b981;
|
|
--color-warning: #f59e0b;
|
|
--color-error: #ef4444;
|
|
|
|
/* Surfaces */
|
|
--color-background: #ffffff;
|
|
--color-surface: #f9fafb;
|
|
--color-border: #e5e7eb;
|
|
|
|
/* Text */
|
|
--color-text: #111827;
|
|
--color-text-muted: #6b7280;
|
|
|
|
/* Spacing */
|
|
--space-1: 0.25rem;
|
|
--space-2: 0.5rem;
|
|
--space-3: 0.75rem;
|
|
--space-4: 1rem;
|
|
--space-6: 1.5rem;
|
|
--space-8: 2rem;
|
|
|
|
/* Typography */
|
|
--font-sans: system-ui, -apple-system, sans-serif;
|
|
--font-mono: ui-monospace, monospace;
|
|
|
|
/* Shadows */
|
|
--shadow-sm: 0 1px 2px rgb(0 0 0 / 0.05);
|
|
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
|
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
|
|
|
|
/* Borders */
|
|
--radius-sm: 0.25rem;
|
|
--radius-md: 0.375rem;
|
|
--radius-lg: 0.5rem;
|
|
--radius-full: 9999px;
|
|
}
|
|
|
|
/* Dark mode */
|
|
:root[data-theme="dark"] {
|
|
--color-background: #111827;
|
|
--color-surface: #1f2937;
|
|
--color-border: #374151;
|
|
--color-text: #f9fafb;
|
|
--color-text-muted: #9ca3af;
|
|
}
|
|
</style>
|
|
```
|
|
|
|
---
|
|
|
|
## 💨 Tailwind CSS Integration
|
|
|
|
### Setup
|
|
|
|
```bash
|
|
# Install Tailwind
|
|
npm install -D tailwindcss postcss autoprefixer
|
|
npx tailwindcss init -p
|
|
```
|
|
|
|
### tailwind.config.js
|
|
|
|
```javascript
|
|
/** @type {import('tailwindcss').Config} */
|
|
export default {
|
|
content: ['./src/**/*.{html,js,svelte,ts}'],
|
|
darkMode: 'class', // or 'media'
|
|
theme: {
|
|
extend: {
|
|
colors: {
|
|
primary: {
|
|
50: '#eff6ff',
|
|
100: '#dbeafe',
|
|
200: '#bfdbfe',
|
|
300: '#93c5fd',
|
|
400: '#60a5fa',
|
|
500: '#3b82f6',
|
|
600: '#2563eb',
|
|
700: '#1d4ed8',
|
|
800: '#1e40af',
|
|
900: '#1e3a8a',
|
|
},
|
|
},
|
|
fontFamily: {
|
|
sans: ['Inter var', 'system-ui', 'sans-serif'],
|
|
},
|
|
},
|
|
},
|
|
plugins: [
|
|
require('@tailwindcss/forms'),
|
|
require('@tailwindcss/typography'),
|
|
],
|
|
};
|
|
```
|
|
|
|
### postcss.config.js
|
|
|
|
```javascript
|
|
export default {
|
|
plugins: {
|
|
tailwindcss: {},
|
|
autoprefixer: {},
|
|
},
|
|
};
|
|
```
|
|
|
|
### app.css
|
|
|
|
```css
|
|
@tailwind base;
|
|
@tailwind components;
|
|
@tailwind utilities;
|
|
|
|
/* Custom base styles */
|
|
@layer base {
|
|
body {
|
|
@apply bg-white dark:bg-gray-900 text-gray-900 dark:text-white;
|
|
}
|
|
|
|
h1, h2, h3, h4, h5, h6 {
|
|
@apply font-bold tracking-tight;
|
|
}
|
|
}
|
|
|
|
/* Custom component classes */
|
|
@layer components {
|
|
.btn {
|
|
@apply inline-flex items-center justify-center px-4 py-2
|
|
font-medium rounded-lg transition-colors
|
|
focus:outline-none focus:ring-2 focus:ring-offset-2;
|
|
}
|
|
|
|
.btn-primary {
|
|
@apply bg-primary-600 text-white hover:bg-primary-700
|
|
focus:ring-primary-500;
|
|
}
|
|
|
|
.btn-secondary {
|
|
@apply bg-gray-100 text-gray-700 hover:bg-gray-200
|
|
dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700;
|
|
}
|
|
|
|
.input {
|
|
@apply block w-full rounded-lg border border-gray-300
|
|
px-3 py-2 shadow-sm
|
|
focus:border-primary-500 focus:ring-1 focus:ring-primary-500
|
|
dark:border-gray-600 dark:bg-gray-800;
|
|
}
|
|
|
|
.card {
|
|
@apply rounded-xl bg-white dark:bg-gray-800
|
|
border border-gray-200 dark:border-gray-700
|
|
shadow-sm overflow-hidden;
|
|
}
|
|
}
|
|
|
|
/* Custom utilities */
|
|
@layer utilities {
|
|
.text-balance {
|
|
text-wrap: balance;
|
|
}
|
|
|
|
.scrollbar-hide {
|
|
-ms-overflow-style: none;
|
|
scrollbar-width: none;
|
|
}
|
|
|
|
.scrollbar-hide::-webkit-scrollbar {
|
|
display: none;
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 🧩 Component Styling Patterns
|
|
|
|
### Pattern 1: Tailwind Component
|
|
|
|
```svelte
|
|
<!-- Button.svelte -->
|
|
<script lang="ts">
|
|
import type { Snippet } from 'svelte';
|
|
import type { HTMLButtonAttributes } from 'svelte/elements';
|
|
|
|
interface Props extends HTMLButtonAttributes {
|
|
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
|
|
size?: 'sm' | 'md' | 'lg';
|
|
loading?: boolean;
|
|
children: Snippet;
|
|
}
|
|
|
|
let {
|
|
variant = 'primary',
|
|
size = 'md',
|
|
loading = false,
|
|
disabled,
|
|
class: className = '',
|
|
children,
|
|
...rest
|
|
}: Props = $props();
|
|
|
|
const baseClasses = `
|
|
inline-flex items-center justify-center font-medium rounded-lg
|
|
transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2
|
|
disabled:opacity-50 disabled:cursor-not-allowed
|
|
`;
|
|
|
|
const variantClasses = {
|
|
primary: 'bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500',
|
|
secondary: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300',
|
|
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
|
|
ghost: 'text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
|
|
};
|
|
|
|
const sizeClasses = {
|
|
sm: 'px-3 py-1.5 text-sm',
|
|
md: 'px-4 py-2 text-sm',
|
|
lg: 'px-6 py-3 text-base'
|
|
};
|
|
|
|
let classes = $derived(
|
|
`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`.trim()
|
|
);
|
|
</script>
|
|
|
|
<button class={classes} disabled={disabled || loading} {...rest}>
|
|
{#if loading}
|
|
<svg class="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
</svg>
|
|
{/if}
|
|
{@render children()}
|
|
</button>
|
|
```
|
|
|
|
### Pattern 2: Class Merging with clsx/tailwind-merge
|
|
|
|
```bash
|
|
npm install clsx tailwind-merge
|
|
```
|
|
|
|
```typescript
|
|
// src/lib/utils/cn.ts
|
|
import { clsx, type ClassValue } from 'clsx';
|
|
import { twMerge } from 'tailwind-merge';
|
|
|
|
export function cn(...inputs: ClassValue[]) {
|
|
return twMerge(clsx(inputs));
|
|
}
|
|
```
|
|
|
|
```svelte
|
|
<!-- Card.svelte -->
|
|
<script lang="ts">
|
|
import { cn } from '$lib/utils/cn';
|
|
import type { Snippet } from 'svelte';
|
|
|
|
interface Props {
|
|
variant?: 'default' | 'bordered' | 'elevated';
|
|
padding?: 'none' | 'sm' | 'md' | 'lg';
|
|
class?: string;
|
|
children: Snippet;
|
|
}
|
|
|
|
let {
|
|
variant = 'default',
|
|
padding = 'md',
|
|
class: className,
|
|
children
|
|
}: Props = $props();
|
|
</script>
|
|
|
|
<div
|
|
class={cn(
|
|
'rounded-xl bg-white dark:bg-gray-800',
|
|
{
|
|
'border border-gray-200 dark:border-gray-700': variant === 'default' || variant === 'bordered',
|
|
'shadow-lg': variant === 'elevated',
|
|
'p-0': padding === 'none',
|
|
'p-4': padding === 'sm',
|
|
'p-6': padding === 'md',
|
|
'p-8': padding === 'lg',
|
|
},
|
|
className
|
|
)}
|
|
>
|
|
{@render children()}
|
|
</div>
|
|
```
|
|
|
|
### Pattern 3: CVA (Class Variance Authority)
|
|
|
|
```bash
|
|
npm install class-variance-authority
|
|
```
|
|
|
|
```typescript
|
|
// src/lib/components/Button/variants.ts
|
|
import { cva, type VariantProps } from 'class-variance-authority';
|
|
|
|
export const buttonVariants = cva(
|
|
// Base classes
|
|
'inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed',
|
|
{
|
|
variants: {
|
|
variant: {
|
|
primary: 'bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500',
|
|
secondary: 'bg-gray-100 text-gray-700 hover:bg-gray-200',
|
|
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
|
|
ghost: 'text-gray-600 hover:bg-gray-100',
|
|
link: 'text-primary-600 underline-offset-4 hover:underline'
|
|
},
|
|
size: {
|
|
sm: 'h-8 px-3 text-sm',
|
|
md: 'h-10 px-4 text-sm',
|
|
lg: 'h-12 px-6 text-base',
|
|
icon: 'h-10 w-10'
|
|
}
|
|
},
|
|
defaultVariants: {
|
|
variant: 'primary',
|
|
size: 'md'
|
|
}
|
|
}
|
|
);
|
|
|
|
export type ButtonVariants = VariantProps<typeof buttonVariants>;
|
|
```
|
|
|
|
```svelte
|
|
<!-- Button.svelte -->
|
|
<script lang="ts">
|
|
import { buttonVariants, type ButtonVariants } from './variants';
|
|
import { cn } from '$lib/utils/cn';
|
|
import type { Snippet } from 'svelte';
|
|
import type { HTMLButtonAttributes } from 'svelte/elements';
|
|
|
|
interface Props extends ButtonVariants, HTMLButtonAttributes {
|
|
class?: string;
|
|
children: Snippet;
|
|
}
|
|
|
|
let { variant, size, class: className, children, ...rest }: Props = $props();
|
|
</script>
|
|
|
|
<button class={cn(buttonVariants({ variant, size }), className)} {...rest}>
|
|
{@render children()}
|
|
</button>
|
|
```
|
|
|
|
---
|
|
|
|
## 🌓 Dark Mode
|
|
|
|
### With Tailwind
|
|
|
|
```svelte
|
|
<!-- ThemeToggle.svelte -->
|
|
<script lang="ts">
|
|
import { browser } from '$app/environment';
|
|
|
|
type Theme = 'light' | 'dark' | 'system';
|
|
|
|
let theme = $state<Theme>('system');
|
|
|
|
$effect(() => {
|
|
if (!browser) return;
|
|
|
|
const saved = localStorage.getItem('theme') as Theme | null;
|
|
if (saved) {
|
|
theme = saved;
|
|
}
|
|
});
|
|
|
|
$effect(() => {
|
|
if (!browser) return;
|
|
|
|
const root = document.documentElement;
|
|
|
|
if (theme === 'dark') {
|
|
root.classList.add('dark');
|
|
} else if (theme === 'light') {
|
|
root.classList.remove('dark');
|
|
} else {
|
|
// System preference
|
|
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
|
root.classList.add('dark');
|
|
} else {
|
|
root.classList.remove('dark');
|
|
}
|
|
}
|
|
|
|
localStorage.setItem('theme', theme);
|
|
});
|
|
|
|
function cycle() {
|
|
const themes: Theme[] = ['light', 'dark', 'system'];
|
|
const current = themes.indexOf(theme);
|
|
theme = themes[(current + 1) % themes.length];
|
|
}
|
|
</script>
|
|
|
|
<button
|
|
onclick={cycle}
|
|
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
|
|
aria-label="Toggle theme"
|
|
>
|
|
{#if theme === 'light'}
|
|
☀️
|
|
{:else if theme === 'dark'}
|
|
🌙
|
|
{:else}
|
|
💻
|
|
{/if}
|
|
</button>
|
|
```
|
|
|
|
### Prevent Flash of Wrong Theme
|
|
|
|
```html
|
|
<!-- app.html -->
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<script>
|
|
// Prevent flash of wrong theme
|
|
(function() {
|
|
const theme = localStorage.getItem('theme');
|
|
if (theme === 'dark' ||
|
|
(!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
|
document.documentElement.classList.add('dark');
|
|
}
|
|
})();
|
|
</script>
|
|
%sveltekit.head%
|
|
</head>
|
|
<body>
|
|
<div style="display: contents">%sveltekit.body%</div>
|
|
</body>
|
|
</html>
|
|
```
|
|
|
|
---
|
|
|
|
## 📐 Responsive Design
|
|
|
|
### Tailwind Breakpoints
|
|
|
|
```svelte
|
|
<div class="
|
|
grid grid-cols-1
|
|
sm:grid-cols-2
|
|
md:grid-cols-3
|
|
lg:grid-cols-4
|
|
xl:grid-cols-5
|
|
gap-4
|
|
">
|
|
{#each items as item}
|
|
<div class="p-4 bg-white rounded-lg shadow">
|
|
{item.name}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
|
|
<!-- Responsive typography -->
|
|
<h1 class="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-bold">
|
|
Responsive Heading
|
|
</h1>
|
|
|
|
<!-- Hide/show based on screen size -->
|
|
<nav class="hidden md:flex gap-4">
|
|
<!-- Desktop navigation -->
|
|
</nav>
|
|
|
|
<button class="md:hidden">
|
|
<!-- Mobile menu button -->
|
|
</button>
|
|
```
|
|
|
|
### Container Queries (Tailwind v3.4+)
|
|
|
|
```svelte
|
|
<div class="@container">
|
|
<div class="@md:flex @md:gap-4">
|
|
<img class="@md:w-48" src="/image.jpg" alt="" />
|
|
<div>
|
|
<h2 class="@md:text-xl">Title</h2>
|
|
<p class="@md:text-base text-sm">Description</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
---
|
|
|
|
## ✨ Animations
|
|
|
|
### Tailwind Animations
|
|
|
|
```svelte
|
|
<!-- Built-in animations -->
|
|
<div class="animate-spin">Spinning</div>
|
|
<div class="animate-pulse">Pulsing</div>
|
|
<div class="animate-bounce">Bouncing</div>
|
|
|
|
<!-- Custom animation in tailwind.config.js -->
|
|
<div class="animate-fade-in">Fading in</div>
|
|
```
|
|
|
|
```javascript
|
|
// tailwind.config.js
|
|
export default {
|
|
theme: {
|
|
extend: {
|
|
animation: {
|
|
'fade-in': 'fadeIn 0.3s ease-in-out',
|
|
'slide-up': 'slideUp 0.3s ease-out',
|
|
'slide-down': 'slideDown 0.3s ease-out',
|
|
},
|
|
keyframes: {
|
|
fadeIn: {
|
|
'0%': { opacity: '0' },
|
|
'100%': { opacity: '1' },
|
|
},
|
|
slideUp: {
|
|
'0%': { transform: 'translateY(10px)', opacity: '0' },
|
|
'100%': { transform: 'translateY(0)', opacity: '1' },
|
|
},
|
|
slideDown: {
|
|
'0%': { transform: 'translateY(-10px)', opacity: '0' },
|
|
'100%': { transform: 'translateY(0)', opacity: '1' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
```
|
|
|
|
### Svelte Transitions
|
|
|
|
```svelte
|
|
<script lang="ts">
|
|
import { fade, fly, slide, scale, blur } from 'svelte/transition';
|
|
import { quintOut } from 'svelte/easing';
|
|
|
|
let visible = $state(true);
|
|
</script>
|
|
|
|
{#if visible}
|
|
<!-- Fade -->
|
|
<div transition:fade={{ duration: 300 }}>
|
|
Fading content
|
|
</div>
|
|
|
|
<!-- Fly -->
|
|
<div transition:fly={{ y: 20, duration: 300 }}>
|
|
Flying content
|
|
</div>
|
|
|
|
<!-- Slide -->
|
|
<div transition:slide={{ duration: 300 }}>
|
|
Sliding content
|
|
</div>
|
|
|
|
<!-- Scale -->
|
|
<div transition:scale={{ start: 0.95, duration: 300 }}>
|
|
Scaling content
|
|
</div>
|
|
|
|
<!-- Combined with easing -->
|
|
<div transition:fly={{ y: 20, duration: 400, easing: quintOut }}>
|
|
Smooth flying content
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- In/out separately -->
|
|
{#if visible}
|
|
<div
|
|
in:fly={{ y: 20, duration: 300 }}
|
|
out:fade={{ duration: 200 }}
|
|
>
|
|
Different in/out
|
|
</div>
|
|
{/if}
|
|
```
|
|
|
|
### Custom Transition
|
|
|
|
```typescript
|
|
// src/lib/transitions/index.ts
|
|
import { cubicOut } from 'svelte/easing';
|
|
|
|
export function expandHeight(
|
|
node: Element,
|
|
{ duration = 300, easing = cubicOut } = {}
|
|
) {
|
|
const style = getComputedStyle(node);
|
|
const height = parseFloat(style.height);
|
|
const paddingTop = parseFloat(style.paddingTop);
|
|
const paddingBottom = parseFloat(style.paddingBottom);
|
|
|
|
return {
|
|
duration,
|
|
easing,
|
|
css: (t: number) => `
|
|
height: ${t * height}px;
|
|
padding-top: ${t * paddingTop}px;
|
|
padding-bottom: ${t * paddingBottom}px;
|
|
overflow: hidden;
|
|
`
|
|
};
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 📋 Best Practices
|
|
|
|
### ✅ Do:
|
|
- Use Tailwind for utility-first styling
|
|
- Create reusable component classes with `@layer components`
|
|
- Use CSS variables for design tokens
|
|
- Prefer `class:` directive for conditional classes
|
|
- Use `cn()` utility for merging classes
|
|
- Keep animations subtle and purposeful
|
|
|
|
### ❌ Don't:
|
|
- Mix inline styles with Tailwind randomly
|
|
- Override Tailwind classes with `!important`
|
|
- Create deeply nested CSS selectors
|
|
- Use `@apply` for everything (defeats the purpose)
|
|
- Forget dark mode variants
|
|
- Ignore accessibility (focus states, contrast)
|