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,227 @@
---
name: shadcn-svelte
description: Manages shadcn-svelte components and projects — adding, updating, fixing, debugging, styling, and composing UI. Provides project context, component docs, and usage examples. Applies when working with shadcn-svelte, the CLI, design-system presets, or any project with a components.json file. Also triggers for "shadcn-svelte init", "add component", or registry URLs.
user-invocable: false
allowed-tools: Bash(npx shadcn-svelte@latest *), Bash(pnpm dlx shadcn-svelte@latest *), Bash(bunx --bun shadcn-svelte@latest *)
---
# shadcn-svelte
A framework for building UI, components, and design systems for Svelte. Components are added as source to the user's project via the CLI.
> **IMPORTANT:** Run all CLI commands using the project's package runner: `npx shadcn-svelte@latest`, `pnpm dlx shadcn-svelte@latest`, or `bunx --bun shadcn-svelte@latest` — based on the project's package manager. Examples below use `npx shadcn-svelte@latest` but substitute the correct runner for the project.
## Current Project Context
Read `components.json` at the project root and, when you need the live file layout, list the directory given by the `aliases.ui` path (resolved with the same rules as the CLI).
## Imports (Svelte)
Each component lives in its own folder with an `index.ts` barrel. Match the [installation docs](https://shadcn-svelte.com/docs/installation):
- **Multi-part components** (dialog, select, card, field, tabs, …): `import * as Dialog from "$lib/components/ui/dialog"` then `Dialog.Content`, `Dialog.Title`, `Card.Root`, `Card.Header`, etc. — whatever the barrel exports (short names and/or `Root as …` aliases).
- **Single-component barrels** (only one meaningful component in the folder): **named imports** — `import { Button } from "$lib/components/ui/button"` and `<Button>`, not `import * as Button` + `Button.Root`. Same pattern for `{ Input }`, `{ Badge }`, `{ Spinner }`, `{ Checkbox }`, `{ Separator }`, `{ Skeleton }`, etc.
```ts
import * as Dialog from "$lib/components/ui/dialog";
import { Button } from "$lib/components/ui/button";
import { Separator } from "$lib/components/ui/separator";
```
Use the real aliases from `components.json` (often `$lib/components/ui/...`), not hardcoded paths.
## Principles
1. **Use existing components first.** Run `npx shadcn-svelte@latest add` with no arguments to browse available components, or check [Components](https://shadcn-svelte.com/docs/components) before writing custom UI.
2. **Compose, don't reinvent.** Settings page = Tabs + Card + form controls. Dashboard = Sidebar + Card + Chart + Table.
3. **Use built-in variants before custom styles.** `variant="outline"`, `size="sm"`, etc.
4. **Use semantic colors.** `bg-primary`, `text-muted-foreground` — never raw values like `bg-blue-500`.
## Critical Rules
These rules are **always enforced**. Each links to a file with Incorrect/Correct code pairs.
### Styling & Tailwind → [styling.md](./rules/styling.md)
- **`class` for layout, not styling.** Never override component colors or typography.
- **No `space-x-*` or `space-y-*`.** Use `flex` with `gap-*`. For vertical stacks, `flex flex-col gap-*`.
- **Use `size-*` when width and height are equal.** `size-10` not `w-10 h-10`.
- **Use `truncate` shorthand.** Not `overflow-hidden text-ellipsis whitespace-nowrap`.
- **No manual `dark:` color overrides.** Use semantic tokens (`bg-background`, `text-muted-foreground`).
- **Use `cn()` for conditional classes.** Don't write manual template literal ternaries.
- **No manual `z-index` on overlay components.** Dialog, Sheet, Popover, etc. handle their own stacking.
### Forms & Inputs → [forms.md](./rules/forms.md)
- **Forms use `Field.FieldGroup` + `Field.Field`.** Never use raw `div` with `space-y-*` or `grid gap-*` for form layout.
- **`InputGroup` uses `InputGroup.Input`/`InputGroup.Textarea`.** Never raw `Input`/`Textarea` inside `InputGroup.Root`.
- **Buttons inside inputs use `InputGroup.Root` + `InputGroup.Addon`.**
- **Option sets (2–7 choices) use `ToggleGroup`.** Don't loop `Button` with manual active state.
- **`Field.FieldSet` + `Field.FieldLegend` for grouping related checkboxes/radios.** Don't use a `div` with a heading.
- **Field validation uses `data-invalid` + `aria-invalid`.** `data-invalid` on `Field`, `aria-invalid` on the control. For disabled: `data-disabled` on `Field`, `disabled` on the control.
### Component Structure → [composition.md](./rules/composition.md)
- **Items always inside their Group.** `Select.Item` → `Select.Group`. `DropdownMenu.Item` → `DropdownMenu.Group`. `Command.Item` → `Command.Group`.
- **Custom triggers.** Wrap controls in `Dialog.Trigger` / `AlertDialog.Trigger`, or control open state with `bind:open` on the root — see component docs.
- **Dialog, Sheet, and Drawer always need a Title.** `Dialog.Title`, `Sheet.Title`, `Drawer.Title` required for accessibility. Use `class="sr-only"` if visually hidden.
- **Use full Card composition.** `Card.Header`/`Card.Title`/`Card.Description`/`Card.Content`/`Card.Footer`. Don't dump everything in `Card.Content`.
- **Button has no `isPending`/`isLoading`.** Compose with `Spinner` inside `Button` + `disabled`; use `data-icon="inline-start"` / `inline-end` on `Spinner` for correct spacing (`import { Button }`, `import { Spinner }`).
- **`Tabs.Trigger` must be inside `Tabs.List`.** Never render triggers directly in `Tabs`.
- **`Avatar` always needs `Avatar.Fallback`.** For when the image fails to load.
### Use Components, Not Custom Markup → [composition.md](./rules/composition.md)
- **Use existing components before custom markup.** Check if a component exists before writing a styled `div`.
- **Callouts use `Alert`.** Don't build custom styled divs.
- **Empty states use `Empty`.** Don't build custom empty state markup.
- **Toast via `svelte-sonner`.** Use `toast()` from `svelte-sonner` with the Sonner component from your UI folder.
- **Use `Separator`** instead of `<hr>` or a `div` with border-only classes.
- **Use `Skeleton`** for loading placeholders. No custom `animate-pulse` divs.
- **Use `Badge`** instead of custom styled spans.
### Icons → [icons.md](./rules/icons.md)
- **Icons in `<Button>` use `data-icon`.** `data-icon="inline-start"` or `data-icon="inline-end"` on the icon.
- **No sizing classes on icons inside components.** Components handle icon sizing via CSS. No `size-4` or `w-4 h-4`.
- **Pass icons as components.** Import from the configured `iconLibrary` (e.g. `@lucide/svelte`), not string keys.
### CLI
- **Presets** — copy the encoded string from the design-system builder on [shadcn-svelte.com](https://shadcn-svelte.com) and pass it to `npx shadcn-svelte@latest init --preset <code>`.
## Key Patterns
These are the most common patterns that differentiate correct shadcn-svelte code. For edge cases, see the linked rule files above.
```svelte
<script lang="ts">
import * as Field from "$lib/components/ui/field";
import { Input } from "$lib/components/ui/input";
import { Button } from "$lib/components/ui/button";
import SearchIcon from "@lucide/svelte/icons/search";
import { Badge } from "$lib/components/ui/badge";
import * as Avatar from "$lib/components/ui/avatar";
</script>
<!-- Form layout: Field.FieldGroup + Field.Field, not div + Label. -->
<Field.FieldGroup>
<Field.Field>
<Field.FieldLabel for="email">Email</Field.FieldLabel>
<Input id="email" />
</Field.Field>
</Field.FieldGroup>
<!-- Validation: data-invalid on Field, aria-invalid on the control. -->
<Field.Field data-invalid>
<Field.FieldLabel for="email">Email</Field.FieldLabel>
<Input id="email" aria-invalid />
<Field.FieldDescription>Invalid email.</Field.FieldDescription>
</Field.Field>
<!-- Icons in buttons: data-icon, no sizing classes. -->
<Button>
<SearchIcon data-icon="inline-start" />
Search
</Button>
<!-- Spacing: gap-*, not space-y-*. -->
<div class="flex flex-col gap-4"></div>
<!-- Equal dimensions: size-*, not w-* h-*. -->
<Avatar.Root class="size-10">
<Avatar.Image src="/u.png" alt="User" />
<Avatar.Fallback>U</Avatar.Fallback>
</Avatar.Root>
<!-- Status colors: Badge variants or semantic tokens, not raw colors. -->
<Badge variant="secondary">+20.1%</Badge>
```
## Component Selection
| Need | Use |
| -------------------------- | --------------------------------------------------------------------------------------------------- |
| Button/action | `Button` with appropriate variant (`import { Button }`) |
| Form inputs | `Input`, `Select`, `Combobox`, `Switch`, `Checkbox`, `RadioGroup`, `Textarea`, `InputOTP`, `Slider` |
| Toggle between 2–5 options | `ToggleGroup.Root` + `ToggleGroup.Item` |
| Data display | `Table`, `Card`, `Badge`, `Avatar` |
| Navigation | `Sidebar`, `NavigationMenu`, `Breadcrumb`, `Tabs`, `Pagination` |
| Overlays | `Dialog` (modal), `Sheet` (side panel), `Drawer` (bottom sheet), `AlertDialog` (confirmation) |
| Feedback | `svelte-sonner` (toast), `Alert`, `Progress`, `Skeleton`, `Spinner` |
| Command palette | `Command` inside `Dialog` |
| Charts | `Chart` (LayerChart) |
| Layout | `Card`, `Separator`, `Resizable`, `ScrollArea`, `Accordion`, `Collapsible` |
| Empty states | `Empty` |
| Menus | `DropdownMenu`, `ContextMenu`, `Menubar` |
| Tooltips/info | `Tooltip`, `HoverCard`, `Popover` |
## Key Fields
Use `components.json` and the filesystem — not a separate `info` command:
- **`aliases`** → use the actual alias prefix from config (e.g. `$lib/`), never hardcode unrelated projects.
- **`tailwind.css`** → the global CSS file where theme variables live. Edit this file for theme tweaks; don't add a second globals file unless the user already uses one.
- **`style`** → visual treatment (e.g. `nova`, `vega`, …) and registry style path.
- **`iconLibrary`** → determines icon packages (`@lucide/svelte`, `@tabler/icons-svelte`, etc.). Never assume `@lucide/svelte`.
- **`registry`** → where the CLI fetches components; default official registry at `shadcn-svelte.com`.
- **`resolvedPaths`** (conceptual) → the CLI resolves `aliases` to absolute paths; list `aliases.ui` on disk to see installed components.
See [cli.md](./cli.md) for commands and flags.
## Component Docs, Examples, and Usage
Open `https://shadcn-svelte.com/docs/components/<name>.md` for docs and examples. **When creating, fixing, debugging, or using a component, read the official page first** so you follow the documented APIs.
## Workflow
1. **Get project context** — read `components.json` and list the UI components directory when needed.
2. **Check installed components first** — before running `add`, list files under the resolved `ui` path. Don't import components that haven't been added, and don't re-add ones already present unless updating.
3. **Discover components** — `npx shadcn-svelte@latest add` with no arguments (interactive list), or the docs site.
4. **Install or update** — `npx shadcn-svelte@latest add <name>` or a registry **URL**. To refresh existing files from the registry, use `npx shadcn-svelte@latest update` (see [cli.md](./cli.md)).
5. **Fix imports in third-party / URL-added items** — After adding from a custom registry URL, check for hardcoded paths that don't match the project's `aliases`. Rewrite imports to use the project's `ui` / `lib` aliases from `components.json`.
6. **Review added components** — After adding, **read the added files** and verify composition (groups, titles, validation attrs). Align icon imports with `iconLibrary`.
7. **Remote registry items** — Adding by URL is explicit; if the user wants a component from an unknown source, confirm the registry URL or item before running `add`.
## Updating Components
Use the **`update`** command to pull the latest registry versions of components already in the project. Review changes with `git diff` after `update`.
1. Commit or stash local work.
2. Run `npx shadcn-svelte@latest update [component]` or `--all`.
3. Resolve merge conflicts if you had customized files.
4. **Never use `--overwrite` on `add` without the user's explicit approval** when it would destroy intentional edits.
## Quick Reference
```bash
# Initialize shadcn-svelte in your project.
npx shadcn-svelte@latest init
# Initialize with a preset string from the docs site builder.
npx shadcn-svelte@latest init --preset <code>
# Add components (interactive when run with no names).
npx shadcn-svelte@latest add
npx shadcn-svelte@latest add button card dialog
npx shadcn-svelte@latest add --all
# Update components already installed.
npx shadcn-svelte@latest update button
npx shadcn-svelte@latest update --all --yes
# Build a custom registry (registry authors).
npx shadcn-svelte@latest registry build
```
**Registry:** default `https://shadcn-svelte.com/registry` — override in `components.json` if needed.
**Docs:** [shadcn-svelte.com](https://shadcn-svelte.com)
## Detailed References
- [rules/forms.md](./rules/forms.md) — Field.FieldGroup, Field.Field, InputGroup, ToggleGroup, Field.FieldSet, validation states
- [rules/composition.md](./rules/composition.md) — Groups, overlays, Card, Tabs, Avatar, Alert, Empty, Toast, Separator, Skeleton, Badge, Button loading
- [rules/icons.md](./rules/icons.md) — data-icon, icon sizing, passing icon components
- [rules/styling.md](./rules/styling.md) — Semantic colors, variants, class, spacing, size, truncate, dark mode, cn(), z-index
- [cli.md](./cli.md) — Commands, flags, registry
- [customization.md](./customization.md) — Theming, CSS variables, extending components
@@ -0,0 +1,5 @@
interface:
display_name: "shadcn-svelte"
short_description: "Manages shadcn-svelte components — adding, updating, fixing, debugging, styling, and composing UI."
icon_small: "./assets/shadcn-svelte-small.png"
icon_large: "./assets/shadcn-svelte.png"
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

@@ -0,0 +1,166 @@
# shadcn-svelte CLI Reference
Configuration is read from `components.json`. See [components.json](https://shadcn-svelte.com/docs/components-json) on the docs site for the full schema.
> **IMPORTANT:** Always run commands using the project's package runner: `npx shadcn-svelte@latest`, `pnpm dlx shadcn-svelte@latest`, or `bunx --bun shadcn-svelte@latest`. Check `packageManager` from the project (or lockfile) to choose the right one. Examples below use `npx shadcn-svelte@latest` but substitute the correct runner for the project.
> **IMPORTANT:** Only use the flags documented below. Do not invent or guess flags — if a flag isn't listed here, it doesn't exist. The CLI auto-detects the package manager; there is no `--package-manager` flag.
## Contents
- Commands: `init`, `add`, `apply`, `update`, `registry build`
- Proxy / outgoing requests
- Presets (via `init` and `apply`)
---
## Commands
### `init` — Initialize an existing project
```bash
npx shadcn-svelte@latest init [options]
```
Installs dependencies, adds the `cn` util, creates `components.json`, and sets up CSS variables. Run `init` from the root of your project.
| Flag | Short | Description | Default |
| --------------------------- | ----- | ------------------------------------------------------------------------- | --------- |
| `--preset <preset>` | — | Encoded design-system preset string from the docs site | — |
| `-c, --cwd <path>` | `-c` | Working directory | current |
| `-o, --overwrite` | — | Overwrite existing files | `false` |
| `--no-deps` | — | Do not add or install dependencies | — |
| `--skip-preflight` | — | Ignore preflight checks and continue | `false` |
| `--base-color <name>` | — | Base color: `neutral`, `stone`, `zinc`, `mauve`, `olive`, `mist`, `taupe` | — |
| `--css <path>` | — | Path to the global CSS file | — |
| `--components-alias <path>` | — | Import alias for components | — |
| `--lib-alias <path>` | — | Import alias for lib | — |
| `--utils-alias <path>` | — | Import alias for utils | — |
| `--hooks-alias <path>` | — | Import alias for hooks | — |
| `--ui-alias <path>` | — | Import alias for UI components | — |
| `--proxy <proxy>` | — | Fetch registry items through this proxy | env-based |
| `--design-system-url` | — | Optional design-system URL (see docs / preset builder) | — |
| `-h, --help` | `-h` | Help | — |
---
### `add` — Add components
```bash
npx shadcn-svelte@latest add [options] [components...]
```
Adds components from the configured registry. Arguments are component names from the registry index, or a **URL** to a registry JSON item. With **no** component names, the CLI prompts you to pick components interactively.
| Flag | Short | Description | Default |
| ------------------ | ----- | ----------------------------------------------- | --------- |
| `-c, --cwd <path>` | `-c` | Working directory | current |
| `--no-deps` | — | Skip adding and installing package dependencies | — |
| `--skip-preflight` | — | Ignore preflight checks and continue | `false` |
| `-a, --all` | — | Install all UI components | `false` |
| `-y, --yes` | — | Skip confirmation prompt | `false` |
| `-o, --overwrite` | — | Overwrite existing files | `false` |
| `--proxy <proxy>` | — | Fetch components through this proxy | env-based |
| `-h, --help` | `-h` | Help | — |
---
### `apply` — Apply a preset to an existing project
```bash
npx shadcn-svelte@latest apply [options]
```
Applies a design-system preset to a project that has already been initialized. Updates `components.json` with the preset settings, reinstalls existing components (except `utils`) with the new styles, and installs any required dependencies.
Use `--only theme` or `--only font` to apply only part of a preset without reinstalling UI components.
Get a preset code from the builder at [shadcn-svelte.com/create](https://shadcn-svelte.com/create).
| Flag | Short | Description | Default |
| ------------------- | ----- | ---------------------------------------------- | --------- |
| `--preset <preset>` | — | Encoded design-system preset string (required) | — |
| `--only [parts]` | — | Apply only `theme` or `font` from the preset | — |
| `-c, --cwd <path>` | `-c` | Working directory | current |
| `-y, --yes` | `-y` | Overwrite existing files without confirmation | `false` |
| `-s, --silent` | `-s` | Mute output | `false` |
| `--skip-preflight` | — | Ignore preflight checks and continue | `false` |
| `--proxy <proxy>` | — | Fetch registry items through this proxy | env-based |
| `-h, --help` | `-h` | Help | — |
Requires an existing `components.json`. Run `init` first if the project is not yet configured.
---
### `update` — Update installed components
```bash
npx shadcn-svelte@latest update [options] [components...]
```
Re-fetches and applies registry content for components **already present** in the project. Run `shadcn-svelte update --help` for options.
| Flag | Short | Description | Default |
| ------------------ | ----- | ----------------------------------------------- | --------- |
| `-c, --cwd <path>` | `-c` | Working directory | current |
| `--skip-preflight` | — | Ignore preflight checks and continue | `false` |
| `--no-deps` | — | Skip adding and installing package dependencies | — |
| `-a, --all` | — | Update every installed component | `false` |
| `-y, --yes` | — | Skip confirmation prompt | `false` |
| `--proxy <proxy>` | — | Fetch through this proxy | env-based |
| `-h, --help` | `-h` | Help | — |
Commit your work before updating; overwrites are destructive.
---
### `registry build` — Build a custom registry
```bash
npx shadcn-svelte@latest registry build [options] [registry]
```
Reads a `registry.json` and writes registry JSON files for distribution. Default input: `./registry.json`, default output: `./static/r`.
| Flag | Short | Description | Default |
| --------------------- | ----- | ------------------------------- | ------------ |
| `-c, --cwd <path>` | `-c` | Working directory | current |
| `-o, --output <path>` | `-o` | Output directory for JSON files | `./static/r` |
| `-h, --help` | `-h` | Help | — |
---
## Outgoing Requests
### Proxy
The CLI can fetch the registry through a proxy. If `HTTP_PROXY` or `http_proxy` is set, requests respect it. You can also pass `--proxy` on `init`, `add`, `apply`, or `update`.
```bash
HTTP_PROXY="<proxy-url>" npx shadcn-svelte@latest init
```
---
## Presets
Design-system options (style, theme, icons, fonts, etc.) can be captured as an encoded **preset** string from the builder on [shadcn-svelte.com/create](https://shadcn-svelte.com/create).
- **New project:** pass the preset to **`init`** with `--preset <string>`.
- **Existing project:** use **`apply --preset <string>`** to update configuration, restyle installed components, and install any new dependencies.
---
## `components.json` — useful fields for agents
| Field / path | Meaning |
| -------------------- | ---------------------------------------------------------------- |
| `tailwind.css` | Global CSS file path (Tailwind entry / theme variables) |
| `tailwind.baseColor` | Base palette (cannot change after init) |
| `aliases.*` | Import aliases; must match `svelte.config.js` / `tsconfig` paths |
| `registry` | Base registry URL (default `https://shadcn-svelte.com/registry`) |
| `style` | Registered style name (e.g. `nova`, `vega`, …) |
| `iconLibrary` | Icon set key (`lucide`, `tabler`, …) — drives generated imports |
| `typescript` | Whether TS and optional custom config path |
Resolved paths (including `tailwindCss`, `ui`, `components`) are computed by the CLI from `components.json` and the filesystem. Read `components.json` and list the UI directory when you need a snapshot of what is installed.
@@ -0,0 +1,213 @@
# Customization & Theming
Components reference semantic CSS variable tokens. Change the variables to change every component.
## Contents
- How it works (CSS variables → Tailwind utilities → components)
- Color variables and OKLCH format
- Dark mode setup
- Changing the theme (presets, CSS variables)
- Adding custom colors (Tailwind v3 and v4)
- Border radius
- Customizing components (variants, class, wrappers)
- Checking for updates
---
## How It Works
1. CSS variables defined in `:root` (light) and `.dark` (dark mode).
2. Tailwind maps them to utilities: `bg-primary`, `text-muted-foreground`, etc.
3. Components use these utilities — changing a variable changes all components that reference it.
---
## Color Variables
Every color follows the `name` / `name-foreground` convention. The base variable is for backgrounds, `-foreground` is for text/icons on that background.
| Variable | Purpose |
| -------------------------------------------- | -------------------------------- |
| `--background` / `--foreground` | Page background and default text |
| `--card` / `--card-foreground` | Card surfaces |
| `--primary` / `--primary-foreground` | Primary buttons and actions |
| `--secondary` / `--secondary-foreground` | Secondary actions |
| `--muted` / `--muted-foreground` | Muted/disabled states |
| `--accent` / `--accent-foreground` | Hover and accent states |
| `--destructive` / `--destructive-foreground` | Error and destructive actions |
| `--border` | Default border color |
| `--input` | Form input borders |
| `--ring` | Focus ring color |
| `--chart-1` through `--chart-5` | Chart/data visualization |
| `--sidebar-*` | Sidebar-specific colors |
| `--surface` / `--surface-foreground` | Secondary surface |
Colors use OKLCH: `--primary: oklch(0.205 0 0)` where values are lightness (0–1), chroma (0 = gray), and hue (0–360).
---
## Dark Mode
Class-based toggle via `.dark` on the root element. In SvelteKit, use [mode-watcher](https://github.com/svecosystem/mode-watcher) (see [Dark mode — Svelte](https://shadcn-svelte.com/docs/dark-mode/svelte)):
```svelte
<script lang="ts">
import { ModeWatcher } from "mode-watcher";
let { children } = $props();
</script>
<ModeWatcher />
{@render children?.()}
```
---
## Changing the Theme
Use a **preset** from the design-system builder on [shadcn-svelte.com](https://shadcn-svelte.com) and pass it to `init`:
```bash
npx shadcn-svelte@latest init --preset <code>
```
Or edit CSS variables directly in the file set in `components.json` as `tailwind.css` (for example `src/app.css`).
To align config and components with a new preset, re-run `init` with `--preset` and confirm overwrites when prompted.
---
## Adding Custom Colors
Add variables to the global CSS file path in `components.json` (`tailwind.css`). Do not create a second global CSS file for theming unless the project already uses that pattern.
```css
/* 1. Define in the global CSS file. */
:root {
--warning: oklch(0.84 0.16 84);
--warning-foreground: oklch(0.28 0.07 46);
}
.dark {
--warning: oklch(0.41 0.11 46);
--warning-foreground: oklch(0.99 0.02 95);
}
```
```css
/* 2a. Register with Tailwind v4 (@theme inline). */
@theme inline {
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
}
```
On Tailwind v3, register in `tailwind.config.js` (see the [Tailwind v3 docs](https://tw3.shadcn-svelte.com) if you maintain a legacy setup):
```js
// 2b. Register with Tailwind v3 (tailwind.config.js).
module.exports = {
theme: {
extend: {
colors: {
warning: "oklch(var(--warning) / <alpha-value>)",
"warning-foreground":
"oklch(var(--warning-foreground) / <alpha-value>)",
},
},
},
};
```
```svelte
<!-- 3. Use in components. -->
<div class="bg-warning text-warning-foreground">Warning</div>
```
---
## Border Radius
`--radius` controls border radius globally. Components derive values from it (`rounded-lg` = `var(--radius)`, `rounded-md` = `calc(var(--radius) - 2px)`).
---
## Customizing Components
See also: [rules/styling.md](./rules/styling.md) for Incorrect/Correct examples.
Prefer these approaches in order:
### 1. Built-in variants
```svelte
<script lang="ts">
import { Button } from "$lib/components/ui/button";
</script>
<Button variant="outline" size="sm">Click</Button>
```
### 2. Tailwind classes via `class`
```svelte
<script lang="ts">
import * as Card from "$lib/components/ui/card";
</script>
<Card.Root class="mx-auto max-w-md">
<Card.Content>...</Card.Content>
</Card.Root>
```
### 3. Add a new variant
Edit the component source to add a variant via `tailwind-variants` / `cva` in the `.svelte` or shared variants file:
```ts
// e.g. in button variants
warning: "bg-warning text-warning-foreground hover:bg-warning/90",
```
### 4. Wrapper components
Compose shadcn-svelte primitives into higher-level `.svelte` files:
```svelte
<script lang="ts">
import * as AlertDialog from "$lib/components/ui/alert-dialog";
let { title, description, onConfirm, children } = $props();
let open = $state(false);
</script>
<AlertDialog.Root bind:open>
<AlertDialog.Trigger>
{@render children?.()}
</AlertDialog.Trigger>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>{title}</AlertDialog.Title>
<AlertDialog.Description>{description}</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<AlertDialog.Action
onclick={() => {
onConfirm?.();
open = false;
}}>Confirm</AlertDialog.Action
>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
```
---
## Checking for Updates
```bash
npx shadcn-svelte@latest update button
npx shadcn-svelte@latest update --all
```
See [Updating Components in SKILL.md](./SKILL.md#updating-components). Review `git diff` after `update` to see what changed.
@@ -0,0 +1,47 @@
{
"skill_name": "shadcn-svelte",
"evals": [
{
"id": 1,
"prompt": "I'm building a SvelteKit app with shadcn-svelte (nova style, lucide icons). Create a settings form component with fields for: full name, email address, and notification preferences (email, SMS, push notifications as toggle options). Add validation states for required fields.",
"expected_output": "A Svelte component using Field.FieldGroup, Field.Field, ToggleGroup, data-invalid/aria-invalid validation, gap-* spacing, and semantic colors.",
"files": [],
"expectations": [
"Uses Field.FieldGroup and Field.Field for form layout instead of raw div with space-y",
"Uses Switch for independent on/off notification toggles (not looping Button with manual active state)",
"Uses data-invalid on Field and aria-invalid on the input control for validation states",
"Uses gap-* (e.g. gap-4, gap-6) instead of space-y-* or space-x-* for spacing",
"Uses semantic color tokens (e.g. bg-background, text-muted-foreground, text-destructive) instead of raw colors like bg-red-500",
"No manual dark: color overrides"
]
},
{
"id": 2,
"prompt": "Create a dialog component for editing a user profile. It should have the user's avatar at the top, input fields for name and bio, and Save/Cancel buttons with appropriate icons. Using shadcn-svelte with tabler icons.",
"expected_output": "A Svelte component with Dialog.Title, Avatar with Avatar.Fallback, data-icon on icon buttons, no icon sizing classes, @tabler/icons-svelte imports.",
"files": [],
"expectations": [
"Includes Dialog.Title for accessibility (visible or with sr-only class)",
"Avatar includes Avatar.Fallback",
"Icons on buttons use the data-icon attribute (data-icon=\"inline-start\" or data-icon=\"inline-end\")",
"No sizing classes on icons inside components (no size-4, w-4, h-4, etc.)",
"Uses tabler icons (@tabler/icons-svelte) instead of @lucide/svelte when tabler is configured",
"Uses shadcn-svelte Dialog patterns (e.g. Dialog.Trigger wrapping the control, or bind:open on Dialog.Root)"
]
},
{
"id": 3,
"prompt": "Create a dashboard component that shows 4 stat cards in a grid. Each card has a title, large number, percentage change badge, and a loading skeleton state. Using shadcn-svelte with lucide icons.",
"expected_output": "A Svelte component with full Card composition, Skeleton for loading, Badge for changes, semantic colors, gap-* spacing.",
"files": [],
"expectations": [
"Uses full Card composition with Card.Header, Card.Title, Card.Content (not dumping everything into Card.Content)",
"Uses Skeleton component for loading placeholders instead of custom animate-pulse divs",
"Uses Badge component for percentage change instead of custom styled spans",
"Uses semantic color tokens instead of raw color values like bg-green-500 or text-red-600",
"Uses gap-* instead of space-y-* or space-x-* for spacing",
"Uses size-* when width and height are equal instead of separate w-* h-*"
]
}
]
}
@@ -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]`.
@@ -0,0 +1,64 @@
---
name: svelte-code-writer
description: CLI tools for Svelte 5 documentation lookup and code analysis. MUST be used whenever creating, editing or analyzing any Svelte component (.svelte) or Svelte module (.svelte.ts/.svelte.js). If possible, this skill should be executed within the svelte-file-editor agent for optimal results.
---
## CLI tools
You have access to `@sveltejs/mcp` CLI for Svelte-specific assistance. Use these commands via `npx`:
### List documentation sections
```bash
npx @sveltejs/mcp list-sections
```
Lists all available Svelte 5 and SvelteKit documentation sections with titles and paths.
### Get documentation
```bash
npx @sveltejs/mcp get-documentation "<section1>,<section2>,..."
```
Retrieves full documentation for specified sections. Use after `list-sections` to fetch relevant docs.
**Example:**
```bash
npx @sveltejs/mcp get-documentation "$state,$derived,$effect"
```
### Svelte autofixer
```bash
npx @sveltejs/mcp svelte-autofixer "<code_or_path>" [options]
```
Analyzes Svelte code and suggests fixes for common issues.
**Options:**
- `--async` - Enable async Svelte mode (default: false)
- `--svelte-version` - Target version: 4 or 5 (default: 5)
**Examples:**
```bash
# Analyze inline code (escape $ as \$)
npx @sveltejs/mcp svelte-autofixer '<script>let count = \$state(0);</script>'
# Analyze a file
npx @sveltejs/mcp svelte-autofixer ./src/lib/Component.svelte
# Target Svelte 4
npx @sveltejs/mcp svelte-autofixer ./Component.svelte --svelte-version 4
```
**Important:** When passing code with runes (`$state`, `$derived`, etc.) via the terminal, escape the `$` character as `\$` to prevent shell variable substitution.
## Workflow
1. **Uncertain about syntax?** Run `list-sections` then `get-documentation` for relevant topics
2. **Reviewing/debugging?** Run `svelte-autofixer` on the code to detect issues
3. **Always validate** - Run `svelte-autofixer` before finalizing any Svelte component
@@ -0,0 +1,176 @@
---
name: svelte-core-bestpractices
description: Guidance on writing fast, robust, modern Svelte code. Load this skill whenever in a Svelte project and asked to write/edit or analyze a Svelte component or module. Covers reactivity, event handling, styling, integration with libraries and more.
---
## `$state`
Only use the `$state` rune for variables that should be _reactive_ — in other words, variables that cause an `$effect`, `$derived` or template expression to update. Everything else can be a normal variable.
Objects and arrays (`$state({...})` or `$state([...])`) are made deeply reactive, meaning mutation will trigger updates. This has a trade-off: in exchange for fine-grained reactivity, the objects must be proxied, which has performance overhead. In cases where you're dealing with large objects that are only ever reassigned (rather than mutated), use `$state.raw` instead. This is often the case with API responses, for example.
## `$derived`
To compute something from state, use `$derived` rather than `$effect`:
```js
// do this
let square = $derived(num * num);
// don't do this
let square;
$effect(() => {
square = num * num;
});
```
> [!NOTE] `$derived` is given an expression, _not_ a function. If you need to use a function (because the expression is complex, for example) use `$derived.by`.
Deriveds are writable — you can assign to them, just like `$state`, except that they will re-evaluate when their expression changes.
If the derived expression is an object or array, it will be returned as-is — it is _not_ made deeply reactive. You can, however, use `$state` inside `$derived.by` in the rare cases that you need this.
## `$effect`
Effects are an escape hatch and should mostly be avoided. In particular, avoid updating state inside effects.
- If you need to sync state to an external library such as D3, it is often neater to use [`{@attach ...}`](references/attach.md)
- If you need to run some code in response to user interaction, put the code directly in an event handler or use a [function binding](references/bind.md) as appropriate
- If you need to log values for debugging purposes, use [`$inspect`](references/inspect.md)
- If you need to observe something external to Svelte, use [`createSubscriber`](references/svelte-reactivity.md)
Never wrap the contents of an effect in `if (browser) {...}` or similar — effects do not run on the server.
## `$props`
Treat props as though they will change. For example, values that depend on props should usually use `$derived`:
```js
// @errors: 2451
let { type } = $props();
// do this
let color = $derived(type === 'danger' ? 'red' : 'green');
// don't do this — `color` will not update if `type` changes
let color = type === 'danger' ? 'red' : 'green';
```
## `$inspect.trace`
`$inspect.trace` is a debugging tool for reactivity. If something is not updating properly or running more than it should you can add `$inspect.trace(label)` as the first line of an `$effect` or `$derived.by` (or any function they call) to trace their dependencies and discover which one triggered an update.
## Events
Any element attribute starting with `on` is treated as an event listener:
```svelte
<button onclick={() => {...}}>click me</button>
<!-- attribute shorthand also works -->
<button {onclick}>...</button>
<!-- so do spread attributes -->
<button {...props}>...</button>
```
If you need to attach listeners to `window` or `document` you can use `<svelte:window>` and `<svelte:document>`:
```svelte
<svelte:window onkeydown={...} />
<svelte:document onvisibilitychange={...} />
```
Avoid using `onMount` or `$effect` for this.
## Snippets
[Snippets](references/snippet.md) are a way to define reusable chunks of markup that can be instantiated with the [`{@render ...}`](references/render.md) tag, or passed to components as props. They must be declared within the template.
```svelte
{#snippet greeting(name)}
<p>hello {name}!</p>
{/snippet}
{@render greeting('world')}
```
> [!NOTE] Snippets declared at the top level of a component (i.e. not inside elements or blocks) can be referenced inside `<script>`. A snippet that doesn't reference component state is also available in a `<script module>`, in which case it can be exported for use by other components.
## Each blocks
Prefer to use [keyed each blocks](references/each.md) — this improves performance by allowing Svelte to surgically insert or remove items rather than updating the DOM belonging to existing items.
> [!NOTE] The key _must_ uniquely identify the object. Do not use the index as a key.
Avoid destructuring if you need to mutate the item (with something like `bind:value={item.count}`, for example).
## Using JavaScript variables in CSS
If you have a JS variable that you want to use inside CSS you can set a CSS custom property with the `style:` directive.
```svelte
<div style:--columns={columns}>...</div>
```
You can then reference `var(--columns)` inside the component's `<style>`.
## Styling child components
The CSS in a component's `<style>` is scoped to that component. If a parent component needs to control the child's styles, the preferred way is to use CSS custom properties:
```svelte
<!-- Parent.svelte -->
<Child --color="red" />
<!-- Child.svelte -->
<h1>Hello</h1>
<style>
h1 {
color: var(--color);
}
</style>
```
If this is impossible (for example, the child component comes from a library) you can use `:global` to override styles:
```svelte
<div>
<Child />
</div>
<style>
div :global {
h1 {
color: red;
}
}
</style>
```
## Context
Consider using context instead of declaring state in a shared module. This will scope the state to the part of the app that needs it, and eliminate the possibility of it leaking between users when server-side rendering.
Use `createContext` rather than `setContext` and `getContext`, as it provides type safety.
## Async Svelte
If using version 5.36 or higher, you can use [await expressions](references/await-expressions.md) and [hydratable](references/hydratable.md) to use promises directly inside components. Note that these require the `experimental.async` option to be enabled in `svelte.config.js` as they are not yet considered fully stable.
## Avoid legacy features
Always use runes mode for new code, and avoid features that have more modern replacements:
- use `$state` instead of implicit reactivity (e.g. `let count = 0; count += 1`)
- use `$derived` and `$effect` instead of `$:` assignments and statements (but only use effects when there is no better solution)
- use `$props` instead of `export let`, `$$props` and `$$restProps`
- use `onclick={...}` instead of `on:click={...}`
- use `{#snippet ...}` and `{@render ...}` instead of `<slot>` and `$$slots` and `<svelte:fragment>`
- use `<DynamicComponent>` instead of `<svelte:component this={DynamicComponent}>`
- use `import Self from './ThisComponent.svelte'` and `<Self>` instead of `<svelte:self>`
- use classes with `$state` fields to share reactivity between components, instead of using stores
- use `{@attach ...}` instead of `use:action`
- use clsx-style arrays and objects in `class` attributes, instead of the `class:` directive
@@ -0,0 +1,170 @@
Attachments are functions that run in an [effect](https://svelte.dev/docs/svelte/$effect/llms.txt) when an element is mounted to the DOM or when [state](https://svelte.dev/docs/svelte/$state/llms.txt) read inside the function updates.
Optionally, they can return a function that is called before the attachment re-runs, or after the element is later removed from the DOM.
> [!NOTE]
> Attachments are available in Svelte 5.29 and newer.
```svelte
<!--- file: App.svelte --->
<script>
/** @type {import('svelte/attachments').Attachment} */
function myAttachment(element) {
console.log(element.nodeName); // 'DIV'
return () => {
console.log('cleaning up');
};
}
</script>
<div {@attach myAttachment}>...</div>
```
An element can have any number of attachments.
## Attachment factories
A useful pattern is for a function, such as `tooltip` in this example, to _return_ an attachment (demo:
```svelte
<!--- file: App.svelte --->
<script>
import tippy from 'tippy.js';
let content = $state('Hello!');
/**
* @param {string} content
* @returns {import('svelte/attachments').Attachment}
*/
function tooltip(content) {
return (element) => {
const tooltip = tippy(element, { content });
return tooltip.destroy;
};
}
</script>
<input bind:value={content} />
<button {@attach tooltip(content)}>
Hover me
</button>
```
Since the `tooltip(content)` expression runs inside an [effect](https://svelte.dev/docs/svelte/$effect/llms.txt), the attachment will be destroyed and recreated whenever `content` changes. The same thing would happen for any state read _inside_ the attachment function when it first runs. (If this isn't what you want, see [Controlling when attachments re-run](#Controlling-when-attachments-re-run).)
## Inline attachments
Attachments can also be created inline (demo:
```svelte
<!--- file: App.svelte --->
<canvas
width={32}
height={32}
{@attach (canvas) => {
const context = canvas.getContext('2d');
$effect(() => {
context.fillStyle = color;
context.fillRect(0, 0, canvas.width, canvas.height);
});
}}
></canvas>
```
> [!NOTE]
> The nested effect runs whenever `color` changes, while the outer effect (where `canvas.getContext(...)` is called) only runs once, since it doesn't read any reactive state.
## Conditional attachments
Falsy values like `false` or `undefined` are treated as no attachment, enabling conditional usage:
```svelte
<div {@attach enabled && myAttachment}>...</div>
```
## Passing attachments to components
When used on a component, `{@attach ...}` will create a prop whose key is a [`Symbol`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol). If the component then [spreads](https://svelte.dev/tutorial/svelte/spread-props/llms.txt) props onto an element, the element will receive those attachments.
This allows you to create _wrapper components_ that augment elements (demo:
```svelte
<!--- file: Button.svelte --->
<script>
/** @type {import('svelte/elements').HTMLButtonAttributes} */
let { children, ...props } = $props();
</script>
<!-- `props` includes attachments -->
<button {...props}>
{@render children?.()}
</button>
```
```svelte
<!--- file: App.svelte --->
<script>
import tippy from 'tippy.js';
import Button from './Button.svelte';
let content = $state('Hello!');
/**
* @param {string} content
* @returns {import('svelte/attachments').Attachment}
*/
function tooltip(content) {
return (element) => {
const tooltip = tippy(element, { content });
return tooltip.destroy;
};
}
</script>
<input bind:value={content} />
<Button {@attach tooltip(content)}>
Hover me
</Button>
```
## Controlling when attachments re-run
Attachments, unlike [actions](https://svelte.dev/docs/svelte/use/llms.txt), are fully reactive: `{@attach foo(bar)}` will re-run on changes to `foo` _or_ `bar` (or any state read inside `foo`):
```js
// @errors: 7006 2304 2552
function foo(bar) {
return (node) => {
veryExpensiveSetupWork(node);
update(node, bar);
};
}
```
In the rare case that this is a problem (for example, if `foo` does expensive and unavoidable setup work) consider passing the data inside a function and reading it in a child effect:
```js
// @errors: 7006 2304 2552
function foo(+++getBar+++) {
return (node) => {
veryExpensiveSetupWork(node);
+++ $effect(() => {
update(node, getBar());
});+++
}
}
```
## Creating attachments programmatically
To add attachments to an object that will be spread onto a component or element, use [`createAttachmentKey`](https://svelte.dev/docs/svelte/svelte-attachments/llms.txt#createAttachmentKey).
## Converting actions to attachments
If you're using a library that only provides actions, you can convert them to attachments with [`fromAction`](https://svelte.dev/docs/svelte/svelte-attachments/llms.txt#fromAction), allowing you to (for example) use them with components.
@@ -0,0 +1,187 @@
As of Svelte 5.36, you can use the `await` keyword inside your components in three places where it was previously unavailable:
- at the top level of your component's `<script>`
- inside `$derived(...)` declarations
- inside your markup
This feature is currently experimental, and you must opt in by adding the `experimental.async` option wherever you [configure](https://svelte.dev/docs/kit/configuration/llms.txt) Svelte, usually `svelte.config.js`:
```js
/// file: svelte.config.js
export default {
compilerOptions: {
experimental: {
async: true
}
}
};
```
The experimental flag will be removed in Svelte 6.
## Synchronized updates
When an `await` expression depends on a particular piece of state, changes to that state will not be reflected in the UI until the asynchronous work has completed, so that the UI is not left in an inconsistent state. In other words, in an example like this...
<!-- codeblock:start {"title":"Synchronized updates"} -->
```svelte
<!--- file: App.svelte --->
<script>
let a = $state(1);
let b = $state(2);
async function add(a, b) {
await new Promise((f) => setTimeout(f, 500)); // artificial delay
return a + b;
}
</script>
<input type="number" bind:value={a}>
<input type="number" bind:value={b}>
<p>{a} + {b} = {await add(a, b)}</p>
```
<!-- codeblock:end -->
...if you increment `a`, the contents of the `<p>` will _not_ immediately update to read this —
```html
<p>2 + 2 = 3</p>
```
— instead, the text will update to `2 + 2 = 4` when `add(a, b)` resolves.
Updates can overlap — a fast update will be reflected in the UI while an earlier slow update is still ongoing.
## Concurrency
Svelte will do as much asynchronous work as it can in parallel. For example if you have two `await` expressions in your markup...
```svelte
<p>{await one(x)}</p>
<p>{await two(y)}</p>
```
...both functions will run at the same time, as they are independent expressions, even though they are _visually_ sequential.
This does not apply to sequential `await` expressions inside your `<script>` or inside async functions — these run like any other asynchronous JavaScript. An exception is that independent `$derived` expressions will update independently, even though they will run sequentially when they are first created:
```js
// `b` will not be created until `a` has resolved,
// but once created they will update independently
// even if `x` and `y` update simultaneously
let a = $derived(await one(x));
let b = $derived(await two(y));
```
> [!NOTE] If you write code like this, expect Svelte to give you an [`await_waterfall`](https://svelte.dev/docs/svelte/runtime-warnings/llms.txt#Client-warnings-await_waterfall) warning
## Indicating loading states
To render placeholder UI, you can wrap content in a `<svelte:boundary>` with a [`pending`](https://svelte.dev/docs/svelte/svelte-boundary/llms.txt#Properties-pending) snippet. This will be shown when the boundary is first created, but not for subsequent updates, which are globally coordinated.
After the contents of a boundary have resolved for the first time and have replaced the `pending` snippet, you can detect subsequent async work with [`$effect.pending()`](https://svelte.dev/docs/svelte/$effect/llms.txt#$effect.pending). This is what you would use to display a "we're asynchronously validating your input" spinner next to a form field, for example.
You can also use [`settled()`](https://svelte.dev/docs/svelte/svelte/llms.txt#settled) to get a promise that resolves when the current update is complete:
```js
import { tick, settled } from 'svelte';
async function onclick() {
updating = true;
// without this, the change to `updating` will be
// grouped with the other changes, meaning it
// won't be reflected in the UI
await tick();
color = 'octarine';
answer = 42;
await settled();
// any updates affected by `color` or `answer`
// have now been applied
updating = false;
}
```
## Error handling
Errors in `await` expressions will bubble to the nearest [error boundary](https://svelte.dev/docs/svelte/svelte-boundary/llms.txt).
## Server-side rendering
Svelte supports asynchronous server-side rendering (SSR) with the `render(...)` API. To use it, simply await the return value:
```js
/// file: server.js
import { render } from 'svelte/server';
import App from './App.svelte';
const { head, body } = +++await+++ render(App);
```
> [!NOTE] If you're using a framework like SvelteKit, this is done on your behalf.
If a `<svelte:boundary>` with a `pending` snippet is encountered during SSR, that snippet will be rendered while the rest of the content is ignored. All `await` expressions encountered outside boundaries with `pending` snippets will resolve and render their contents prior to `await render(...)` returning.
> [!NOTE] In the future, we plan to add a streaming implementation that renders the content in the background.
## Forking
The [`fork(...)`](https://svelte.dev/docs/svelte/svelte/llms.txt#fork) API, added in 5.42, makes it possible to run `await` expressions that you _expect_ to happen in the near future. This is mainly intended for frameworks like SvelteKit to implement preloading when (for example) users signal an intent to navigate.
```svelte
<script>
import { fork } from 'svelte';
import Menu from './Menu.svelte';
let open = $state(false);
/** @type {import('svelte').Fork | null} */
let pending = null;
function preload() {
pending ??= fork(() => {
open = true;
});
}
function discard() {
pending?.discard();
pending = null;
}
</script>
<button
onfocusin={preload}
onfocusout={discard}
onpointerenter={preload}
onpointerleave={discard}
onclick={() => {
pending?.commit();
pending = null;
// in case `pending` didn't exist
// (if it did, this is a no-op)
open = true;
}}
>open menu</button>
{#if open}
<!-- any async work inside this component will start
as soon as the fork is created -->
<Menu onclose={() => open = false} />
{/if}
```
## Caveats
As an experimental feature, the details of how `await` is handled (and related APIs like `$effect.pending()`) are subject to breaking changes outside of a semver major release, though we intend to keep such changes to a bare minimum.
## Breaking changes
Effects run in a slightly different order when the `experimental.async` option is `true`. Specifically, _block_ effects like `{#if ...}` and `{#each ...}` now run before an `$effect.pre` or `beforeUpdate` in the same component, which means that in very rare situations.
@@ -0,0 +1,22 @@
## Function bindings
You can also use `bind:property={get, set}`, where `get` and `set` are functions, allowing you to perform validation and transformation:
```svelte
<input bind:value={
() => value,
(v) => value = v.toLowerCase()}
/>
```
In the case of readonly bindings like [dimension bindings](#Dimensions), the `get` value should be `null`:
```svelte
<div
bind:clientWidth={null, redraw}
bind:clientHeight={null, redraw}
>...</div>
```
> [!NOTE]
> Function bindings are available in Svelte 5.9.0 and newer.
@@ -0,0 +1,42 @@
## Keyed each blocks
```svelte
<!--- copy: false --->
{#each expression as name (key)}...{/each}
```
```svelte
<!--- copy: false --->
{#each expression as name, index (key)}...{/each}
```
If a _key_ expression is provided — which must uniquely identify each list item — Svelte will use it to intelligently update the list when data changes by inserting, moving and deleting items, rather than adding or removing items at the end and updating the state in the middle.
The key can be any object, but strings and numbers are recommended since they allow identity to persist when the objects themselves change.
```svelte
{#each items as item (item.id)}
<li>{item.name} x {item.qty}</li>
{/each}
<!-- or with additional index value -->
{#each items as item, i (item.id)}
<li>{i + 1}: {item.name} x {item.qty}</li>
{/each}
```
You can freely use destructuring and rest patterns in each blocks.
```svelte
{#each items as { id, name, qty }, i (id)}
<li>{i + 1}: {name} x {qty}</li>
{/each}
{#each objects as { id, ...rest }}
<li><span>{id}</span><MyComponent {...rest} /></li>
{/each}
{#each items as [id, ...rest]}
<li><span>{id}</span><MyComponent values={rest} /></li>
{/each}
```
@@ -0,0 +1,103 @@
In Svelte, when you want to render asynchronous content data on the server, you can simply `await` it. This is great! However, it comes with a pitfall: when hydrating that content on the client, Svelte has to redo the asynchronous work, which blocks hydration for however long it takes:
```svelte
<script>
import { getUser } from 'my-database-library';
// This will get the user on the server, render the user's name into the h1,
// and then, during hydration on the client, it will get the user _again_,
// blocking hydration until it's done.
const user = await getUser();
</script>
<h1>{user.name}</h1>
```
That's silly, though. If we've already done the hard work of getting the data on the server, we don't want to get it again during hydration on the client. `hydratable` is a low-level API built to solve this problem. You probably won't need this very often — it will be used behind the scenes by whatever datafetching library you use. For example, it powers [remote functions in SvelteKit](https://svelte.dev/docs/kit/remote-functions/llms.txt).
To fix the example above:
```svelte
<script>
import { hydratable } from 'svelte';
import { getUser } from 'my-database-library';
// During server rendering, this will serialize and stash the result of `getUser`, associating
// it with the provided key and baking it into the `head` content. During hydration, it will
// look for the serialized version, returning it instead of running `getUser`. After hydration
// is done, if it's called again, it'll simply invoke `getUser`.
const user = await hydratable('user', () => getUser());
</script>
<h1>{user.name}</h1>
```
This API can also be used to provide access to random or time-based values that are stable between server rendering and hydration. For example, to get a random number that doesn't update on hydration:
```ts
import { hydratable } from 'svelte';
const rand = hydratable('random', () => Math.random());
```
If you're a library author, be sure to prefix the keys of your `hydratable` values with the name of your library so that your keys don't conflict with other libraries.
## Serialization
All data returned from a `hydratable` function must be serializable. But this doesn't mean you're limited to JSON — Svelte uses [`devalue`](https://npmjs.com/package/devalue), which can serialize all sorts of things including `Map`, `Set`, `URL`, and `BigInt`. Check the documentation page for a full list. In addition to these, thanks to some Svelte magic, you can also fearlessly use promises:
```svelte
<script>
import { hydratable } from 'svelte';
const promises = hydratable('random', () => {
return {
one: Promise.resolve(1),
two: Promise.resolve(2)
}
});
</script>
{await promises.one}
{await promises.two}
```
## CSP
`hydratable` adds an inline `<script>` block to the `head` returned from `render`. If you're using [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP) (CSP), this script will likely fail to run. You can provide a `nonce` to `render`:
```js
const nonce = crypto.randomUUID();
const { head, body } = await render(App, {
csp: { nonce }
});
```
This will add the `nonce` to the script block, on the assumption that you will later add the same nonce to the CSP header of the document that contains it:
```js
response.headers.set(
'Content-Security-Policy',
`script-src 'nonce-${nonce}'`
);
```
It's essential that a `nonce` — which, British slang definition aside, means 'number used once' — is only used when dynamically server rendering an individual response.
If instead you are generating static HTML ahead of time, you must use hashes instead:
```js
const { head, body, hashes } = await render(App, {
csp: { hash: true }
});
```
`hashes.script` will be an array of strings like `["sha256-abcd123"]`. As with `nonce`, the hashes should be used in your CSP header:
```js
response.headers.set(
'Content-Security-Policy',
`script-src ${hashes.script.map((hash) => `'${hash}'`).join(' ')}`
);
```
We recommend using `nonce` over hash if you can, as `hash` will interfere with streaming SSR in the future.
@@ -0,0 +1,63 @@
> [!NOTE] `$inspect` only works during development. In a production build it becomes a noop.
The `$inspect` rune is roughly equivalent to `console.log`, with the exception that it will re-run whenever its argument changes. `$inspect` tracks reactive state deeply, meaning that updating something inside an object or array using fine-grained reactivity will cause it to re-fire:
<!-- codeblock:start {"title":"$inspect(...)"} -->
```svelte
<!--- file: App.svelte --->
<script>
let count = $state(0);
let message = $state('hello');
$inspect(count, message); // will console.log when `count` or `message` change
</script>
<button onclick={() => count++}>Increment</button>
<input bind:value={message} />
```
<!-- codeblock:end -->
On updates, a stack trace will be printed, making it easy to find the origin of a state change (unless you're in the playground, due to technical limitations).
## $inspect(...).with
`$inspect(...)` returns an object with a `with` method, which you can invoke with a callback that will then be invoked instead of `console.log`. The first argument to the callback is either `"init"` or `"update"`; subsequent arguments are the values passed to `$inspect`:
<!-- codeblock:start {"title":"$inspect(...).with(...)"} -->
```svelte
<!--- file: App.svelte --->
<script>
let count = $state(0);
$inspect(count).with((type, count) => {
if (type === 'update') {
debugger; // or `console.trace`, or whatever you want
}
});
</script>
<button onclick={() => count++}>Increment</button>
```
<!-- codeblock:end -->
## $inspect.trace(...)
This rune, added in 5.14, causes the surrounding function to be _traced_ in development. Any time the function re-runs as part of an [effect](https://svelte.dev/docs/svelte/$effect/llms.txt) or a [derived](https://svelte.dev/docs/svelte/$derived/llms.txt), information will be printed to the console about which pieces of reactive state caused the effect to fire.
```svelte
<script>
import { doSomeWork } from './elsewhere';
$effect(() => {
+++// $inspect.trace must be the first statement of a function body+++
+++$inspect.trace();+++
doSomeWork();
});
</script>
```
`$inspect.trace` takes an optional first argument which will be used as the label.
@@ -0,0 +1,35 @@
To render a [snippet](https://svelte.dev/docs/svelte/snippet/llms.txt), use a `{@render ...}` tag.
```svelte
{#snippet sum(a, b)}
<p>{a} + {b} = {a + b}</p>
{/snippet}
{@render sum(1, 2)}
{@render sum(3, 4)}
{@render sum(5, 6)}
```
The expression can be an identifier like `sum`, or an arbitrary JavaScript expression:
```svelte
{@render (cool ? coolSnippet : lameSnippet)()}
```
## Optional snippets
If the snippet is potentially undefined — for example, because it's an incoming prop — then you can use optional chaining to only render it when it _is_ defined:
```svelte
{@render children?.()}
```
Alternatively, use an [`{#if ...}`](https://svelte.dev/docs/svelte/if/llms.txt) block with an `:else` clause to render fallback content:
```svelte
{#if children}
{@render children()}
{:else}
<p>fallback content</p>
{/if}
```
@@ -0,0 +1,400 @@
```svelte
<!--- copy: false --->
{#snippet name()}...{/snippet}
```
```svelte
<!--- copy: false --->
{#snippet name(param1, param2, paramN)}...{/snippet}
```
Snippets, and [render tags](https://svelte.dev/docs/svelte/@render/llms.txt), are a way to create reusable chunks of markup inside your components. Instead of writing duplicative code like this...
```svelte
{#each images as image}
{#if image.href}
<a href={image.href}>
<figure>
<img src={image.src} alt={image.caption} width={image.width} height={image.height} />
<figcaption>{image.caption}</figcaption>
</figure>
</a>
{:else}
<figure>
<img src={image.src} alt={image.caption} width={image.width} height={image.height} />
<figcaption>{image.caption}</figcaption>
</figure>
{/if}
{/each}
```
...you can write this:
```svelte
{#snippet figure(image)}
<figure>
<img src={image.src} alt={image.caption} width={image.width} height={image.height} />
<figcaption>{image.caption}</figcaption>
</figure>
{/snippet}
{#each images as image}
{#if image.href}
<a href={image.href}>
{@render figure(image)}
</a>
{:else}
{@render figure(image)}
{/if}
{/each}
```
Like function declarations, snippets can have an arbitrary number of parameters, which can have default values, and you can destructure each parameter. You cannot use rest parameters, however.
## Snippet scope
Snippets can be declared anywhere inside your component. They can reference values declared outside themselves, for example in the `<script>` tag or in `{#each ...}` blocks...
<!-- codeblock:start {"title":"Snippets"} -->
```svelte
<!--- file: App.svelte --->
<script>
let { message = `it's great to see you!` } = $props();
</script>
{#snippet hello(name)}
<p>hello {name}! {message}!</p>
{/snippet}
{@render hello('alice')}
{@render hello('bob')}
```
<!-- codeblock:end -->
...and they are 'visible' to everything in the same lexical scope (i.e. siblings, and children of those siblings):
```svelte
<div>
{#snippet x()}
{#snippet y()}...{/snippet}
<!-- this is fine -->
{@render y()}
{/snippet}
<!-- this will error, as `y` is not in scope -->
{@render y()}
</div>
<!-- this will also error, as `x` is not in scope -->
{@render x()}
```
Snippets can reference themselves and each other:
<!-- codeblock:start {"title":"Self-referencing snippets"} -->
```svelte
<!--- file: App.svelte --->
{#snippet blastoff()}
<span>🚀</span>
{/snippet}
{#snippet countdown(n)}
{#if n > 0}
<span>{n}...</span>
{@render countdown(n - 1)}
{:else}
{@render blastoff()}
{/if}
{/snippet}
{@render countdown(10)}
```
<!-- codeblock:end -->
## Passing snippets to components
### Explicit props
Within the template, snippets are values just like any other. As such, they can be passed to components as props:
<!-- codeblock:start {"title":"Explicit snippet props"} -->
```svelte
<!--- file: App.svelte --->
<script>
import Table from './Table.svelte';
const fruits = [
{ name: 'apples', qty: 5, price: 2 },
{ name: 'bananas', qty: 10, price: 1 },
{ name: 'cherries', qty: 20, price: 0.5 }
];
</script>
{#snippet header()}
<th>fruit</th>
<th>qty</th>
<th>price</th>
<th>total</th>
{/snippet}
{#snippet row(d)}
<td>{d.name}</td>
<td>{d.qty}</td>
<td>{d.price}</td>
<td>{d.qty * d.price}</td>
{/snippet}
<Table data={fruits} +++{header} {row}+++ />
```
```svelte
<!--- file: Table.svelte --->
<script>
let { data, header, row } = $props();
</script>
<table>
{#if header}
<thead>
<tr>{@render header()}</tr>
</thead>
{/if}
<tbody>
{#each data as d}
<tr>{@render row(d)}</tr>
{/each}
</tbody>
</table>
<style>
table {
text-align: left;
border-spacing: 0;
}
tbody tr:nth-child(2n+1) {
background: ButtonFace;
}
table :global(th), table :global(td) {
padding: 0.5em;
}
</style>
```
<!-- codeblock:end -->
Think about it like passing content instead of data to a component. The concept is similar to slots in web components.
### Implicit props
As an authoring convenience, snippets declared directly _inside_ a component implicitly become props _on_ the component:
<!-- codeblock:start {"title":"Implicit snippet props"} -->
```svelte
<!--- file: App.svelte --->
<script>
import Table from './Table.svelte';
const fruits = [
{ name: 'apples', qty: 5, price: 2 },
{ name: 'bananas', qty: 10, price: 1 },
{ name: 'cherries', qty: 20, price: 0.5 }
];
</script>
<Table data={fruits}>
{#snippet header()}
<th>fruit</th>
<th>qty</th>
<th>price</th>
<th>total</th>
{/snippet}
{#snippet row(d)}
<td>{d.name}</td>
<td>{d.qty}</td>
<td>{d.price}</td>
<td>{d.qty * d.price}</td>
{/snippet}
</Table>
```
```svelte
<!--- file: Table.svelte --->
<script>
let { data, header, row } = $props();
</script>
<table>
{#if header}
<thead>
<tr>{@render header()}</tr>
</thead>
{/if}
<tbody>
{#each data as d}
<tr>{@render row(d)}</tr>
{/each}
</tbody>
</table>
<style>
table {
text-align: left;
border-spacing: 0;
}
tbody tr:nth-child(2n+1) {
background: ButtonFace;
}
table :global(th), table :global(td) {
padding: 0.5em;
}
</style>
```
<!-- codeblock:end -->
### Implicit `children` snippet
Any content inside the component tags that is _not_ a snippet declaration implicitly becomes part of the `children` snippet:
<!-- codeblock:start {"title":"Implicit children snippet","selected":"Button.svelte"} -->
```svelte
<!--- file: App.svelte --->
<script>
import Button from './Button.svelte';
</script>
<Button>click me</Button>
```
```svelte
<!--- file: Button.svelte --->
<script>
let { children } = $props();
</script>
<!-- result will be <button>click me</button> -->
<button>{@render children()}</button>
```
<!-- codeblock:end -->
> [!NOTE] Note that you cannot have a prop called `children` if you also have content inside the component — for this reason, you should avoid having props with that name
### Optional snippet props
You can declare snippet props as being optional. You can either use optional chaining to not render anything if the snippet isn't set...
```svelte
<script>
let { children } = $props();
</script>
{@render children?.()}
```
...or use an `#if` block to render fallback content:
```svelte
<script>
let { children } = $props();
</script>
{#if children}
{@render children()}
{:else}
fallback content
{/if}
```
## Typing snippets
Snippets implement the `Snippet` interface imported from `'svelte'`:
```svelte
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
data: any[];
children: Snippet;
row: Snippet<[any]>;
}
let { data, children, row }: Props = $props();
</script>
```
With this change, red squigglies will appear if you try and use the component without providing a `data` prop and a `row` snippet. Notice that the type argument provided to `Snippet` is a tuple, since snippets can have multiple parameters.
We can tighten things up further by declaring a generic, so that `data` and `row` refer to the same type:
```svelte
<script lang="ts" generics="T">
import type { Snippet } from 'svelte';
let {
data,
children,
row
}: {
data: T[];
children: Snippet;
row: Snippet<[T]>;
} = $props();
</script>
```
## Exporting snippets
Snippets declared at the top level of a `.svelte` file can be exported from a `<script module>` for use in other components, provided they don't reference any declarations in a non-module `<script>` (whether directly or indirectly, via other snippets):
<!-- codeblock:start {"title":"Exported snippets","selected":"snippets.svelte"} -->
```svelte
<!--- file: App.svelte --->
<script>
import { add } from './snippets.svelte';
</script>
{@render add(1, 2)}
```
```svelte
<!--- file: snippets.svelte --->
<script module>
export { add };
</script>
{#snippet add(a, b)}
{a} + {b} = {a + b}
{/snippet}
```
<!-- codeblock:end -->
> [!NOTE]
> This requires Svelte 5.5.0 or newer
## Programmatic snippets
Snippets can be created programmatically with the [`createRawSnippet`](https://svelte.dev/docs/svelte/svelte/llms.txt#createRawSnippet) API. This is intended for advanced use cases.
## Snippets and slots
In Svelte 4, content can be passed to components using [slots](https://svelte.dev/docs/svelte/legacy-slots/llms.txt). Snippets are more powerful and flexible, and so slots have been deprecated in Svelte 5.
@@ -0,0 +1,61 @@
## createSubscriber
<blockquote class="since note">
Available since 5.7.0
</blockquote>
Returns a `subscribe` function that integrates external event-based systems with Svelte's reactivity.
It's particularly useful for integrating with web APIs like `MediaQuery`, `IntersectionObserver`, or `WebSocket`.
If `subscribe` is called inside an effect (including indirectly, for example inside a getter),
the `start` callback will be called with an `update` function. Whenever `update` is called, the effect re-runs.
If `start` returns a cleanup function, it will be called when the effect is destroyed.
If `subscribe` is called in multiple effects, `start` will only be called once as long as the effects
are active, and the returned teardown function will only be called when all effects are destroyed.
It's best understood with an example. Here's an implementation of [`MediaQuery`](https://svelte.dev/docs/svelte/svelte-reactivity/llms.txt#MediaQuery):
```js
// @errors: 7031
import { createSubscriber } from 'svelte/reactivity';
import { on } from 'svelte/events';
export class MediaQuery {
#query;
#subscribe;
constructor(query) {
this.#query = window.matchMedia(`(${query})`);
this.#subscribe = createSubscriber((update) => {
// when the `change` event occurs, re-run any effects that read `this.current`
const off = on(this.#query, 'change', update);
// stop listening when all the effects are destroyed
return () => off();
});
}
get current() {
// This makes the getter reactive, if read in an effect
this.#subscribe();
// Return the current state of the query, whether or not we're in an effect
return this.#query.matches;
}
}
```
<div class="ts-block">
```dts
function createSubscriber(
start: (update: () => void) => (() => void) | void
): () => void;
```
</div>
+6
View File
@@ -0,0 +1,6 @@
{
"$schema": "https://opencode.ai/config.json",
"skills": {
"paths": [".agents/skills"]
}
}
+23
View File
@@ -0,0 +1,23 @@
{
"version": 1,
"skills": {
"shadcn-svelte": {
"source": "huntabyte/shadcn-svelte",
"sourceType": "github",
"skillPath": "skills/shadcn-svelte/SKILL.md",
"computedHash": "d4f3f983a71466a86649985a8a7fbce47da29f44de3a9d02540bbcf3167134e0"
},
"svelte-code-writer": {
"source": "sveltejs/ai-tools",
"sourceType": "github",
"skillPath": "plugins/claude/svelte/skills/svelte-code-writer/SKILL.md",
"computedHash": "6f78193a79906175595ce8e4ca8d726da350eb99035a9544b6553620df5673a1"
},
"svelte-core-bestpractices": {
"source": "sveltejs/ai-tools",
"sourceType": "github",
"skillPath": "plugins/claude/svelte/skills/svelte-core-bestpractices/SKILL.md",
"computedHash": "98b4ee346ac45e13740c9f3353b604d5c6e844c96cdc8867052fa961cbdb525d"
}
}
}
+2 -2
View File
@@ -123,14 +123,14 @@
@apply border-border outline-ring/50;
}
html {
@apply font-sans;
@apply font-sans h-full overflow-hidden;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
font-feature-settings: "ss01", "cv01";
}
body {
@apply bg-background text-foreground;
@apply bg-background text-foreground h-full overflow-hidden;
}
::selection {
background: oklch(0.5 0 0 / 25%);
+28 -4
View File
@@ -24,7 +24,7 @@ export function createMoneroWalletState(): MoneroWalletState {
process: null,
rpcPort: 28084,
rpcUser: 'koins',
rpcPassword: crypto.randomUUID(),
rpcPassword: 'koins-local-rpc',
daemonAddress: DEFAULT_DAEMON,
}
}
@@ -97,6 +97,20 @@ export async function start(
)
state.daemonAddress = daemonAddress
// Kill any existing monero-wallet-rpc on the port (stale process from dev reloads)
try {
const proc = Bun.spawnSync(['lsof', '-ti', `tcp:${state.rpcPort}`], {
stdout: 'pipe',
})
const pids = proc.stdout.toString().trim().split('\n').filter(Boolean)
for (const pid of pids) {
try {
process.kill(Number(pid), 'SIGKILL')
console.log(`[monero] killed stale process ${pid} on port ${state.rpcPort}`)
} catch {}
}
} catch {}
console.log(`[monero] starting wallet-rpc process...`)
console.log(`[monero] daemon address: ${state.daemonAddress}`)
console.log(`[monero] wallet dir: ${getWalletDir()}`)
@@ -356,7 +370,9 @@ export async function getTransferDetails(
let t: any = null
let dests: any[] = []
try {
console.log('[monero] trying get_transfer_by_txid for', txid)
const result = await rawRpc(state, 'get_transfer_by_txid', { txid })
console.log('[monero] get_transfer_by_txid result:', result ? 'has result' : 'null')
if (result?.transfer) {
t = result.transfer
dests =
@@ -365,10 +381,11 @@ export async function getTransferDetails(
amount: d.amount?.toString() ?? '0',
})) ?? []
}
} catch {
// get_transfer_by_txid can fail for various reasons; fall back
} catch (e: any) {
console.log('[monero] get_transfer_by_txid failed:', e?.message?.slice(0, 100))
}
if (!t) {
console.log('[monero] falling back to get_transfers scan')
const params: Record<string, any> = {
in: true,
out: true,
@@ -376,9 +393,11 @@ export async function getTransferDetails(
pool: true,
}
const result = await rawRpc(state, 'get_transfers', params)
console.log('[monero] get_transfers result keys:', Object.keys(result ?? {}))
let found: any = null
for (const dir of ['in', 'out', 'pending', 'pool'] as const) {
const transfers = result[dir] ?? []
console.log(`[monero] ${dir}: ${transfers.length} transfers`)
for (const tr of transfers) {
if (tr.txid === txid) {
found = { ...tr, type: dir === 'in' ? 'in' : 'out' }
@@ -387,7 +406,12 @@ export async function getTransferDetails(
}
if (found) break
}
if (!found) return null
if (!found) {
console.log('[monero] txid not found in get_transfers. Available txids:',
['in', 'out', 'pending', 'pool'].flatMap(d => (result[d] ?? []).map((t: any) => t.txid?.slice(0, 12))))
return null
}
console.log('[monero] found transfer in get_transfers')
t = found
}
const isIn = t.type === 'in'
@@ -13,7 +13,7 @@
<div
bind:this={ref}
data-slot="card-footer"
class={cn("rounded-none border-t p-4 group-data-[size=sm]/card:p-3 flex items-center", className)}
class={cn("rounded-xl border-t p-4 group-data-[size=sm]/card:p-3 flex items-center", className)}
{...restProps}
>
{@render children?.()}
@@ -14,7 +14,7 @@
bind:this={ref}
data-slot="card-header"
class={cn(
"gap-1 rounded-none px-4 group-data-[size=sm]/card:px-3 [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]",
"gap-1 rounded-xl px-4 group-data-[size=sm]/card:px-3 [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]",
className
)}
{...restProps}
@@ -15,7 +15,7 @@
bind:this={ref}
data-slot="card"
data-size={size}
class={cn("ring-foreground/10 bg-card text-card-foreground gap-4 overflow-hidden rounded-none py-4 text-xs/relaxed ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-2 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-none *:[img:last-child]:rounded-none group/card flex flex-col", className)}
class={cn("ring-foreground/10 bg-card text-card-foreground gap-4 overflow-hidden rounded-xl py-4 text-xs/relaxed ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-2 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-xl *:[img:last-child]:rounded-xl group/card flex flex-col", className)}
{...restProps}
>
{@render children?.()}
+1 -1
View File
@@ -190,7 +190,7 @@
{/if}
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<button
onclick={() => navigate('/xrp')}
class="text-left transition-transform active:scale-[0.98]">
+3 -3
View File
@@ -32,7 +32,7 @@
if (!moneroWallet.ready) await moneroWallet.init()
await Promise.allSettled([
xrpWallet.autoUnlock(),
moneroWallet.autoUnlock(),
moneroWallet.login(),
])
}
@@ -144,8 +144,8 @@
</div>
</header>
<main class="min-w-0 flex-1 overflow-y-auto">
<div class="mx-auto w-full max-w-2xl px-8 py-8">
<main class="min-h-0 min-w-0 flex-1 overflow-y-auto">
<div class="mx-auto w-full max-w-5xl px-6 pt-6 pb-6 lg:px-10">
{@render children?.()}
</div>
</main>
+1 -1
View File
@@ -46,7 +46,7 @@
})
</script>
<div class="mx-auto max-w-xl">
<div class="mx-auto max-w-lg">
<div class="flex flex-col gap-4">
{#if !w.ready}
<p class="text-center text-muted-foreground text-sm mt-8">
+1 -1
View File
@@ -92,7 +92,7 @@
}
</script>
<div class="mx-auto max-w-xl">
<div class="mx-auto max-w-lg">
<Card>
<CardHeader>
<CardTitle>Send XMR</CardTitle>
@@ -35,7 +35,7 @@
})
</script>
<div class="mx-auto max-w-xl">
<div class="mx-auto max-w-lg">
<Card>
<CardHeader>
<CardTitle>Transaction Details</CardTitle>
@@ -16,15 +16,14 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '$lib/components/ui/card/index.js'
import ArrowDown from '@lucide/svelte/icons/arrow-down'
import ArrowUp from '@lucide/svelte/icons/arrow-up'
import SettingsIcon from '@lucide/svelte/icons/settings'
import Copy from '@lucide/svelte/icons/copy'
import Send from '@lucide/svelte/icons/send'
import QrCodeIcon from '@lucide/svelte/icons/qr-code'
import Plus from '@lucide/svelte/icons/plus'
import Loader from '$lib/components/loader.svelte'
import QrCode from '$lib/components/qr.svelte'
import { navigate, route } from 'sv-router/generated'
@@ -104,8 +103,7 @@
const walletName = $derived(route.params.name)
let autoRefreshTimer: ReturnType<typeof setInterval> | undefined =
$state()
let autoRefreshTimer: ReturnType<typeof setInterval> | undefined = $state()
$effect(() => {
if (w.walletOpen && !autoRefreshTimer) {
@@ -146,242 +144,92 @@
})
</script>
<div class="mx-auto max-w-xl">
<div class="flex flex-col gap-4">
<Card>
{@const a = chunkAddress(w.address)}
<CardHeader>
<div class="flex items-center justify-between">
<CardTitle>Monero Wallet</CardTitle>
<div class="flex items-center gap-1.5">
<div
class="size-2 rounded-full {w.connected
? 'bg-green-500'
: 'bg-red-500'}">
</div>
<span class="text-xs text-muted-foreground">
{w.connected ? 'Connected' : 'Disconnected'}
</span>
</div>
</div>
<CardDescription>
{#if w.height < w.daemonHeight}
Scanning: {w.height.toLocaleString()} / {w.daemonHeight.toLocaleString()}
<div class="space-y-6">
<div class="flex flex-col items-center py-2">
{#if w.loading}
<div class="h-12 w-40 rounded-lg bg-muted animate-pulse"></div>
{:else}
Height: {w.height.toLocaleString()}
{/if}
</CardDescription>
</CardHeader>
<CardContent>
<div class="mb-4 space-y-1">
<div class="flex items-start gap-3">
<div class="min-w-0 flex-1 space-y-1">
<p class="font-medium text-xs">Address</p>
<p class="font-mono text-xs break-all">
<span class="text-primary-foreground">
{a.start}
</span>
<span class="text-muted-foreground">{a.mid}</span>
<span class="text-muted-foreground/40">...</span>
<span class="text-primary-foreground">
{a.end}
</span>
<p class="text-sm text-muted-foreground">Balance</p>
<p class="mt-1 font-mono text-4xl font-semibold tabular-nums tracking-tight">
{w.balance} <span class="text-muted-foreground text-2xl">XMR</span>
</p>
<div class="flex gap-1.5 mt-1">
<Dialog.Root bind:open={qrDialogOpen}>
<Dialog.Trigger>
<Button
variant="outline"
size="icon-sm"
class="text-muted-foreground"
aria-label="Show QR code">
<QrCodeIcon size={16} />
</Button>
</Dialog.Trigger>
<Dialog.Content>
<div
class="flex flex-col items-center gap-3 py-4">
<QrCode text={w.address} size={256} />
<p
class="font-mono text-xs break-all text-center max-w-64">
{w.address}
</p>
</div>
</Dialog.Content>
</Dialog.Root>
<CopyButton
text={w.address}
variant="outline"
size="icon-sm"
class="text-muted-foreground">
</CopyButton>
</div>
</div>
</div>
<p class="mt-2 font-medium text-xs">Balance</p>
<div class="flex items-baseline gap-2">
<p class="font-mono text-lg">{w.balance} XMR</p>
{#if moneroPrice}
<p class="font-mono text-sm text-muted-foreground">
≈ ${(
parseFloat(w.balance) * parseFloat(moneroPrice.usd)
).toLocaleString(undefined, {
<p class="mt-1 text-sm text-muted-foreground tabular-nums">
≈ ${(parseFloat(w.balance) * parseFloat(moneroPrice.usd)).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</p>
{/if}
</div>
{#if w.unlockedAtomic !== w.balAtomic}
<p class="text-xs text-muted-foreground">
<p class="mt-1 text-xs text-muted-foreground">
Unlocked: {w.unlocked} XMR
</p>
{/if}
{#if feeEstimate}
{@const labels = [
'Unimportant',
'Normal',
'Elevated',
'Priority',
]}
<div class="mt-1 space-y-0.5">
{#each feeEstimate.fees as f, i}
{@const feeAtomic = (BigInt(f) * 2500n) / 1000n}
<p class="text-xs text-muted-foreground">
{labels[i]}: ~{atomicToXmr(feeAtomic.toString())} XMR
</p>
{/each}
<div class="mt-2 flex items-center gap-1.5">
<div class="size-2 rounded-full {w.connected ? 'bg-green-500' : 'bg-red-500'}"></div>
<span class="text-xs text-muted-foreground">
{w.connected ? 'Connected' : 'Disconnected'}
{#if w.height < w.daemonHeight}
· Scanning {w.height.toLocaleString()} / {w.daemonHeight.toLocaleString()}
{/if}
</span>
</div>
{/if}
</div>
{#if moneroMnemonic}
<div class="mb-4 rounded-md bg-muted p-3">
<p class="text-xs font-medium mb-1">
Seed phrase (save this!)
</p>
<p class="font-mono text-xs break-all">
{moneroMnemonic}
</p>
</div>
{/if}
<div class="flex gap-2">
<Button
variant="outline"
onclick={() => navigate('/monero/send')}>
<div class="flex items-center justify-center">
<Button onclick={() => navigate('/monero/send')} class="gap-2">
<Send size={16} />
Send
</Button>
<Button
variant="outline"
onclick={() => navigate('/monero/settings')}>
<SettingsIcon size={16} />
</Button>
<Button
variant="outline"
onclick={async () => {
await w.logout()
navigate('/monero')
}}>
Logout
</Button>
</div>
</CardContent>
</Card>
<!-- Monero transactions -->
<Card>
<CardHeader>
<CardTitle>Transactions</CardTitle>
{#if w.accounts.length > 0}
<CardDescription>
<div class="flex flex-wrap gap-1.5 mt-1">
{#each w.accounts as acct}
<button
onclick={() => {
w.selectedAccountIndex = acct.index
w.refresh()
}}
class="rounded-md px-2 py-0.5 text-xs transition-colors {w.selectedAccountIndex ===
acct.index
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80'}">
Account {acct.index}
</button>
{/each}
</div>
</CardDescription>
{/if}
</CardHeader>
<CardContent>
{#if w.loading}
<div class="flex justify-center py-4">
<Loader />
</div>
{:else if w.txs.length === 0}
<p class="text-muted-foreground text-xs">
No transactions found
<div class="mx-auto max-w-md">
<div class="flex items-center justify-between rounded-lg border border-border px-4 py-3">
<div class="min-w-0 flex-1">
<p class="text-xs text-muted-foreground mb-0.5">Address</p>
<p class="font-mono text-xs break-all">
<span>{chunkAddress(w.address).start}</span>
<span class="text-muted-foreground">{chunkAddress(w.address).mid}</span>
<span class="text-muted-foreground/40">...</span>
<span>{chunkAddress(w.address).end}</span>
</p>
{:else}
<div class="max-h-96 space-y-1 overflow-y-auto">
{#each w.txs as tx}
<button
onclick={() =>
navigate(`/monero/tx/:hash`, {
params: {
hash: tx.hash,
},
})}
class="flex w-full items-start gap-2 rounded-md bg-muted px-3 py-2 text-xs text-left cursor-pointer hover:bg-muted/80 transition-colors">
<div
class="shrink-0 mt-0.5 {tx.direction === 'in'
? 'text-green-500'
: 'text-muted-foreground'}">
{#if tx.direction === 'in'}
<ArrowDown size={16} />
{:else}
<ArrowUp size={16} />
{/if}
</div>
<div class="min-w-0 flex-1 space-y-0.5">
<p class="font-medium">
{tx.direction === 'in' ? 'Received' : 'Sent'}
</p>
<p class="font-mono text-muted-foreground">
{atomicToXmr(tx.amount)} XMR
</p>
{#if tx.timestamp && tx.timestamp !== '0'}
<p class="text-muted-foreground">
{new Date(
Number(tx.timestamp) * 1000,
).toLocaleString()}
<div class="flex shrink-0 gap-1 ml-2">
<Dialog.Root bind:open={qrDialogOpen}>
<Dialog.Trigger>
<Button variant="ghost" size="icon-sm" class="text-muted-foreground" aria-label="QR code">
<QrCodeIcon size={14} />
</Button>
</Dialog.Trigger>
<Dialog.Content>
<div class="flex flex-col items-center gap-3 py-4">
<QrCode text={w.address} size={256} />
<p class="font-mono text-xs break-all text-center max-w-64">{w.address}</p>
</div>
</Dialog.Content>
</Dialog.Root>
<CopyButton text={w.address} variant="ghost" size="icon-sm" class="text-muted-foreground" />
</div>
</div>
{#if feeEstimate}
<p class="mt-2 text-center text-xs text-muted-foreground">
Fee: ~{atomicToXmr(((BigInt(feeEstimate.fees[2]) * 2500n) / 1000n).toString())} XMR (Normal)
</p>
{/if}
<p
class="text-muted-foreground/50 truncate font-mono">
{tx.hash.slice(0, 12)}...
</p>
</div>
</button>
{/each}
</div>
{/if}
</CardContent>
</Card>
{#if balanceHistory.length > 1}
<Card>
<CardHeader>
<div class="flex items-center justify-between">
<CardTitle>Balance</CardTitle>
<div class="flex gap-1">
<CardTitle class="text-sm">Balance History</CardTitle>
<div class="inline-flex rounded-lg bg-muted p-0.5">
{#each ['1D', '1W', '1M', '1Y', 'ALL'] as const as range}
<button
onclick={() => (selectedRange = range)}
class="rounded px-1.5 py-0.5 text-xs font-medium transition-colors {selectedRange ===
range
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground'}">
class="rounded-md px-2.5 py-1 text-xs font-medium transition-all {selectedRange === range ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'}">
{range}
</button>
{/each}
@@ -390,311 +238,212 @@
</CardHeader>
<CardContent>
{#if chartData.length > 1}
<Chart.Container
config={{
balance: { label: 'XMR', color: 'var(--chart-1)' },
}}>
<Chart.Container config={{ balance: { label: 'XMR', color: 'var(--chart-1)' } }}>
<LineChart
data={chartData}
x="date"
xScale={scaleUtc()}
axis="x"
points
series={[
{
key: 'balance',
label: 'XMR',
color: 'var(--chart-1)',
},
]}
series={[{ key: 'balance', label: 'XMR', color: 'var(--chart-1)' }]}
yDomain={[
chartData.reduce((a, b) =>
a.balance < b.balance ? a : b,
).balance,
chartData.reduce((a, b) =>
a.balance > b.balance ? a : b,
).balance,
chartData.reduce((a, b) => (a.balance < b.balance ? a : b)).balance,
chartData.reduce((a, b) => (a.balance > b.balance ? a : b)).balance,
]}
props={{
spline: {
curve: curveLinear,
motion: 'tween',
strokeWidth: 2,
},
spline: { curve: curveLinear, motion: 'tween', strokeWidth: 2 },
yAxis: {},
xAxis: {
format: (v: Date) =>
v.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
}),
},
xAxis: { format: (v: Date) => v.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) },
highlight: { points: { r: 4 } },
}}>
{#snippet tooltip()}
<Chart.Tooltip
labelFormatter={(value: Date) => {
return value.toLocaleDateString()
}}
valueFormatter={(value: Number) => {
return value.toString().slice(0, 8)
}} />
labelFormatter={(value: Date) => value.toLocaleDateString()}
valueFormatter={(value: Number) => value.toString().slice(0, 8)} />
{/snippet}
</LineChart>
</Chart.Container>
{:else}
<p class="text-center text-sm text-muted-foreground">
No transactions in this time range.
</p>
<p class="text-center text-sm text-muted-foreground py-4">No data in this range</p>
{/if}
</CardContent>
</Card>
{/if}
<!-- Monero accounts & subaddresses -->
<Card>
<CardHeader>
<CardTitle>Accounts & Addresses</CardTitle>
<CardDescription>
{w.accounts.length} account{w.accounts.length !== 1
? 's'
: ''}
</CardDescription>
<div class="flex items-center justify-between">
<CardTitle class="text-sm">Transactions</CardTitle>
{#if w.accounts.length > 0}
<div class="inline-flex rounded-lg bg-muted p-0.5">
{#each w.accounts as acct}
<button
onclick={() => { w.selectedAccountIndex = acct.index; w.refresh() }}
class="rounded-md px-2.5 py-1 text-xs font-medium transition-all {w.selectedAccountIndex === acct.index ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'}">
{acct.index}
</button>
{/each}
</div>
{/if}
</div>
</CardHeader>
<CardContent>
<div class="flex gap-2 mb-3">
{#if w.loading}
<div class="flex justify-center py-8"><Loader /></div>
{:else if w.txs.length === 0}
<p class="text-center text-sm text-muted-foreground py-8">No transactions yet</p>
{:else}
<div class="space-y-1">
{#each w.txs as tx}
<button
onclick={() => navigate(`/monero/tx/:hash`, { params: { hash: tx.hash } })}
class="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left hover:bg-muted transition-colors">
<div class="flex size-8 shrink-0 items-center justify-center rounded-full bg-muted">
<div class={tx.direction === 'in' ? 'text-green-500' : 'text-muted-foreground'}>
{#if tx.direction === 'in'}
<ArrowDown size={16} />
{:else}
<ArrowUp size={16} />
{/if}
</div>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium">
{tx.direction === 'in' ? 'Received' : 'Sent'}
</p>
<p class="text-xs text-muted-foreground">
{#if tx.timestamp && tx.timestamp !== '0'}
{new Date(Number(tx.timestamp) * 1000).toLocaleString()}
{/if}
</p>
</div>
<p class="shrink-0 font-mono text-sm tabular-nums {tx.direction === 'in' ? 'text-green-500' : ''}">
{tx.direction === 'in' ? '+' : '-'}{atomicToXmr(tx.amount)} XMR
</p>
</button>
{/each}
</div>
{/if}
</CardContent>
</Card>
<Card>
<CardHeader>
<div class="flex items-center justify-between">
<CardTitle class="text-sm">Accounts & Addresses</CardTitle>
<div class="flex gap-1.5">
<Dialog.Root bind:open={accountDialogOpen}>
<Dialog.Trigger>
<Button size="sm" variant="outline">+ Account</Button>
<Button size="sm" variant="ghost" class="gap-1 text-xs">
<Plus size={12} /> Account
</Button>
</Dialog.Trigger>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Create Account</Dialog.Title>
<Dialog.Description>
Optional label for the new account
</Dialog.Description>
<Dialog.Description>Optional label for the new account</Dialog.Description>
</Dialog.Header>
<div class="px-6 pb-4 space-y-3">
<Input
placeholder="Account label (optional)"
bind:value={accountLabel} />
<Input placeholder="Account label (optional)" bind:value={accountLabel} />
<div class="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onclick={() => {
accountDialogOpen = false
accountLabel = ''
}}>
Cancel
</Button>
<Button
size="sm"
onclick={async () => {
await w.createAccount(accountLabel || undefined)
accountDialogOpen = false
accountLabel = ''
}}>
Create
</Button>
<Button variant="outline" size="sm" onclick={() => { accountDialogOpen = false; accountLabel = '' }}>Cancel</Button>
<Button size="sm" onclick={async () => { await w.createAccount(accountLabel || undefined); accountDialogOpen = false; accountLabel = '' }}>Create</Button>
</div>
</div>
</Dialog.Content>
</Dialog.Root>
<Dialog.Root bind:open={subaddressDialogOpen}>
<Dialog.Trigger>
<Button size="sm" variant="outline">
+ Subaddress
<Button size="sm" variant="ghost" class="gap-1 text-xs">
<Plus size={12} /> Subaddress
</Button>
</Dialog.Trigger>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Create Subaddress</Dialog.Title>
<Dialog.Description>
Account index and optional label
</Dialog.Description>
<Dialog.Description>Account index and optional label</Dialog.Description>
</Dialog.Header>
<div class="px-6 pb-4 space-y-3">
<div class="space-y-1.5">
<label class="text-xs font-medium">Account</label>
<Select
type="single"
bind:value={subaddressAccountIndex}>
<Select type="single" bind:value={subaddressAccountIndex}>
<SelectTrigger>
{subaddressAccountIndex
? `Account ${subaddressAccountIndex}`
: 'Select account'}
{subaddressAccountIndex ? `Account ${subaddressAccountIndex}` : 'Select account'}
</SelectTrigger>
<SelectContent>
{#each w.accounts as acct}
<SelectItem value={String(acct.index)}>
Account {acct.index}{acct.label
? ` - ${acct.label}`
: ''}
Account {acct.index}{acct.label ? ` - ${acct.label}` : ''}
</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<Input
placeholder="Subaddress label (optional)"
bind:value={subaddressLabel} />
<Input placeholder="Subaddress label (optional)" bind:value={subaddressLabel} />
<div class="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onclick={() => {
subaddressDialogOpen = false
subaddressAccountIndex = ''
subaddressLabel = ''
}}>
Cancel
</Button>
<Button
size="sm"
disabled={!subaddressAccountIndex}
onclick={async () => {
await w.createSubaddress(
parseInt(subaddressAccountIndex),
subaddressLabel || undefined,
)
subaddressDialogOpen = false
subaddressAccountIndex = ''
subaddressLabel = ''
}}>
Create
</Button>
<Button variant="outline" size="sm" onclick={() => { subaddressDialogOpen = false; subaddressAccountIndex = ''; subaddressLabel = '' }}>Cancel</Button>
<Button size="sm" disabled={!subaddressAccountIndex} onclick={async () => { await w.createSubaddress(parseInt(subaddressAccountIndex), subaddressLabel || undefined); subaddressDialogOpen = false; subaddressAccountIndex = ''; subaddressLabel = '' }}>Create</Button>
</div>
</div>
</Dialog.Content>
</Dialog.Root>
</div>
</div>
</CardHeader>
<CardContent>
<div class="space-y-2">
{#each w.accounts as account}
{@const a = chunkAddress(account.primaryAddress)}
<div class="mb-3 rounded-md bg-muted p-3">
<div class="rounded-lg border border-border p-3">
<div class="mb-1 flex items-center justify-between">
<p class="text-xs font-medium">
Account {account.index}
</p>
<p class="text-xs text-muted-foreground">
{atomicToXmr(account.balance)} XMR
</p>
<p class="text-xs font-medium">Account {account.index}{account.label ? ` — ${account.label}` : ''}</p>
<p class="text-xs text-muted-foreground tabular-nums">{atomicToXmr(account.balance)} XMR</p>
</div>
<p
class="mb-2 font-mono text-xs text-muted-foreground break-all">
<span class="text-primary-foreground">
{a.start}
</span>
<span class="text-muted-foreground">{a.mid}</span>
<span class="text-muted-foreground/40">...</span>
<span class="text-primary-foreground">{a.end}</span>
<div class="flex items-center gap-2">
<p class="min-w-0 flex-1 font-mono text-xs text-muted-foreground break-all">
<span>{a.start}</span><span>{a.mid}</span><span class="text-muted-foreground/40">...</span><span>{a.end}</span>
</p>
<div class="flex gap-1.5 mt-1">
<Dialog.Root
open={subQrDialogOpen === account.primaryAddress}
onopenchange={(v) => {
subQrDialogOpen = v ? account.primaryAddress : null
}}>
<div class="flex shrink-0 gap-1">
<Dialog.Root open={subQrDialogOpen === account.primaryAddress} onopenchange={(v) => { subQrDialogOpen = v ? account.primaryAddress : null }}>
<Dialog.Trigger>
<Button
variant="outline"
size="icon-xs"
class="text-muted-foreground"
aria-label="Show QR code">
<QrCodeIcon />
</Button>
<Button variant="ghost" size="icon-xs" class="text-muted-foreground" aria-label="QR"><QrCodeIcon size={12} /></Button>
</Dialog.Trigger>
<Dialog.Content>
<div class="flex flex-col items-center gap-3 py-4">
<QrCode
text={account.primaryAddress}
size={256} />
<p
class="font-mono text-xs break-all text-center max-w-64">
{account.primaryAddress}
</p>
<QrCode text={account.primaryAddress} size={256} />
<p class="font-mono text-xs break-all text-center max-w-64">{account.primaryAddress}</p>
</div>
</Dialog.Content>
</Dialog.Root>
<CopyButton
text={account.primaryAddress}
variant="outline"
size="icon-xs"
class="text-muted-foreground">
</CopyButton>
<CopyButton text={account.primaryAddress} variant="ghost" size="icon-xs" class="text-muted-foreground" />
</div>
</div>
{#if account.subaddresses.length > 1}
<div class="space-y-1 border-t border-border pt-2">
<p class="text-xs text-muted-foreground mb-1">
Subaddresses
</p>
<div class="mt-2 space-y-1 border-t border-border pt-2">
{#each account.subaddresses as sub}
{@const a = chunkAddress(sub.address)}
<div
class="rounded-sm bg-background px-2 py-1.5 text-xs">
<div class="flex items-start gap-2">
<div class="min-w-0 flex-1">
<div
class="flex items-center justify-between">
<p class="font-medium">
#{sub.index}{sub.label
? ` - ${sub.label}`
: ''}
</p>
<p class="text-muted-foreground">
{atomicToXmr(sub.balance)} XMR
</p>
{@const sa = chunkAddress(sub.address)}
<div class="rounded-md px-2 py-1.5 text-xs">
<div class="flex items-center justify-between">
<p class="font-medium">#{sub.index}{sub.label ? ` - ${sub.label}` : ''}</p>
<p class="text-muted-foreground tabular-nums">{atomicToXmr(sub.balance)} XMR</p>
</div>
<p class="font-mono text-xs mt-0.5">
<span class="text-primary-foreground">
{a.start}
</span>
<span class="text-muted-foreground">
{a.mid}
</span>
<span class="text-muted-foreground/40">
...
</span>
<span class="text-primary-foreground">
{a.end}
</span>
<div class="flex items-center gap-2 mt-0.5">
<p class="min-w-0 flex-1 font-mono text-muted-foreground truncate">
<span>{sa.start}</span><span>{sa.mid}</span><span class="text-muted-foreground/40">...</span><span>{sa.end}</span>
</p>
<div class="flex gap-1.5 mt-1">
<Dialog.Root
open={subQrDialogOpen === sub.address}
onopenchange={(v) => {
subQrDialogOpen = v ? sub.address : null
}}>
<div class="flex shrink-0 gap-1">
<Dialog.Root open={subQrDialogOpen === sub.address} onopenchange={(v) => { subQrDialogOpen = v ? sub.address : null }}>
<Dialog.Trigger>
<Button
variant="outline"
size="icon-xs"
class="text-muted-foreground"
aria-label="Show QR code">
<QrCodeIcon />
</Button>
<Button variant="ghost" size="icon-xs" class="text-muted-foreground" aria-label="QR"><QrCodeIcon size={12} /></Button>
</Dialog.Trigger>
<Dialog.Content>
<div
class="flex flex-col items-center gap-3 py-4">
<QrCode
text={sub.address}
size={256} />
<p
class="font-mono text-xs break-all text-center max-w-64">
{sub.address}
</p>
<div class="flex flex-col items-center gap-3 py-4">
<QrCode text={sub.address} size={256} />
<p class="font-mono text-xs break-all text-center max-w-64">{sub.address}</p>
</div>
</Dialog.Content>
</Dialog.Root>
<CopyButton
text={sub.address}
variant="outline"
size="icon-xs"
class="text-muted-foreground">
</CopyButton>
</div>
<CopyButton text={sub.address} variant="ghost" size="icon-xs" class="text-muted-foreground" />
</div>
</div>
</div>
@@ -703,7 +452,7 @@
{/if}
</div>
{/each}
</div>
</CardContent>
</Card>
</div>
</div>
+1 -1
View File
@@ -23,7 +23,7 @@
}
</script>
<div class="mx-auto max-w-xl">
<div class="mx-auto max-w-lg">
<Card>
<CardHeader>
<CardTitle>Settings</CardTitle>
+1 -1
View File
@@ -59,7 +59,7 @@
}
</script>
<div class="mx-auto max-w-xl">
<div class="mx-auto max-w-lg">
<div class="flex flex-col gap-4">
{#if !w.ready}
<p class="text-center text-muted-foreground text-sm mt-8">
+1 -1
View File
@@ -59,7 +59,7 @@
}
</script>
<div class="mx-auto max-w-xl">
<div class="mx-auto max-w-lg">
<Card>
<CardHeader>
<CardTitle>Send XRP</CardTitle>
+1 -1
View File
@@ -35,7 +35,7 @@
})
</script>
<div class="mx-auto max-w-xl">
<div class="mx-auto max-w-lg">
<Card>
<CardHeader>
<CardTitle>Transaction Details</CardTitle>
+75 -138
View File
@@ -5,15 +5,14 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '$lib/components/ui/card/index.js'
import ArrowDown from '@lucide/svelte/icons/arrow-down'
import ArrowUp from '@lucide/svelte/icons/arrow-up'
import SettingsIcon from '@lucide/svelte/icons/settings'
import QrCodeIcon from '@lucide/svelte/icons/qr-code'
import ExternalLink from '@lucide/svelte/icons/external-link'
import Send from '@lucide/svelte/icons/send'
import Loader from '$lib/components/loader.svelte'
import QrCode from '$lib/components/qr.svelte'
import * as Dialog from '$lib/components/ui/dialog/index.js'
@@ -26,14 +25,11 @@
const walletId = $derived(route.params.id)
let qrDialogOpen = $state(false)
let autoRefreshTimer: ReturnType<typeof setInterval> | undefined =
$state()
let autoRefreshTimer: ReturnType<typeof setInterval> | undefined = $state()
$effect(() => {
if (w.seed && !autoRefreshTimer) {
autoRefreshTimer = setInterval(() => {
w.refresh()
}, 30000)
autoRefreshTimer = setInterval(() => w.refresh(), 30000)
} else if (!w.seed && autoRefreshTimer) {
clearInterval(autoRefreshTimer)
autoRefreshTimer = undefined
@@ -55,181 +51,122 @@
})
</script>
<div class="mx-auto max-w-xl">
<div class="flex flex-col gap-4">
<Card>
<CardHeader>
<div class="flex items-center justify-between">
<CardTitle>
{w.currentWallet?.name ?? 'XRP Wallet'}
</CardTitle>
<img src="/icons/xrp.png" alt="" class="size-6 rounded-full" />
</div>
<CardDescription>XRP Ledger</CardDescription>
</CardHeader>
<CardContent>
<div class="mb-4 space-y-1">
<div class="flex items-start gap-3">
<div class="min-w-0 flex-1 space-y-1">
<p class="font-medium text-xs">Address</p>
<p class="font-mono text-xs break-all">{w.address}</p>
<div class="flex gap-1.5 mt-1">
<Dialog.Root bind:open={qrDialogOpen}>
<Dialog.Trigger>
<Button
variant="outline"
size="icon-sm"
class="text-muted-foreground"
aria-label="Show QR code">
<QrCodeIcon size={16} />
</Button>
</Dialog.Trigger>
<Dialog.Content>
<div
class="flex flex-col items-center gap-3 py-4">
<QrCode text={w.address} size={256} />
<p
class="font-mono text-xs break-all text-center max-w-64">
{w.address}
</p>
</div>
</Dialog.Content>
</Dialog.Root>
<CopyButton
text={w.address}
variant="outline"
size="icon-sm"
class="text-muted-foreground">
</CopyButton>
<Button
variant="outline"
size="icon-sm"
class="text-muted-foreground"
aria-label="View on explorer"
onclick={() =>
electrobun.rpc?.request.openExternal({
url: w.explorerAddressUrl,
})}>
<ExternalLink size={16} />
</Button>
</div>
</div>
</div>
<p class="mt-2 font-medium text-xs">Balance</p>
<div class="space-y-6">
<div class="flex flex-col items-center py-2">
{#if w.loadingBalance}
<div class="py-1"><Loader /></div>
<div class="h-12 w-40 rounded-lg bg-muted animate-pulse"></div>
{:else}
<div class="flex items-baseline gap-2">
<p class="font-mono text-lg">{w.balance} XRP</p>
<p class="text-sm text-muted-foreground">Balance</p>
<p class="mt-1 font-mono text-4xl font-semibold tabular-nums tracking-tight">
{w.balance} <span class="text-muted-foreground text-2xl">XRP</span>
</p>
{#if w.price}
<p class="font-mono text-sm text-muted-foreground">
≈ ${(
parseFloat(w.balance) * parseFloat(w.price)
).toLocaleString(undefined, {
<p class="mt-1 text-sm text-muted-foreground tabular-nums">
≈ ${(parseFloat(w.balance) * parseFloat(w.price)).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</p>
{/if}
</div>
{#if !w.funded}
<p class="text-xs text-muted-foreground">
Account not activated — it needs at least 10 XRP to
meet the base reserve.
<p class="mt-2 text-xs text-muted-foreground">
Account not activated — needs at least 10 XRP
</p>
{/if}
{/if}
</div>
<div class="flex items-center justify-center">
<Button onclick={() => navigate('/xrp/send')} class="gap-2">
<Send size={16} />
Send
</Button>
</div>
<div class="mx-auto max-w-md">
<div class="flex items-center justify-between rounded-lg border border-border px-4 py-3">
<div class="min-w-0 flex-1">
<p class="text-xs text-muted-foreground mb-0.5">Address</p>
<p class="font-mono text-xs break-all">{w.address}</p>
</div>
<div class="flex shrink-0 gap-1 ml-2">
<Dialog.Root bind:open={qrDialogOpen}>
<Dialog.Trigger>
<Button variant="ghost" size="icon-sm" class="text-muted-foreground" aria-label="QR code">
<QrCodeIcon size={14} />
</Button>
</Dialog.Trigger>
<Dialog.Content>
<div class="flex flex-col items-center gap-3 py-4">
<QrCode text={w.address} size={256} />
<p class="font-mono text-xs break-all text-center max-w-64">{w.address}</p>
</div>
</Dialog.Content>
</Dialog.Root>
<CopyButton text={w.address} variant="ghost" size="icon-sm" class="text-muted-foreground" />
<Button
variant="ghost"
size="icon-sm"
class="text-muted-foreground"
aria-label="View on explorer"
onclick={() => electrobun.rpc?.request.openExternal({ url: w.explorerAddressUrl })}>
<ExternalLink size={14} />
</Button>
</div>
</div>
{#if w.fee}
<p class="text-xs text-muted-foreground mt-1">
<p class="mt-2 text-center text-xs text-muted-foreground">
Network fee: ~{w.fee} XRP
</p>
{/if}
{/if}
</div>
<div class="flex gap-2">
<Button
variant="outline"
onclick={() => navigate('/xrp/send')}>
Send
</Button>
<Button
variant="outline"
onclick={() => navigate('/settings')}>
<SettingsIcon size={16} />
</Button>
<Button
variant="outline"
onclick={async () => {
await w.logout()
navigate('/')
}}>
Logout
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Transactions</CardTitle>
<CardTitle class="text-sm">Transactions</CardTitle>
</CardHeader>
<CardContent>
{#if w.loadingTransactions}
<div class="flex justify-center py-4">
<Loader />
</div>
<div class="flex justify-center py-8"><Loader /></div>
{:else if w.transactions.length === 0}
<p class="text-muted-foreground text-xs">
No transactions found
</p>
<p class="text-center text-sm text-muted-foreground py-8">No transactions yet</p>
{:else}
<div class="max-h-96 space-y-1 overflow-y-auto">
<div class="space-y-1">
{#each w.transactions as tx}
<button
onclick={() =>
navigate('/xrp/tx/:hash', {
params: { hash: tx.hash },
})}
class="flex w-full items-start gap-2 rounded-md bg-muted px-3 py-2 text-xs text-left cursor-pointer hover:bg-muted/80 transition-colors">
<div
class="shrink-0 mt-0.5 {tx.direction === 'in'
? 'text-green-500'
: 'text-muted-foreground'}">
onclick={() => navigate('/xrp/tx/:hash', { params: { hash: tx.hash } })}
class="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left hover:bg-muted transition-colors">
<div class="flex size-8 shrink-0 items-center justify-center rounded-full {tx.direction === 'in' ? 'bg-muted' : 'bg-muted'}">
<div class={tx.direction === 'in' ? 'text-green-500' : 'text-muted-foreground'}>
{#if tx.direction === 'in'}
<ArrowDown size={16} />
{:else}
<ArrowUp size={16} />
{/if}
</div>
<div class="min-w-0 flex-1 space-y-0.5">
<p class="font-medium">
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium">
{tx.direction === 'in' ? 'Received' : 'Sent'}
{#if !tx.confirmed}
<span class="text-muted-foreground">
(unconfirmed)
</span>
<span class="text-muted-foreground text-xs ml-1">(unconfirmed)</span>
{/if}
</p>
<p class="font-mono text-muted-foreground">
{tx.amount} XRP
</p>
<p class="text-xs text-muted-foreground">
{#if tx.timestamp && tx.timestamp !== '0'}
<p class="text-muted-foreground">
{new Date(
Number(tx.timestamp) * 1000,
).toLocaleString()}
</p>
{/if}
<p
class="text-muted-foreground/50 truncate font-mono">
{new Date(Number(tx.timestamp) * 1000).toLocaleString()}
{:else}
{tx.hash.slice(0, 12)}...
{/if}
</p>
</div>
<p class="shrink-0 font-mono text-sm tabular-nums {tx.direction === 'in' ? 'text-green-500' : ''}">
{tx.direction === 'in' ? '+' : '-'}{tx.amount} XRP
</p>
</button>
{/each}
</div>
{/if}
</CardContent>
</Card>
</div>
</div>