epicenter-ui
$
npx mdskill add EpicenterHQ/epicenter/epicenter-uiSelect and compose Svelte UI components from @epicenter/ui for consistent state surfaces
- Solves inconsistent UI patterns like custom loading dots or raw placeholders
- Uses @epicenter/ui package with pre-built loading, empty, and action states
- Matches component choice to state context like tables, buttons, or wrappers
- Delivers ready-to-use UI patterns with spacing, color, and accessibility built in
SKILL.md
.github/skills/epicenter-uiView on GitHub ↗
---
name: epicenter-ui
description: Epicenter UI component selection and composition patterns for Svelte apps using packages/ui. Use when choosing or reviewing @epicenter/ui components, loading states, empty states, skeletons, spinners, command empty states, action pending UI, table/list no-row states, button tooltips, wrapper minimization, or replacing ad hoc UI such as Loading... text, custom loading dots, raw animate-pulse placeholders, or one-off centered status markup.
metadata:
author: epicenter
version: '1.0'
---
# Epicenter UI
Use the local `@epicenter/ui` package before writing one-off UI. Most state surfaces already have a component with spacing, color, accessibility, and composition handled.
Related skills:
- Use `svelte` for branch mechanics: `{#if}`, `{#await}`, derived state, query state, and component lifecycle.
- Use `styling` for Tailwind details, wrapper element decisions, scroll traps, and disabled-state styling.
- Use this skill for local component choice and composition.
## Reference Repositories
- [shadcn-svelte](https://github.com/huntabyte/shadcn-svelte): component structure and Svelte composition patterns
- [shadcn-svelte-extras](https://github.com/ieedan/shadcn-svelte-extras): chat components and extra UI patterns
- [TanStack Table](https://github.com/TanStack/table): headless table state, not table empty UI
- [Autumn](https://github.com/useautumn/autumn): billing and usage UI contexts where pending, progress, and empty states matter
## Loading State Choice
Pick the component by what the user is waiting for:
- Full surface pending: use `Empty.Root` with `Spinner` when the whole surface is replaced.
- Known progress: use `Progress`, not a spinner.
- Content shape is known: use `Skeleton`, not raw `animate-pulse` divs.
- Button action pending: disable the `Button` and put a small `Spinner` inside it.
- Chat assistant typing: use `Chat.BubbleMessage typing`. `LoadingDots` is only for chat bubbles.
- Command search with no matches: use `Command.Empty`.
- No rows, no files, no results, or failed surface: use `Empty.*`.
Do not show plain text such as `Loading...` by itself. Pair status text with an affordance, usually `Spinner`, and choose text that says what is happening: `Checking session`, `Loading tabs`, `Downloading model`.
## Composition And Wrappers
Collapse wrapper elements whenever a component can own the layout directly. `Empty.Root` already centers content, lays out a column, sets text alignment, and accepts `class`, so full-surface pending, empty, and error states usually do not need an outer `div`.
```svelte
<!-- Prefer this -->
<Empty.Root class="h-dvh flex-none border-0" aria-live="polite">
<Empty.Media>
<Spinner class="size-5 text-muted-foreground" />
</Empty.Media>
<Empty.Title>Checking session</Empty.Title>
</Empty.Root>
<!-- Avoid this -->
<div class="flex h-dvh items-center justify-center">
<Empty.Root class="border-0">
<Empty.Title>Checking session</Empty.Title>
</Empty.Root>
</div>
```
Add a wrapper only when it owns a real layout boundary that the component should not own: scroll containment, pane sizing, table cell structure, sticky headers, or sibling spacing.
Use this boundary ladder before copying or forking component internals:
1. Use an existing local `@epicenter/ui` component and variant.
2. Pass a `class` or supported prop.
3. Add a local variant to the wrapper component.
4. Wrap the component for a real composition boundary.
5. Copy upstream component code only when Epicenter needs to own behavior, tokens, persistence, shortcuts, or app state.
Import compound components as namespaces, such as `import * as Dialog from '@epicenter/ui/dialog'`. Import single components by name, such as `import { Button } from '@epicenter/ui/button'`.
Dialog, Sheet, and Drawer surfaces need accessible titles. Use an `sr-only` title when the visual design already supplies equivalent context. Form controls with validation state should expose `aria-invalid` or the local component equivalent.
Before pulling upstream shadcn-svelte component updates, commit local wrapper state. Then reconcile upstream changes against local deltas instead of overwriting the wrapper.
## Empty States
Use the `Empty.*` compound component for an absent or failed surface:
```svelte
<script lang="ts">
import * as Empty from '@epicenter/ui/empty';
import FolderOpenIcon from '@lucide/svelte/icons/folder-open';
</script>
<Empty.Root class="py-8">
<Empty.Media variant="icon">
<FolderOpenIcon class="size-5" />
</Empty.Media>
<Empty.Title>No recordings yet</Empty.Title>
<Empty.Description>Record audio to see transcripts here.</Empty.Description>
</Empty.Root>
```
Use `Empty.Content` when the state has an action button. Keep the title short and let the description explain the next step.
## Pending Surfaces
When pending replaces a whole pane or page, use the same structure you would use for the error branch. This keeps layout and alignment stable:
```svelte
<script lang="ts">
import * as Empty from '@epicenter/ui/empty';
import { Spinner } from '@epicenter/ui/spinner';
</script>
<Empty.Root class="h-dvh border-0" aria-live="polite">
<Empty.Media>
<Spinner class="size-5 text-muted-foreground" />
</Empty.Media>
<Empty.Title>Checking session</Empty.Title>
</Empty.Root>
```
For inline pending inside an existing surface, keep the wrapper small and only use it when no existing element can take the layout classes:
```svelte
<div class="flex h-full items-center justify-center">
<Spinner class="size-5 text-muted-foreground" />
</div>
```
## Button Pending
```svelte
<script lang="ts">
import { Button } from '@epicenter/ui/button';
import { Spinner } from '@epicenter/ui/spinner';
</script>
<Button onclick={save} disabled={isSaving}>
{#if isSaving}
<Spinner class="size-3.5" />
<span>Saving</span>
{:else}
Save
{/if}
</Button>
```
Keep the label when the action needs context. Icon-only pending is fine for compact row actions where the button tooltip or surrounding label already names the action.
## Button Tooltips
`Button` has a built-in `tooltip` prop. Use it before hand-wrapping a button with `Tooltip.Root`, `Tooltip.Trigger`, and `Tooltip.Content`.
```svelte
<Button
size="icon"
variant="ghost"
tooltip="Delete recording"
onclick={deleteRecording}
>
<TrashIcon />
</Button>
```
The built-in tooltip expects a parent `Tooltip.Provider` somewhere above the button. Hand-roll tooltip composition only when the trigger is not a `Button`, the content needs custom markup, or the interaction is not a simple button tooltip.
## Table and List Empty States
TanStack Table is headless. It gives row state, sorting, filtering, and pagination, but it does not decide what empty UI should look like. When `table.getRowModel().rows.length === 0`, render `Empty.Root` in the table body or the surrounding list panel.
Use different copy for true empty data and filtered empty data:
```svelte
{#if rows.length === 0}
<Empty.Root class="min-h-64 border-0">
<Empty.Title>
{filter ? 'No results match your filters' : 'No recordings yet'}
</Empty.Title>
<Empty.Description>
{filter ? 'Try a different search term.' : 'Record audio to see transcripts here.'}
</Empty.Description>
</Empty.Root>
{/if}
```
## Avoid
- Plain `Loading...` text.
- Raw `Loader2Icon`, `LoaderCircleIcon`, or custom `animate-spin` outside `packages/ui`. Use `Spinner`.
- Raw `animate-pulse` placeholder blocks. Use `Skeleton`.
- Loading dots outside chat messages.
- Empty state copy inside an unstructured centered `div` when `Empty.*` would fit.
- Tooltip wrappers around `Button` when the `tooltip` prop is enough.
- Extra wrapper `div`s around `Empty.Root` just to center or stack content.
- Duplicating component internals from shadcn-svelte or extras instead of importing the local `@epicenter/ui` wrapper.
## Boundary With Svelte
Svelte decides which branch renders: `{#if}`, `{#await}`, query status, or derived state. Epicenter UI decides what the branch looks like: `Spinner`, `Skeleton`, `Progress`, `Empty`, `Command.Empty`, `Button tooltip`, or chat typing state.
## Extras And Chat
- Prefer existing local extras such as copy buttons, snippets, links, and chat components before adding one-off equivalents.
- Chat list, message bubble variants, typing state, copy actions, and auto-scroll behavior belong in local wrappers. App code should compose them, not duplicate their internals.
- Copy small generic primitives into `packages/ui` when the primitive is stable and visual. Wrap instead when Epicenter adds domain behavior or persistent app state.
More from EpicenterHQ/epicenter
- agent-goalWrite `/goal` prompts for long-running agent work in Codex or Claude Code. Use for slash goal, agent goal, durable objective, autonomous coding run.
- approachability-auditReview code as a new TypeScript developer. Use when code feels indirect, clever, hard to follow, or needs a pass on abstractions, names, first-read clarity.
- arktypeArktype: runtime validation, discriminated unions with .merge()/.or(), spread keys. Use when mentioning arktype, type(), union types, command/event schemas.
- attach-primitiveContract and invariants for `attach*` composition primitives in `packages/workspace` (side-effectful building blocks like attachIndexedDb, attachSqlite, attachBroadcastChannel, attachEncryption, attachTable, openCollaboration), and when to use `create*` (pure construction) instead. Use when writing or reviewing an `attach*` or `create*` function, naming a new workspace primitive, composing inside a workspace builder, or deciding whether a primitive registers listeners at call time.
- authEpicenter auth packages: `@epicenter/auth`, `@epicenter/auth-svelte`, OAuth sessions, identity state, auth-owned fetch/WebSocket, and workspace lifecycle binding. Use when editing Epicenter auth clients, session state, hosted sign-in, or auth/workspace integration.
- autumnAutumn billing in Epicenter: `autumn.config.ts`, `autumn-js` credit checks, `atmn` CLI, plan gates, and metered AI usage. Use when changing billing, pricing, credits, plan access, refunds, or usage events.
- better-auth-best-practicesBetter Auth server/client setup: `auth.ts`, generated schema, DB adapters, sessions, cookies, env vars, and plugins. Use when mentioning Better Auth, betterauth, auth handlers, OAuth, email/password, or session configuration.
- better-auth-security-best-practicesBetter Auth security hardening: rate limits, secrets, CSRF, trusted origins, cookies, sessions, OAuth tokens, and audit logging. Use when reviewing auth security, brute-force protection, token handling, or deployment safety.
- change-proposalPresent proposed code changes visually before implementing. Use when: "show me options", "compare approaches", "what should we do", or when changes need before/after comparison.
- claude-code-consultUse this skill when the user asks to consult Claude, ask Claude Code, get another model's take, run a taste check, find cleaner options, or prepare a Claude prompt. Create a bounded second-opinion prompt or run a read-only Claude Code consult, then verify Claude's claims against local files.