responsive ui + wallet redesign + 401 fix + round cards

This commit is contained in:
2026-08-01 19:55:11 +08:00
parent 5ac8c93027
commit 23ef77ece6
40 changed files with 3184 additions and 684 deletions
@@ -0,0 +1,244 @@
# Component Composition
## Contents
- Items always inside their Group component
- Callouts use Alert
- Empty states use Empty component
- Toast notifications use svelte-sonner
- Choosing between overlay components
- Dialog, Sheet, and Drawer always need a Title
- Card structure
- Button has no isPending or isLoading prop
- Tabs.Trigger must be inside Tabs.List
- Avatar always needs Avatar.Fallback
- Use Separator instead of raw hr or border divs
- Use Skeleton for loading placeholders
- Use Badge instead of custom styled spans
---
## Items always inside their Group component
Never render items directly inside the content container.
**Incorrect:**
```svelte
<script lang="ts">
import * as Select from "$lib/components/ui/select";
</script>
<Select.Content>
<Select.Item value="apple">Apple</Select.Item>
<Select.Item value="banana">Banana</Select.Item>
</Select.Content>
```
**Correct:**
```svelte
<script lang="ts">
import * as Select from "$lib/components/ui/select";
</script>
<Select.Content>
<Select.Group>
<Select.Item value="apple">Apple</Select.Item>
<Select.Item value="banana">Banana</Select.Item>
</Select.Group>
</Select.Content>
```
This applies to all group-based components:
| Item | Group |
| ------------------------------------------------------------- | -------------------- |
| `Select.Item`, `Select.Label` | `Select.Group` |
| `DropdownMenu.Item`, `DropdownMenu.Label`, `DropdownMenu.Sub` | `DropdownMenu.Group` |
| `Menubar.Item` | `Menubar.Group` |
| `ContextMenu.Item` | `ContextMenu.Group` |
| `Command.Item` | `Command.Group` |
---
## Callouts use Alert
```svelte
<script lang="ts">
import * as Alert from "$lib/components/ui/alert";
</script>
<Alert.Root>
<Alert.Title>Warning</Alert.Title>
<Alert.Description>Something needs attention.</Alert.Description>
</Alert.Root>
```
---
## Empty states use Empty component
```svelte
<script lang="ts">
import * as Empty from "$lib/components/ui/empty";
import { Button } from "$lib/components/ui/button";
import FolderIcon from "@lucide/svelte/icons/folder";
</script>
<Empty.Root>
<Empty.Header>
<Empty.Media variant="icon"><FolderIcon /></Empty.Media>
<Empty.Title>No projects yet</Empty.Title>
<Empty.Description
>Get started by creating a new project.</Empty.Description
>
</Empty.Header>
<Empty.Content>
<Button>Create Project</Button>
</Empty.Content>
</Empty.Root>
```
---
## Toast notifications use svelte-sonner
```svelte
<script lang="ts">
import { toast } from "svelte-sonner";
</script>
```
```ts
toast.success("Changes saved.");
toast.error("Something went wrong.");
toast("File deleted.", {
action: { label: "Undo", onClick: () => undoDelete() },
});
```
Mount the `Toaster` from your UI folder once in the app layout (see [Sonner](https://shadcn-svelte.com/docs/components/sonner)).
---
## Choosing between overlay components
| Use case | Component |
| ---------------------------------- | ------------- |
| Focused task that requires input | `Dialog` |
| Destructive action confirmation | `AlertDialog` |
| Side panel with details or filters | `Sheet` |
| Mobile-first bottom panel | `Drawer` |
| Quick info on hover | `HoverCard` |
| Small contextual content on click | `Popover` |
---
## Dialog, Sheet, and Drawer always need a Title
`Dialog.Title`, `Sheet.Title`, `Drawer.Title` are required for accessibility. Use `class="sr-only"` if visually hidden.
```svelte
<script lang="ts">
import * as Dialog from "$lib/components/ui/dialog";
</script>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Edit Profile</Dialog.Title>
<Dialog.Description>Update your profile.</Dialog.Description>
</Dialog.Header>
...
</Dialog.Content>
```
---
## Card structure
Use full composition — don't dump everything into `Card.Content`:
```svelte
<script lang="ts">
import * as Card from "$lib/components/ui/card";
import { Button } from "$lib/components/ui/button";
</script>
<Card.Root>
<Card.Header>
<Card.Title>Team Members</Card.Title>
<Card.Description>Manage your team.</Card.Description>
</Card.Header>
<Card.Content>...</Card.Content>
<Card.Footer>
<Button>Invite</Button>
</Card.Footer>
</Card.Root>
```
---
## Button has no isPending or isLoading prop
Compose with `Spinner` inside `Button` + `disabled`:
```svelte
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import { Spinner } from "$lib/components/ui/spinner";
</script>
<Button disabled>
<Spinner data-icon="inline-start" />
Saving...
</Button>
```
---
## Tabs.Trigger must be inside Tabs.List
Never render `Tabs.Trigger` directly inside `Tabs.Root` — always wrap in `Tabs.List`:
```svelte
<script lang="ts">
import * as Tabs from "$lib/components/ui/tabs";
let tab = $state("account");
</script>
<Tabs.Root bind:value={tab}>
<Tabs.List>
<Tabs.Trigger value="account">Account</Tabs.Trigger>
<Tabs.Trigger value="password">Password</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="account">...</Tabs.Content>
</Tabs.Root>
```
---
## Avatar always needs Avatar.Fallback
Always include `Avatar.Fallback` for when the image fails to load:
```svelte
<script lang="ts">
import * as Avatar from "$lib/components/ui/avatar";
</script>
<Avatar.Root>
<Avatar.Image src="/avatar.png" alt="User" />
<Avatar.Fallback>JD</Avatar.Fallback>
</Avatar.Root>
```
---
## Use existing components instead of custom markup
| Instead of | Use |
| ---------------------------------------------- | ------------------------------------------------------------------------------------------- |
| `<hr>` or `<div class="border-t">` | `<Separator />` (`import { Separator } from "$lib/components/ui/separator"`) |
| `<div class="animate-pulse">` with styled divs | `<Skeleton class="h-4 w-3/4" />` (`import { Skeleton } from "$lib/components/ui/skeleton"`) |
| `<span class="rounded-full bg-green-100 ...">` | `<Badge variant="secondary">` (`import { Badge } from "$lib/components/ui/badge"`) |
@@ -0,0 +1,234 @@
# Forms & Inputs
## Contents
- Forms use Field.FieldGroup + Field.Field
- InputGroup requires InputGroup.Input/InputGroup.Textarea
- Buttons inside inputs use InputGroup.Root + InputGroup.Addon
- Option sets (2–7 choices) use ToggleGroup.Root + ToggleGroup.Item
- Field.FieldSet + Field.FieldLegend for grouping related fields
- Field validation and disabled states
---
## Forms use Field.FieldGroup + Field.Field
Always use `Field.FieldGroup` + `Field.Field` — never raw `div` with `space-y-*`:
```svelte
<script lang="ts">
import * as Field from "$lib/components/ui/field";
import { Input } from "$lib/components/ui/input";
</script>
<Field.FieldGroup>
<Field.Field>
<Field.FieldLabel for="email">Email</Field.FieldLabel>
<Input id="email" type="email" />
</Field.Field>
<Field.Field>
<Field.FieldLabel for="password">Password</Field.FieldLabel>
<Input id="password" type="password" />
</Field.Field>
</Field.FieldGroup>
```
Use `Field` with `orientation="horizontal"` for settings pages. Use `Field.FieldLabel` with `class="sr-only"` for visually hidden labels.
**Choosing form controls:**
- Simple text input → `Input`
- Dropdown with predefined options → `Select`
- Searchable dropdown → `Combobox`
- Native HTML select (no JS) → `native-select`
- Boolean toggle → `Switch` (for settings) or `Checkbox` (for forms)
- Single choice from few options → `RadioGroup`
- Toggle between 2–5 options → `ToggleGroup.Root` + `ToggleGroup.Item`
- OTP/verification code → `InputOTP`
- Multi-line text → `Textarea`
---
## InputGroup requires InputGroup.Input/InputGroup.Textarea
Never use raw `Input` or `Textarea` inside an `InputGroup.Root`.
**Incorrect:**
```svelte
<script lang="ts">
import * as InputGroup from "$lib/components/ui/input-group";
import { Input } from "$lib/components/ui/input";
</script>
<InputGroup.Root>
<Input placeholder="Search..." />
</InputGroup.Root>
```
**Correct:**
```svelte
<script lang="ts">
import * as InputGroup from "$lib/components/ui/input-group";
</script>
<InputGroup.Root>
<InputGroup.Input placeholder="Search..." />
</InputGroup.Root>
```
---
## Buttons inside inputs use InputGroup.Root + InputGroup.Addon
Never place a `Button` directly inside or adjacent to an `Input` with custom positioning.
**Incorrect:**
```svelte
<script lang="ts">
import { Input } from "$lib/components/ui/input";
import { Button } from "$lib/components/ui/button";
import SearchIcon from "@lucide/svelte/icons/search";
</script>
<div class="relative">
<Input placeholder="Search..." class="pr-10" />
<Button class="absolute top-0 right-0" size="icon">
<SearchIcon />
</Button>
</div>
```
**Correct:**
```svelte
<script lang="ts">
import * as InputGroup from "$lib/components/ui/input-group";
import { Button } from "$lib/components/ui/button";
import SearchIcon from "@lucide/svelte/icons/search";
</script>
<InputGroup.Root>
<InputGroup.Input placeholder="Search..." />
<InputGroup.Addon>
<Button size="icon">
<SearchIcon data-icon="inline-start" />
</Button>
</InputGroup.Addon>
</InputGroup.Root>
```
---
## Option sets (2–7 choices) use ToggleGroup.Root + ToggleGroup.Item
Don't manually loop `Button` components with active state.
**Incorrect:**
```svelte
<script lang="ts">
import { Button } from "$lib/components/ui/button";
let selected = $state("daily");
</script>
<div class="flex gap-2">
{#each ["daily", "weekly", "monthly"] as option (option)}
<Button
variant={selected === option ? "default" : "outline"}
onclick={() => (selected = option)}
>
{option}
</Button>
{/each}
</div>
```
**Correct:**
```svelte
<script lang="ts">
import * as ToggleGroup from "$lib/components/ui/toggle-group";
let selected = $state("daily");
</script>
<ToggleGroup.Root bind:value={selected} spacing={2}>
<ToggleGroup.Item value="daily">Daily</ToggleGroup.Item>
<ToggleGroup.Item value="weekly">Weekly</ToggleGroup.Item>
<ToggleGroup.Item value="monthly">Monthly</ToggleGroup.Item>
</ToggleGroup.Root>
```
Combine with `Field` for labelled toggle groups:
```svelte
<script lang="ts">
import * as Field from "$lib/components/ui/field";
import * as ToggleGroup from "$lib/components/ui/toggle-group";
</script>
<Field.Field orientation="horizontal">
<Field.FieldTitle id="theme-label">Theme</Field.FieldTitle>
<ToggleGroup.Root aria-labelledby="theme-label" spacing={2}>
<ToggleGroup.Item value="light">Light</ToggleGroup.Item>
<ToggleGroup.Item value="dark">Dark</ToggleGroup.Item>
<ToggleGroup.Item value="system">System</ToggleGroup.Item>
</ToggleGroup.Root>
</Field.Field>
```
---
## Field.FieldSet + Field.FieldLegend for grouping related fields
Use `Field.FieldSet` + `Field.FieldLegend` for related checkboxes, radios, or switches — not `div` with a heading:
```svelte
<script lang="ts">
import * as Field from "$lib/components/ui/field";
import { Checkbox } from "$lib/components/ui/checkbox";
</script>
<Field.FieldSet>
<Field.FieldLegend variant="label">Preferences</Field.FieldLegend>
<Field.FieldDescription>Select all that apply.</Field.FieldDescription>
<Field.FieldGroup class="gap-3">
<Field.Field orientation="horizontal">
<Checkbox id="dark" />
<Field.FieldLabel for="dark" class="font-normal"
>Dark mode</Field.FieldLabel
>
</Field.Field>
</Field.FieldGroup>
</Field.FieldSet>
```
---
## Field validation and disabled states
Both attributes are needed — `data-invalid`/`data-disabled` styles the field (label, description), while `aria-invalid`/`disabled` styles the control.
```svelte
<script lang="ts">
import * as Field from "$lib/components/ui/field";
import { Input } from "$lib/components/ui/input";
</script>
<!-- Invalid. -->
<Field.Field data-invalid>
<Field.FieldLabel for="email">Email</Field.FieldLabel>
<Input id="email" aria-invalid />
<Field.FieldDescription>Invalid email address.</Field.FieldDescription>
</Field.Field>
<!-- Disabled. -->
<Field.Field data-disabled>
<Field.FieldLabel for="email">Email</Field.FieldLabel>
<Input id="email" disabled />
</Field.Field>
```
Works for all controls: `Input`, `Textarea`, `Select`, `Checkbox`, `RadioGroupItem`, `Switch`, `Slider`, `NativeSelect`, `InputOTP`.
@@ -0,0 +1,107 @@
# Icons
**Always use the project's configured `iconLibrary` for imports.** Check the `iconLibrary` field in `components.json`: `lucide` → `@lucide/svelte`, `tabler` → `@tabler/icons-svelte`, etc. Never assume `@lucide/svelte`.
---
## Icons in Button use data-icon attribute
Add `data-icon="inline-start"` (prefix) or `data-icon="inline-end"` (suffix) to the icon. No sizing classes on the icon.
**Incorrect:**
```svelte
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import SearchIcon from "@lucide/svelte/icons/search";
</script>
<Button>
<SearchIcon class="mr-2 size-4" />
Search
</Button>
```
**Correct:**
```svelte
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import SearchIcon from "@lucide/svelte/icons/search";
import ArrowRightIcon from "@lucide/svelte/icons/arrow-right";
</script>
<Button>
<SearchIcon data-icon="inline-start" />
Search
</Button>
<Button>
Next
<ArrowRightIcon data-icon="inline-end" />
</Button>
```
---
## No sizing classes on icons inside components
Components handle icon sizing via CSS. Don't add `size-4`, `w-4 h-4`, or other sizing classes to icons inside `<Button>`, `DropdownMenu.Item`, `Alert.Root`, `Sidebar.*`, or other shadcn-svelte components — unless the user explicitly asks for custom icon sizes.
**Incorrect:**
```svelte
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import SearchIcon from "@lucide/svelte/icons/search";
</script>
<Button>
<SearchIcon class="size-4" data-icon="inline-start" />
Search
</Button>
```
**Correct:**
```svelte
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import SearchIcon from "@lucide/svelte/icons/search";
</script>
<Button>
<SearchIcon data-icon="inline-start" />
Search
</Button>
```
The same applies to icons inside `DropdownMenu.Item`, sidebar items, and other menu rows — no extra sizing classes on the icon component.
---
## Pass icons as components, not string keys
Use a component reference, not a string key to a lookup map.
**Incorrect:**
```svelte
<!-- String key lookup — avoid -->
<DynamicIcon name="check" />
```
**Correct:**
```svelte
<script lang="ts">
import type { Component } from "svelte";
import CheckIcon from "@lucide/svelte/icons/check";
let { Icon }: { Icon: Component } = $props();
</script>
<Icon />
<!-- <StatusBadge Icon={CheckIcon} /> -->
```
@@ -0,0 +1,195 @@
# Styling & Customization
See [customization.md](../customization.md) for theming, CSS variables, and adding custom colors.
## Contents
- Semantic colors
- Built-in variants first
- class for layout only
- No space-x-_ / space-y-_
- Prefer size-_ over w-_ h-\* when equal
- Prefer truncate shorthand
- No manual dark: color overrides
- Use cn() for conditional classes
- No manual z-index on overlay components
---
## Semantic colors
**Incorrect:**
```svelte
<div class="bg-blue-500 text-white">
<p class="text-gray-600">Secondary text</p>
</div>
```
**Correct:**
```svelte
<div class="bg-primary text-primary-foreground">
<p class="text-muted-foreground">Secondary text</p>
</div>
```
---
## No raw color values for status/state indicators
For positive, negative, or status indicators, use Badge variants, semantic tokens like `text-destructive`, or define custom CSS variables — don't reach for raw Tailwind colors.
**Incorrect:**
```svelte
<span class="text-emerald-600">+20.1%</span>
<span class="text-green-500">Active</span>
<span class="text-red-600">-3.2%</span>
```
**Correct:**
```svelte
<script lang="ts">
import { Badge } from "$lib/components/ui/badge";
</script>
<Badge variant="secondary">+20.1%</Badge>
<Badge>Active</Badge>
<span class="text-destructive">-3.2%</span>
```
If you need a success/positive color that doesn't exist as a semantic token, use a Badge variant or ask the user about adding a custom CSS variable to the theme (see [customization.md](../customization.md)).
---
## Built-in variants first
**Incorrect:**
```svelte
<script lang="ts">
import { Button } from "$lib/components/ui/button";
</script>
<Button class="border-input hover:bg-accent border bg-transparent"
>Click me</Button
>
```
**Correct:**
```svelte
<script lang="ts">
import { Button } from "$lib/components/ui/button";
</script>
<Button variant="outline">Click me</Button>
```
---
## class for layout only
Use `class` for layout (e.g. `max-w-md`, `mx-auto`, `mt-4`), **not** for overriding component colors or typography. To change colors, use semantic tokens, built-in variants, or CSS variables.
**Incorrect:**
```svelte
<script lang="ts">
import * as Card from "$lib/components/ui/card";
</script>
<Card.Root class="bg-blue-100 font-bold text-blue-900">
<Card.Content>Dashboard</Card.Content>
</Card.Root>
```
**Correct:**
```svelte
<script lang="ts">
import * as Card from "$lib/components/ui/card";
</script>
<Card.Root class="mx-auto max-w-md">
<Card.Content>Dashboard</Card.Content>
</Card.Root>
```
To customize a component's appearance, prefer these approaches in order:
1. **Built-in variants** — `variant="outline"`, `variant="destructive"`, etc.
2. **Semantic color tokens** — `bg-primary`, `text-muted-foreground`.
3. **CSS variables** — define custom colors in the global CSS file (see [customization.md](../customization.md)).
---
## No space-x-_ / space-y-_
Use `gap-*` instead. `space-y-4` → `flex flex-col gap-4`. `space-x-2` → `flex gap-2`.
```svelte
<script lang="ts">
import { Input } from "$lib/components/ui/input";
import { Button } from "$lib/components/ui/button";
</script>
<div class="flex flex-col gap-4">
<Input />
<Input />
<Button>Submit</Button>
</div>
```
---
## Prefer size-_ over w-_ h-\* when equal
`size-10` not `w-10 h-10`. Applies to icons, avatars, skeletons, etc.
---
## Prefer truncate shorthand
`truncate` not `overflow-hidden text-ellipsis whitespace-nowrap`.
---
## No manual dark: color overrides
Use semantic tokens — they handle light/dark via CSS variables. `bg-background text-foreground` not `bg-white dark:bg-gray-950`.
---
## Use cn() for conditional classes
Use the `cn()` utility from the project for conditional or merged class names. Don't write manual ternaries in `class` strings.
**Incorrect:**
```svelte
<script lang="ts">
let isActive = $state(false);
</script>
<div class={`flex items-center ${isActive ? "bg-primary text-primary-foreground" : "bg-muted"}`}>
```
**Correct:**
```svelte
<script lang="ts">
import { cn } from "$lib/utils";
let isActive = $state(false);
</script>
<div class={cn("flex items-center", isActive ? "bg-primary text-primary-foreground" : "bg-muted")}>
```
---
## No manual z-index on overlay components
`Dialog`, `Sheet`, `Drawer`, `AlertDialog`, `DropdownMenu`, `Popover`, `Tooltip`, `HoverCard` handle their own stacking. Never add `z-50` or `z-[999]`.